iOS NSArray 、NSMutableArray原理揭露
在iOS開發中,我們在非常非常多的地方用到了陣列。而關於陣列,有很多需要注意和優化的細節,需要我們潛入到下面,去了解。
閱讀《Effective Objective-C 2.0》的原版的時候,我發現了之前沒怎麼注意到的一段話:
In the case of NSArray, when an instance is allocated, it’s an instance of another class that’s allocated (during a call to alloc), known as a placeholder array. This placeholder array is then converted to an instance of another class, which is a concrete subclass of NSArray.
在使用了NSArray的alloc方法來獲取例項時,該方法首先會分類一個屬於某類的例項,此例項充當“佔位陣列”。該陣列稍後會轉為另一個類的例項,而那個類則是NSArray的實體子類。
話不多說,程式碼寫兩行:
NSArray*placeholder = [ NSArrayalloc];
NSArray*arr1 = [[ NSArrayalloc] init];
NSArray*arr2 = [[ NSArrayalloc] initWithObjects:@ 0, nil];
NSArray*arr3 = [[ NSArrayalloc] initWithObjects:@ 0, @ 1, nil];
NSArray*arr4 = [[ NSArrayalloc] initWithObjects:@ 0, @ 1, @ 2, nil];
NSLog( @"placeholder: %s", object_getClassName(placeholder));
NSLog( @"arr1: %s", object_getClassName(arr1));
NSLog( @"arr2: %s", object_getClassName(arr2));
NSLog( @"arr3: %s", object_getClassName(arr3));
NSLog( @"arr4: %s", object_getClassName(arr4));
NSMutableArray*mPlaceholder = [ NSMutableArrayalloc];
NSMutableArray*mArr1 = [[ NSMutableArrayalloc] init];
NSMutableArray*mArr2 = [[ NSMutableArrayalloc] initWithObjects:@ 0, nil];
NSMutableArray*mArr3 = [[ NSMutableArrayalloc] initWithObjects:@ 0, @ 1, nil];
NSLog( @"mPlaceholder: %s", object_getClassName(mPlaceholder));
NSLog( @"mArr1: %s", object_getClassName(mArr1));
NSLog( @"mArr2: %s", object_getClassName(mArr2));
NSLog( @"mArr3: %s", object_getClassName(mArr3));
打印出來的結果是這樣的:
2018 -02-2509 :09:15.628381+0800NSArrayTest[44716:5228210]placeholder: __ NSPlaceholderArray
2018 -02-2509 :09:15.628749+0800NSArrayTest[44716:5228210]arr1: __ NSArray0
2018 -02-2509 :09:15.629535+0800NSArrayTest[44716:5228210]arr2: __ NSSingleObjectArrayI
2018 -02-2509 :09:15.630635+0800NSArrayTest[44716:5228210]arr3: __ NSArrayI
2018 -02-2509 :09:15.630789+0800NSArrayTest[44716:5228210]arr4: __ NSArrayI
2018 -02-2509 :09:15.630993+0800NSArrayTest[44716:5228210]mPlaceholder: __ NSPlaceholderArray
2018 -02-2509 :09:15.631095+0800NSArrayTest[44716:5228210]mArr1: __ NSArrayM
2018 -02-2509 :09:15.631954+0800NSArrayTest[44716:5228210]mArr2: __ NSArrayM
2018 -02-2509 :09:15.632702+0800NSArrayTest[44716:5228210]mArr3: __ NSArrayM
清晰易懂,我們可以看到,不管建立的事可變還是不可變的陣列,在alloc之後得到的類都是 __NSPlaceholderArray。而當我們init一個不可變的空陣列之後,得到的是**__NSArray0**;如果有且只有一個元素,那就是 __NSSingleObjectArrayI;有多個元素的,叫做 __NSArrayI;init出來一個可變陣列的話,都是 __NSArrayM。
我們看到__NSPlaceholderArray的名字就知道它是用來佔位的。
那它是什麼呢?我們繼續寫幾行程式碼:
NSArray*placeholder1 = [ NSArrayalloc];
NSArray*placeholder2 = [ NSArrayalloc];
NSLog( @"placeholder1: %p", placeholder1);
NSLog( @"placeholder2: %p", placeholder2);
打印出來的結果很有意思
2018 -02-2509 :41:45.097431+0800NSArrayTest[45228:5277101]placeholder1: 0 x604000005d90
2018 -02-2509 :41:45.097713+0800NSArrayTest[45228:5277101]placeholder2: 0 x604000005d90
這兩個記憶體地址是一樣的,我們可以猜測,這裡是生成了一個單例,在執行init之後就被新的例項給更換掉了。該類內部只有一個isa指標,除此之外沒有別的東西。
由於蘋果沒有公開此處的原始碼,我查閱了別的類似的開源以及資料,得到如下的結論:
- 當元素為空時,返回的是__NSArray0的單例;
- 當元素僅有一個時,返回的是__NSSingleObjectArrayI的例項
- 當元素大於一個的時候,返回的是__NSArrayI的例項;
- 網上的資料,大多未提及__NSSingleObjectArrayI,可能是後面新增的,理由大概還是為了效率,在此不深究。
為了區別可變和不可變的情況,在init的時候,會根據是NSArray還是NSMutableArray來建立immutablePlaceholder和mutablePlaceholder,它們都是__NSPlaceholderArray型別的。
建立陣列
在上面的多種建立陣列的方法裡,都是最後呼叫了initWithObjects:count:函式。
@interfaceNSArray<__covariantObjectType> : NSObject<NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>
@property( readonly) NSUIntegercount;
- (ObjectType)objectAtIndex:( NSUInteger)index;
- ( instancetype)init NS_DESIGNATED_INITIALIZER;
- ( instancetype)initWithObjects:( constObjectType _Nonnull [_Nullable])objects count:( NSUInteger)cnt NS_DESIGNATED_INITIALIZER;
- ( nullableinstancetype)initWithCoder:( NSCoder*)aDecoder NS_DESIGNATED_INITIALIZER;
@end
這就是類族的優點,在建立某個類族的子類的時候,我們不需要實現所有的功能。在CoreFoundation的類蔟的抽象工廠基類(如NSArray、NSString、NSNumber等)中,Primitive methods指的就是這些核心的方法,也就是那些在建立子類時必須要重寫的方法,通常在類的interface中宣告,在文件中一般也會說明。其他可選實現的方法在Category中宣告。同時還需要注意其整個繼承樹的祖先的Primitive methods也都需要實現。
CFArray和NSMutableArray
CFArray是CoreFoundation中的,和Foundation中的NSArray相對應,他們是Toll-Free Bridged的。通過閱讀 ibireme的這篇部落格,我們可以知道,CFArray最開始是使用雙端佇列實現的,但是因為效能問題,後來發生了改變,因為沒有開原始碼,ibireme只能通過測試來猜測它可能換成圓形緩衝區來實現了(但是現在可以確定還是雙端佇列)。
任何典型的程式設計師都知道 C 陣列的原理。可以歸結為一段能被方便讀寫的連續記憶體空間。陣列和指標並不相同 ,不能說:一塊被 malloc過的記憶體空間等同於一個數組 (一種被濫用了的說法)。
使用一段線性記憶體空間的一個最明顯的缺點是,在下標 0 處插入一個元素時,需要移動其它所有的元素,即 memmove的原理:
同樣地,假如想要保持相同的記憶體指標作為首個元素的地址,移除第一個元素需要進行相同的動作:
當陣列非常大時,這樣很快會成為問題。顯而易見,直接指標存取在陣列的世界裡必定不是最高階的抽象。C 風格的陣列通常很有用,但 Obj-C 程式設計師每天的主要工作使得它們需要 NSMutableArray 這樣一個可變的、可索引的容器。這裡,我們需要閱讀這篇部落格(http://ciechanowski.me/blog/2014/03/05/exposing-nsmutablearray/)。在這裡我們可以確定使用了環形緩衝區。正如你會猜測的,__NSArrayM用了環形緩衝區 (circular buffer)。這個資料結構相當簡單,只是比常規陣列或緩衝區複雜點。環形緩衝區的內容能在到達任意一端時繞向另一端。
環形緩衝區有一些非常酷的屬性。尤其是,除非緩衝區滿了,否則在任意一端插入或刪除均不會要求移動任何記憶體。我們來分析這個類如何充分利用環形緩衝區來使得自身比 C 陣列強大得多。我們在這裡知道了幾個有趣的東西:在刪除的時候不會清除指標。最有意思的一點,如果我們在中間進行插入或者刪除,只會移動最少的一邊的元素。
NSMutableArray的方法
正如 NSMutableArray Class Reference 的討論,每個 NSMutableArray 子類必須實現下面 7 個方法:
- count
- objectAtIndex:
- insertObject:atIndex:
- removeObjectAtIndex:
- addObject:
- removeLastObject
- replaceObjectAtIndex:withObject:
毫不意外的是,__NSArrayM 履行了這個規定。然而,__NSArrayM 的所有實現方法列表相當短且不包含 21 個額外的在 NSMutableArray 標頭檔案列出來的方法。誰負責執行這些方法呢?
這證明它們只是 NSMutableArray 類自身的一部分。這會相當的方便:任何 NSMutableArray 的子類只須實現 7 個最基本的方法。所有其它高等級的抽象建立在它們的基礎之上。例如 - removeAllObjects 方法簡單地往回迭代,一個個地呼叫 - removeObjectAtIndex:。
遍歷陣列的n個方法
1.for 迴圈
for( inti = 0; i < array.count; ++i) {
id object = array[i];
}
2.NSEnumerator
NSArray*anArray = /*...*/;
NSEnumerator*enumerator = [anArray objectEnumerator];
idobject;
while((object = [enumerator nextObject])!= nil){
}
3.forin
快速遍歷
NSArray*anArray = /*...*/;
for( idobject inanArray) {
}
4.enumerateObjectsWithOptions:usingBlock:
通過block回撥,在子執行緒中遍歷,物件的回撥次序是亂序的,而且呼叫執行緒會等待該遍歷過程完成:
[array enumerateObjectsWithOptions: NSEnumerationConcurrent
usingBlock:^( id_Nonnull obj, NSUIntegeridx, BOOL* _Nonnull stop) {
xxx
}];
效能比較如圖
橫軸為遍歷的物件數目,縱軸為耗時,單位us.從圖中看出,在物件數目很小的時候,各種方式的效能差別微乎其微。隨著物件數目的增大, 效能差異才體現出來.其中for in的耗時一直都是最低的,當物件數高達100萬的時候,for in耗時也沒有超過5ms.
其次是for迴圈耗時較低.反而,直覺上應該非常快速的多執行緒遍歷方式卻是效能最差的。
我們來看一下陣列的內部結構:
NSArray
和NSMutableArray
都沒有定義例項變數,只是定義和實現了介面,且對內部資料操作的介面都是在各個子類中實現的.所以真正需要了解的是子類結構,瞭解了__NSArrayI
就相當於瞭解NSArray
,瞭解了__NSArrayM
就相當於瞭解NSMutableArray
.1. __NSArrayI
__NSArrayI的結構定義為:
@interface __NSArrayI : NSArray
{
NSUInteger _used;
id _list[0];
}
@end
_used
是陣列的元素個數,呼叫[array count]
時,返回的就是_used
的值。id _list[0]
是陣列內部實際儲存物件的陣列,但為何定義為0長度呢?這裡有一篇關於0長度陣列的文章:http://blog.csdn.net/zhaqiwen/article/details/7904515
這裡我們可以把id _list[0]
當作id *_list
來用,即一個儲存id
物件的buff
.
由於__NSArrayI
的不可變,所以_list
一旦分配,釋放之前都不會再有移動刪除操作了,只有獲取物件一種操作.因此__NSArrayI
的實現並不複雜.2. __NSSingleObjectArrayI
__NSSingleObjectArrayI的結構定義為:
@interface __NSSingleObjectArrayI : NSArray
{
id object;
}
@end
因為只有在"建立只包含一個物件的不可變陣列"時,才會得到__NSSingleObjectArrayI
物件,所以其內部結構更加簡單,一個object
足矣.3. __NSArrayM
__NSArrayM的結構定義為:
@interface __NSArrayM : NSMutableArray
{
NSUInteger _used;
NSUInteger _offset;
int _size:28;
int _unused:4;
uint32_t _mutations;
id *_list;
}
@end
__NSArrayM
稍微複雜一些,但是同樣的,它的內部物件陣列也是一塊連續記憶體id* _list
,正如__NSArrayI
的id _list[0]
一樣_used
:當前物件數目_offset
:實際物件陣列的起始偏移,這個欄位的用處稍後會討論_size
:已分配的_list
大小(能儲存的物件個數,不是位元組數)_mutations
:修改標記,每次對__NSArrayM
的修改操作都會使_mutations
加1,“*** Collection <__NSArrayM: 0x1002076b0> was mutated while being enumerated.
”這個異常就是通過對_mutations
的識別來引發的
id *_list
是個迴圈陣列.並且在增刪操作時會動態地重新分配以符合當前的儲存需求.以一個初始包含5個物件,總大小_size
為6的_list
為例:_offset = 0
,_used = 5
,_size=6
在末端追加3個物件後:_offset = 0
,_used = 8
,_size=8
_list
已重新分配
刪除物件A:_offset = 1
,_used = 7
,_size=8
刪除物件E:
_offset = 2
,_used = 6
,_size=8
B,C往後移動了,E的空缺被填補
在末端追加兩個物件:_offset = 2
,_used = 8
,_size=8
_list
足夠儲存新加入的兩個物件,因此沒有重新分配,而是將兩個新物件儲存到了_list
起始端
因此可見,__NSArrayM
的_list
是個迴圈陣列,它的起始由_offset
標識.
遍歷的速度特點探究
1.for 迴圈&for in
這兩個速度是最快的,我們就以forin為例。forin遵從了NSFastEnumeration協議,它只有一個方法:
- ( NSUInteger)countByEnumeratingWithState:
( NSFastEnumerationState*)state
objects:( id*)stackbuffer
count:( NSUInteger)len;
它直接從C陣列中取物件。對於可變陣列來說,它最多隻需要兩次就可以獲取全部全速。如果陣列還沒有構成迴圈,那麼第一次就獲得了全部元素,跟不可變陣列一樣。但是如果陣列構成了迴圈,那麼就需要兩次,第一次獲取物件陣列的起始偏移到迴圈陣列末端的元素,第二次獲取存放在迴圈陣列起始處的剩餘元素。而for迴圈之所以慢一點,是因為for迴圈的時候每次都要呼叫objectAtIndex:假如我們遍歷的時候不需要獲取當前遍歷操作所針對的下標,我們就可以選擇forin。
2.block迴圈
這種迴圈雖然是最慢的,但是我們在遍歷的時候可以直接從block中獲取更多的資訊,並且可以修改塊的方法簽名,以免進行型別轉換操作。
for( NSString*key inaDictionary){
NSString*object = ( NSString*)aDictionary[key];
}
NSDictionary*aDictionary = /*...*/;
[aDictionary enumerateKeysAndObjectsUsingBlock:
^( NSString*key, NSString*obj, BOOL*stop){
}];
並且如果需要需要併發的時候,也可以方便的使用dispatch group。
另外還有一點:如果陣列的數量過多,除了block遍歷,其他的遍歷方法都需要新增autoreleasePool方法來優化。block不需要,因為系統在實現它的時候就已經實現了相關處理。