在 iOS 開發中,NSArray
是一個很重要的資料結構。尤其 TableView 中的資料快取與更新, NSArray
來快取資料以及對於顯示資料的修改操作。而在 Core Foundation 中 CFArray
與 NSArray
相互對應,這引起了筆者對 Core Foundation 和 Foundation 庫中的原生資料結構實現產生興趣,所以來研究一下。
CFArray 歷史淵源
NSArray
和 CFArray
是 Toll-Free Bridged 的,在 opensource.apple.com 中, CFArray
是開源的。這更有助於我們的學習與研究。在 Garan no Dou 大神之前在做個人工具庫的時候,曾經研究過 CFArray
的歷史淵源和實現手段,在閱讀此文之前可以參考一下前輩的優秀博文。
Array 這篇 2005 年的早期文獻中,最早介紹過 CFArray
,並且測試過其效能水平。它將 CFArray
和 STL 中的 Vector
容器進行了效能對比,由於後者的實現我們可以理解成是對 C 中的陣列封裝,所以在效能圖上大多數操作都是線性的。而在 CFArray
的圖中,會發現很多不一樣的地方。
上圖分析可以看出, CFArray
在頭插、尾插插入時候的效率近乎常數,而對於中間元素的操作會從小資料的線性效率在一個閥值上突然轉變成線性效率,而這個躍變灰不由得想起在 Java 8 當中的 HashMap
的資料結構轉變方式。
在 ObjC 的初期,CFArray
是使用 deque 雙端佇列 實現,所以會呈現出頭尾操作高效,而中間操作成線性的特點。在容量超過 300000 左右時(實際應該是 262140 = 2^18 ),時間複雜度發生陡變。在原始碼中,閥值被巨集定義為 __CF_MAX_BUCKETS_PER_DEQUE
,具體程式碼可以見 CF-550-CFArray.c (2011 年版本):
1 2 3 4 5 6 7 |
if (__CF_MAX_BUCKETS_PER_DEQUE futureCnt) { // 建立 CFStorage 引用 CFStorageRef store // 轉換 CFArray 為 Storage __CFArrayConvertDequeToStore(array); store = (CFStorageRef)array->_store; } |
可以看到,當資料超出閥值 __CF_MAX_BUCKETS_PER_DEQUE
的時候,會將資料結構從 CFArray
轉換成 CFStorage
。 CFStorage
是一個平衡二叉樹的結構,為了維護陣列的順序訪問,將 Node 的權值使用下標完成插入和旋轉操作。具體的體現可以看 CFStorageInsertValues
操作。具體程式碼可以檢視 CF-368.18-CFStorage.c 。
在 2011 年以後的 CF-635.15-CFArray.c 版本中, CFArray
取消了資料結構轉換這一功能。或許是為了防止大資料時候二叉樹建樹的時間抖動問題從而取消了這一特性。直接來看下資料結構的描述:
1 2 3 4 5 6 7 8 9 10 11 |
struct __CFArrayDeque { uintptr_t _leftIdx; // 自左開始下標位置 uintptr_t _capacity; // 當前容量 }; struct __CFArray { CFRuntimeBase _base; CFIndex _count; // 元素個數 CFIndex _mutations; // 元素抖動量 int32_t _mutInProgress; __strong void *_store; }; |
從命名上可以看出 CFArray
由單一的雙端佇列進行實現,而且記錄了一些容器資訊。
C 陣列的一些問題
C 語言中的陣列,會開闢一段連續的記憶體空間來進行資料的讀寫、儲存操作。另外說一句,陣列和指標並不相同。有一種被很多教材書籍上濫用的說法:一塊被 malloc 過的記憶體空間等於一個陣列。這是錯誤的。最簡單的解釋,指標需要申請一個指標區域來儲存(指向)一塊空間的起始位置,而陣列(的頭部)是對一塊空間起始位置的直接訪問。另外想了解更多可以看 Are pointers and arrays equivalent in C? 這篇博文。
C 中的陣列最顯著的缺點就是,在下標 0 處插入時,需要移動所有的元素(即 memmove()
函式的原理)。類似的,當刪除第一個元素、在第一個元素前插入一個元素也會造成 O(n)複雜度的操作 。然而陣列是常讀寫的容器,所以 O(n) 的操作會造成很嚴重的時間開銷。
當前版本中 CFArray 的部分實現細節
在 CF-855.17 中,我們可以看到當前版本的 CFArray
的實現。文件中對 CFArray
有如下的描述:
CFArray
實現了一個可被指標順序訪問的緊湊容器。其值可通過整數鍵(索引下標)進行訪問,範圍從 0 至 N-1,其中 N 是陣列中值的數量。稱其緊湊 (compact) 的原因是該容器進行刪除或插入某個值的時候,不會再記憶體空間中留下間隙,訪問順序仍舊按照原有鍵值數值大小排列,使得有效檢索集合範圍總是在整數範圍 [0, N-1] 之中。因此,特定值的下標可能會隨著其他元素插入至陣列或被刪除時而改變。
陣列有兩種型別:不可變(immutable) 型別在建立陣列之後,不能向其新增或刪除元素,而 可變(mutable) 型別可以新增或從中刪除元素。可變陣列的元素數量無限制(或者稱只受 CFArray
外部的約束限制,例如可用記憶體空間大小)。與所有的 CoreFoundation 集合型別同理,陣列將保持與元素物件的強引用關係。
為了進一步弄清 CFArray
的細節,我們來分析一下 CFArray
的幾個操作方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// 通過下標查詢元素值 const void *CFArrayGetValueAtIndex(CFArrayRef array, CFIndex idx) { // 這個函式尚未開源 // 通過給定的 CFTypeID 來驗證指定元素是否匹配 Core Foundation 橋接類 CF_OBJC_FUNCDISPATCHV(__kCFArrayTypeID, const void *, (NSArray *)array, objectAtIndex:idx); // 尚未開源 // 通過給定的 CFTypeID 來驗證 Core Foundation 型別合法性 __CFGenericValidateType(array, __kCFArrayTypeID); CFAssert2(0 idx && idx __CFArrayGetCount(array), __kCFLogAssertion, "%s(): index (%d) out of bounds", __PRETTY_FUNCTION__, idx); CHECK_FOR_MUTATION(array); // 從記憶體位置取出元素 return __CFArrayGetBucketAtIndex(array, idx)->_item; } // 返回查詢元素的地址 CF_INLINE struct __CFArrayBucket *__CFArrayGetBucketAtIndex(CFArrayRef array, CFIndex idx) { switch (__CFArrayGetType(array)) { // 只允許兩種陣列型別 // 不可變對應普通線性結構,可變對應雙端佇列 case __kCFArrayImmutable: case __kCFArrayDeque: // 取地址再加上索引偏移量,返回元素地址 return __CFArrayGetBucketsPtr(array) + idx; } return NULL; } |
通過索引下標查詢操作中,CFArray
仍然繼承了傳統陣列的連續地址空間的性質,所以其時間仍然可保持在 O(1) 複雜度,十分高效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
void CFArrayInsertValueAtIndex(CFMutableArrayRef array, CFIndex idx, const void *value) { // 通過給定的 CFTypeID 來驗證指定元素是否匹配 Core Foundation 橋接 CF_OBJC_FUNCDISPATCHV(__kCFArrayTypeID, void, (NSMutableArray *)array, insertObject:(id)value atIndex:(NSUInteger)idx); // 通過給定的 CFTypeID 來驗證 Core Foundation 型別合法性 __CFGenericValidateType(array, __kCFArrayTypeID); CFAssert1(__CFArrayGetType(array) != __kCFArrayImmutable, __kCFLogAssertion, "%s(): array is immutable", __PRETTY_FUNCTION__); CFAssert2(0 idx && idx __CFArrayGetCount(array), __kCFLogAssertion, "%s(): index (%d) out of bounds", __PRETTY_FUNCTION__, idx); // 型別檢查 CHECK_FOR_MUTATION(array); // 呼叫該函式進行具體的陣列變動過程 _CFArrayReplaceValues(array, CFRangeMake(idx, 0), &value, 1); } // 這個函式沒有經過 ObjC 的排程檢查,即 CF_OBJC_FUNCDISPATCHV 方法 // 所以為安全考慮,只能用在已經進行排程檢查的函式入口之後 void _CFArrayReplaceValues(CFMutableArrayRef array, CFRange range, const void **newValues, CFIndex newCount) { // 進一步型別檢查 CHECK_FOR_MUTATION(array); // 加鎖操作,增加自旋鎖防止競爭 BEGIN_MUTATION(array); // 宣告回撥 const CFArrayCallBacks *cb; // 偏移下標,元素總數,陣列改變後元素總數 CFIndex idx, cnt, futureCnt; const void **newv, *buffer[256]; // 獲取陣列中元素個數 cnt = __CFArrayGetCount(array); // 新陣列元素總數 = 原陣列元素總數 - 刪除的元素個數 + 增加的元素個數 futureCnt = cnt - range.length + newCount; CFAssert1(newCount futureCnt, __kCFLogAssertion, "%s(): internal error 1", __PRETTY_FUNCTION__); // 獲取陣列中定義的回撥方法 cb = __CFArrayGetCallBacks(array); // 構造分配釋放記憶體抽象 CFAllocatorRef allocator = __CFGetAllocator(array); // 需要的情況下持有新元素,併為其分配一個臨時緩衝區 // 標準是新元素的個數是否超過256 if (NULL != cb->retain && !hasBeenFinalized(array)) { newv = (newCount 256) ? (const void **)buffer : (const void **)CFAllocatorAllocate(kCFAllocatorSystemDefault, newCount * sizeof(void *), 0); if (newv != buffer && __CFOASafe) __CFSetLastAllocationEventName(newv, "CFArray (temp)"); // 為新元素增加資料緩衝區 for (idx = 0; idx newCount; idx++) { newv[idx] = (void *)INVOKE_CALLBACK2(cb->retain, allocator, (void *)newValues[idx]); } } else { newv = newValues; } // 資料抖動量自加 array->_mutations++; // 現在將一個陣列的儲存區域分成了三個部分,每個部分都有可能為空 // A: 從索引下標零的位置到小於 range.location 的區域 // B: 傳入的 range.location 區域 // C: 從 range.location + range.length 到陣列末尾 // 需要注意的是,索引0的位置不一定位於可用儲存的最低位,當變化位置新值數量與舊值數量不同時,B區域需要先釋放再替換,然後A和C中的值根據情況進行位移 if (0 range.length) { // 正常釋放變化區域操作 __CFArrayReleaseValues(array, range, false); } // B 區現在為清空狀態,需要重新填充資料 if (0) { // 此處隱藏了判斷條件和程式碼。 // 大概操作是排除其他的干擾項,例如 B 區資料未完全釋放等。 } else if (NULL == array->_store) { // 通過資料的首地址引用指標來判斷 B 區釋放 if (0) { // 此處隱藏了判斷條件和程式碼 // 排除干擾條件,例如 futureCnt 不合法等 } else if (0 futureCnt) { // 宣告一個雙端佇列物件 struct __CFArrayDeque *deque; // 根據元素總數確定環狀緩衝區域可載元素總個數 CFIndex capacity = __CFArrayDequeRoundUpCapacity(futureCnt); // 根據元素個數確定空間分配大小 CFIndex size = sizeof(struct __CFArrayDeque) + capacity * sizeof(struct __CFArrayBucket); // 通過緩衝區構造器來構造儲存快取 deque = (struct __CFArrayDeque *)CFAllocatorAllocate((allocator), size, isStrongMemory(array) ? __kCFAllocatorGCScannedMemory : 0); if (__CFOASafe) __CFSetLastAllocationEventName(deque, "CFArray (store-deque)"); // 確定雙端佇列左值 deque->_leftIdx = (capacity - newCount) / 2; deque->_capacity = capacity; __CFAssignWithWriteBarrier((void **)&array->_store, (void *)deque); // 完成 B 區構造,安全釋放陣列 if (CF_IS_COLLECTABLE_ALLOCATOR(allocator)) auto_zone_release(objc_collectableZone(), deque); } } else { // Deque // 根據 B 區元素變化,重新定位 A 和 C 區元素儲存狀態 if (0) { } else if (range.length != newCount) { // 傳入 array 引用,最終根據變化使得陣列更新A、B、C分割槽規則 __CFArrayRepositionDequeRegions(array, range, newCount); } } // 將區域B的新變化拷貝到B區域 if (0 newCount) { if (0) { } else { // Deque // 訪問線性儲存區 struct __CFArrayDeque *deque = (struct __CFArrayDeque *)array->_store; // 在原基礎上,增加一段快取區域 struct __CFArrayBucket *raw_buckets = (struct __CFArrayBucket *)((uint8_t *)deque + sizeof(struct __CFArrayDeque)); // 更改B區域資料,類似與 memcpy,但是有寫屏障(write barrier),執行緒安全 objc_memmove_collectable(raw_buckets + deque->_leftIdx + range.location, newv, newCount * sizeof(struct __CFArrayBucket)); } } // 設定新的元素個數屬性 __CFArraySetCount(array, futureCnt); // 釋放快取區域 if (newv != buffer && newv != newValues) CFAllocatorDeallocate(kCFAllocatorSystemDefault, newv); // 解除執行緒安全保護 END_MUTATION(array); } |
在 CFArray
的插入元素操作中,可以很清楚的看出這是一個雙端佇列(dequeue)的插入元素操作,而且是一種仿照 C++ STL 標準庫的儲存方式,緩衝區巢狀 map 表的靜態實現。用示意圖來說明一下資料結構:
在 STL 中的 deque,是使用的 map 表來記錄的對映關係,而在 Core Foundation 中,CFArray
在保證這樣的二次對映關係的時候很直接地運用了二階指標 _store
。在修改元素的操作中,CFArray
也略顯得暴力一些,先對陣列進行大塊的分割槽操作,再按照順序填充資料,組合成為一塊新的雙端佇列,例如在上圖中的雙端佇列中,在下標為 7 的元素之前增加一個值為 100
的元素:
根據索引下標會找到指定部分的快取區,將其拿出並進行重新構造。構造過程中或將其劃分成 A、B、C 三個區域,B 區域是修改部分。當然如果不夠的話,系統會自己進行快取區的擴容,即 CFAllocatorRef
官方提供的記憶體分配/釋放策略。
CFAllocatorRef
是 Core Foundation 中的分配和釋放記憶體的策略。多數情況下,只需要用預設分配器 kCFAllocatorDefault
,等價於傳入 NULL
引數,這用會用 Core Foundation 所謂的“常規方法”來分配和釋放記憶體。這種方法可能會有變化,我們不應該以來與任何特殊行為。用到特殊分配器的情況很少,下來是官方文件中給出的標準分配器及其功能。
KCFALLOCATORDEFAULT | 預設分配器,與傳入NULL 等價。 |
---|---|
kCFAllocatorSystemDefault | 原始的預設系統分配器。這個分配器用來應對萬一用CFAllocatorSetDefault 改變了預設分配器的情況,很少用到。 |
kCFAllocatorMalloc | 呼叫malloc 、realloc 和free 。如果用malloc 建立了記憶體,那這個分配器對於釋放CFData 和CFString 就很有用。 |
kCFAllocatorMallocZone | 在預設的malloc 區域中建立和釋放記憶體。在 Mac 上開啟了垃圾收集的話,這個分配器會很有用,但在 iOS 中基本上沒什麼用。 |
kCFAllocatorNull | 什麼都不做。跟kCFAllocatorMalloc 一樣,如果不想釋放記憶體,這個分配器對於釋放CFData 和CFString 就很有用。 |
KCFAllocatorUseContext | 只有CFAllocatorCreate 函式用到。建立CFAllocator 時,系統需要分配記憶體。就像其他所有的Create 方法,也需要一個分配器。這個特殊的分配器告訴CFAllocatorCreate 用傳入的函式來分配CFAllocator 。 |
在 _CFArrayReplaceValues
方法中的最後一個判斷:
1 2 |
if (newv != buffer && newv != newValues) CFAllocatorDeallocate(kCFAllocatorSystemDefault, newv); |
會檢查一下快取區的數量問題,如果數量過多會釋放掉多餘的快取區。這是因為這個方法具有通用性,不僅僅可以使用在插入元素操作,在增加(CFArrayAppendValue
)、替換(CFArrayReplaceValues
)、刪除(CFArrayRemoveValueAtIndex
)操作均可使用。由於將資料結構採取分塊管理,所以時間分攤,複雜度大幅度降低。所以,我們看到 CFArray
的時間複雜度在查詢、增添元素操作中均有較高的水平。
而在 NSMutableArray
的實現中,蘋果為了解決移動端的小記憶體特點,使用 CFArray
中在兩端增加可擴充的快取區則會造成大量的浪費。在 NSMutableArray原理揭露 一文中使用逆向的思路,挖掘 NSMutableArray
的實現原理,其做法是使用環形緩衝區對快取部分做到最大化的壓縮,這是蘋果針對於移動裝置的侷限而提出的方案。