CFArray 的歷史淵源及實現原理

發表於2017-02-03

在 iOS 開發中,NSArray 是一個很重要的資料結構。尤其 TableView 中的資料快取與更新, NSArray 來快取資料以及對於顯示資料的修改操作。而在 Core Foundation 中 CFArrayNSArray 相互對應,這引起了筆者對 Core Foundation 和 Foundation 庫中的原生資料結構實現產生興趣,所以來研究一下。

CFArray 歷史淵源

NSArrayCFArrayToll-Free Bridged 的,在 opensource.apple.com 中, CFArray 是開源的。這更有助於我們的學習與研究。在 Garan no Dou 大神之前在做個人工具庫的時候,曾經研究過 CFArray 的歷史淵源和實現手段,在閱讀此文之前可以參考一下前輩的優秀博文。

Array 這篇 2005 年的早期文獻中,最早介紹過 CFArray ,並且測試過其效能水平。它將 CFArray 和 STL 中的 Vector 容器進行了效能對比,由於後者的實現我們可以理解成是對 C 中的陣列封裝,所以在效能圖上大多數操作都是線性的。而在 CFArray 的圖中,會發現很多不一樣的地方。

11vector_results

12cfarray_results

上圖分析可以看出, CFArray 在頭插、尾插插入時候的效率近乎常數,而對於中間元素的操作會從小資料的線性效率在一個閥值上突然轉變成線性效率,而這個躍變灰不由得想起在 Java 8 當中的 HashMap 的資料結構轉變方式。

在 ObjC 的初期,CFArray 是使用 deque 雙端佇列 實現,所以會呈現出頭尾操作高效,而中間操作成線性的特點。在容量超過 300000 左右時(實際應該是 262140 = 2^18 ),時間複雜度發生陡變。在原始碼中,閥值被巨集定義為 __CF_MAX_BUCKETS_PER_DEQUE ,具體程式碼可以見 CF-550-CFArray.c (2011 年版本):

可以看到,當資料超出閥值 __CF_MAX_BUCKETS_PER_DEQUE 的時候,會將資料結構從 CFArray 轉換成 CFStorageCFStorage 是一個平衡二叉樹的結構,為了維護陣列的順序訪問,將 Node 的權值使用下標完成插入和旋轉操作。具體的體現可以看 CFStorageInsertValues 操作。具體程式碼可以檢視 CF-368.18-CFStorage.c

在 2011 年以後的 CF-635.15-CFArray.c 版本中, CFArray 取消了資料結構轉換這一功能。或許是為了防止大資料時候二叉樹建樹的時間抖動問題從而取消了這一特性。直接來看下資料結構的描述:

從命名上可以看出 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 的幾個操作方法:

通過索引下標查詢操作中,CFArray 仍然繼承了傳統陣列的連續地址空間的性質,所以其時間仍然可保持在 O(1) 複雜度,十分高效。

CFArray 的插入元素操作中,可以很清楚的看出這是一個雙端佇列(dequeue)的插入元素操作,而且是一種仿照 C++ STL 標準庫的儲存方式,緩衝區巢狀 map 表的靜態實現。用示意圖來說明一下資料結構:

13cfarray-1

在 STL 中的 deque,是使用的 map 表來記錄的對映關係,而在 Core Foundation 中,CFArray 在保證這樣的二次對映關係的時候很直接地運用了二階指標 _store。在修改元素的操作中,CFArray 也略顯得暴力一些,先對陣列進行大塊的分割槽操作,再按照順序填充資料,組合成為一塊新的雙端佇列,例如在上圖中的雙端佇列中,在下標為 7 的元素之前增加一個值為 100 的元素:

14cfarray-2.1

根據索引下標會找到指定部分的快取區,將其拿出並進行重新構造。構造過程中或將其劃分成 A、B、C 三個區域,B 區域是修改部分。當然如果不夠的話,系統會自己進行快取區的擴容,即 CFAllocatorRef 官方提供的記憶體分配/釋放策略。

CFAllocatorRef 是 Core Foundation 中的分配和釋放記憶體的策略。多數情況下,只需要用預設分配器 kCFAllocatorDefault ,等價於傳入 NULL 引數,這用會用 Core Foundation 所謂的“常規方法”來分配和釋放記憶體。這種方法可能會有變化,我們不應該以來與任何特殊行為。用到特殊分配器的情況很少,下來是官方文件中給出的標準分配器及其功能。

KCFALLOCATORDEFAULT 預設分配器,與傳入NULL等價。
kCFAllocatorSystemDefault 原始的預設系統分配器。這個分配器用來應對萬一用CFAllocatorSetDefault改變了預設分配器的情況,很少用到。
kCFAllocatorMalloc 呼叫mallocreallocfree。如果用malloc建立了記憶體,那這個分配器對於釋放CFDataCFString就很有用。
kCFAllocatorMallocZone 在預設的malloc區域中建立和釋放記憶體。在 Mac 上開啟了垃圾收集的話,這個分配器會很有用,但在 iOS 中基本上沒什麼用。
kCFAllocatorNull 什麼都不做。跟kCFAllocatorMalloc一樣,如果不想釋放記憶體,這個分配器對於釋放CFDataCFString就很有用。
KCFAllocatorUseContext 只有CFAllocatorCreate函式用到。建立CFAllocator時,系統需要分配記憶體。就像其他所有的Create方法,也需要一個分配器。這個特殊的分配器告訴CFAllocatorCreate用傳入的函式來分配CFAllocator

_CFArrayReplaceValues 方法中的最後一個判斷:

會檢查一下快取區的數量問題,如果數量過多會釋放掉多餘的快取區。這是因為這個方法具有通用性,不僅僅可以使用在插入元素操作,在增加(CFArrayAppendValue)、替換(CFArrayReplaceValues)、刪除(CFArrayRemoveValueAtIndex)操作均可使用。由於將資料結構採取分塊管理,所以時間分攤,複雜度大幅度降低。所以,我們看到 CFArray 的時間複雜度在查詢、增添元素操作中均有較高的水平。

而在 NSMutableArray 的實現中,蘋果為了解決移動端的小記憶體特點,使用 CFArray 中在兩端增加可擴充的快取區則會造成大量的浪費。在 NSMutableArray原理揭露 一文中使用逆向的思路,挖掘 NSMutableArray 的實現原理,其做法是使用環形緩衝區對快取部分做到最大化的壓縮,這是蘋果針對於移動裝置的侷限而提出的方案。

參考資料:

Let’s Build NSMutableArray

GNUStep · NSArray

What is the data structure behind NSMutableArray?

Apple Source Code – CF-855.17

相關文章