關注倉庫,及時得到更新:iOS-Source-Code-Analyze
Follow: Draveness · Githubgit由於 ObjC 的 runtime 只能在 Mac OS 下才能編譯,因此文章中的代碼都是在 Mac OS,也就是
x86_64
架構下運行的,對於在 arm64 中運行的代碼會特別說明。github
文章會介紹上古時代 Objective-C 哈希表,也就是 NXHashTable
:bootstrap
NXHashTable
的實現數組
NXHashTable
的性能分析數據結構
NXHashTable
的做用架構
NXHashTable
的實現有着將近 30 年的歷史,不過仍然做爲重要的底層數據結構存儲整個應用中的類。函數
文中會涉及一些數據結構方面的簡單知識,例如拉鍊法。性能
注意:文章中分析的不是
NSHashTable
而是NXHashTable
。測試
NXHashTable
的實現位於 hashtable2.mm
文件,咱們先來看一下 NXHashTable
的結構以及重要的接口:ui
typedef struct { const NXHashTablePrototype *prototype; unsigned count; unsigned nbBuckets; void *buckets; const void *info; } NXHashTable;
對於結構體中的 NXHashTablePrototype
屬性暫且不說,其中的 buckets
是真正用來存儲數據的數組。
NXHashTable *NXCreateHashTableFromZone (NXHashTablePrototype prototype, unsigned capacity, const void *info, void *z); unsigned NXCountHashTable (NXHashTable *table); int NXHashMember (NXHashTable *table, const void *data); void *NXHashGet (NXHashTable *table, const void *data); void *NXHashInsert (NXHashTable *table, const void *data); void *NXHashRemove (NXHashTable *table, const void *data);
咱們會以上面的這些方法做爲切入點,分析 NXHashTable
的實現。
NXHashTable
使用 NXCreateHashTableFromZone
方法初始化:
NXHashTable *NXCreateHashTableFromZone (NXHashTablePrototype prototype, unsigned capacity, const void *info, void *z) { NXHashTable *table; NXHashTablePrototype *proto; table = ALLOCTABLE(z); if (! prototypes) bootstrap (); if (! prototype.hash) prototype.hash = NXPtrHash; if (! prototype.isEqual) prototype.isEqual = NXPtrIsEqual; if (! prototype.free) prototype.free = NXNoEffectFree; proto = (NXHashTablePrototype *)NXHashGet (prototypes, &prototype); if (! proto) { proto = (NXHashTablePrototype *) malloc(sizeof (NXHashTablePrototype)); bcopy ((const char*)&prototype, (char*)proto, sizeof (NXHashTablePrototype)); (void) NXHashInsert (prototypes, proto); proto = (NXHashTablePrototype *)NXHashGet (prototypes, &prototype); }; table->prototype = proto; table->count = 0; table->info = info; table->nbBuckets = GOOD_CAPACITY(capacity); table->buckets = ALLOCBUCKETS(z, table->nbBuckets); return table; }
在這個方法中,絕大多數代碼都是用來初始化 table->prototype
的,咱們先把這部分所有忽略,分析一下簡略版本的實現。
NXHashTable *NXCreateHashTableFromZone (NXHashTablePrototype prototype, unsigned capacity, const void *info, void *z) { NXHashTable *table; NXHashTablePrototype *proto; table = ALLOCTABLE(z); ... table->count = 0; table->info = info; table->nbBuckets = GOOD_CAPACITY(capacity); table->buckets = ALLOCBUCKETS(z, table->nbBuckets); return table; }
其中 ALLOCTABLE
、GOOD_CAPACITY
以及 ALLOCBUCKETS
都是用來輔助初始化的宏:
#define ALLOCTABLE(z) ((NXHashTable *) malloc_zone_malloc ((malloc_zone_t *)z,sizeof (NXHashTable))) #define GOOD_CAPACITY(c) (exp2m1u (log2u (c)+1)) #define ALLOCBUCKETS(z,nb) ((HashBucket *) malloc_zone_calloc ((malloc_zone_t *)z, nb, sizeof (HashBucket)))
ALLOCTABLE
和 ALLOCBUCKETS
只是調用了 malloc_zone_calloc
來初始化相應的結構體,而 GOOD_CAPACITY
有一些特殊,咱們來舉個例子說明:
c binary result 1 1 1 2 10 3(0b11) 6 110 7(0b111) 100 1100100 127(0b111 1111)
c
表示傳入參數,binary
表示二進制下的參數,而 result
就是 GOOD_CAPACITY
返回的結果。
每次返回當前位數下的二進制最大值。
得到 table->nbBuckets
以後,再初始化 table->nbBuckets * sizeof (HashBucket)
大小的內存空間。
在繼續分析其它方法以前,咱們須要先知道 NXHashTablePrototype
是什麼:
typedef struct { uintptr_t (*hash)(const void *info, const void *data); int (*isEqual)(const void *info, const void *data1, const void *data2); void (*free)(const void *info, void *data); int style; /* reserved for future expansion; currently 0 */ } NXHashTablePrototype;
NXHashTablePrototype
中存儲了 hash
、isEqual
和 free
的函數指針(用於獲取數據的哈希、判斷兩個數據是否相等以及釋放數據)。
在 hashtable2.mm
文件中有一個宏 ISEQUAL
就是用了 NXHashTablePrototype
中的 isEqual
來判斷兩個數據是否相等:
#define ISEQUAL(table, data1, data2) ((data1 == data2) || (*table->prototype->isEqual)(table->info, data1, data2))
能夠說,NXHashTablePrototype
中存儲了一些構建哈希表必要的函數指針。
由於
NXHashTable
使用拉鍊法來實現哈希表,在存入表前對數據執行 hash,而後找到對應的 buckets,若是與 buckets 中的數據相同(使用 isEqual 判斷),就替換原數據,不然將數據添加到鏈表中。
在這裏另外一個須要注意的數據結構就是 HashBucket
:
typedef struct { unsigned count; oneOrMany elements; } HashBucket;
oneOrMany
是一個 union
結構體:
typedef union { const void *one; const void **many; } oneOrMany;
這麼設計的主要緣由是提高性能。
若是 HashBucket
中只有一個元素,那麼就直接訪問 one
,不然訪問 many
,遍歷這個 many
列表。
NXCountHashTable
方法應該是咱們要介紹的方法中的最簡單的一個,它會直接返回 NXHashTable
結構體中的 count
。
unsigned NXCountHashTable (NXHashTable *table) { return table->count; }
NXHashMember
的函數簽名雖然會返回 int
,其實它是一個布爾值,會判斷當前的 NXHashTable
中是否包含傳入的數據:
int NXHashMember (NXHashTable *table, const void *data) { HashBucket *bucket = BUCKETOF(table, data); unsigned j = bucket->count; const void **pairs; if (! j) return 0; if (j == 1) { return ISEQUAL(table, data, bucket->elements.one); }; pairs = bucket->elements.many; while (j--) { if (ISEQUAL(table, data, *pairs)) return 1; pairs ++; }; return 0; }
使用 BUCKETOF
對 data
進行 hash,將結果與哈希表的 buckets
數取模,返回 buckets
數組中對應的 NXHashBucket
。
#define BUCKETOF(table, data) (((HashBucket *)table->buckets)+((*table->prototype->hash)(table->info, data) % table->nbBuckets))
在獲取了 bucket
以後,根據其中元素個數的不一樣,選擇不一樣的分支:
if (! j) return 0; if (j == 1) { return ISEQUAL(table, data, bucket->elements.one); }; pairs = bucket->elements.many; while (j--) { if (ISEQUAL(table, data, *pairs)) return 1; pairs ++; };
count == 0
,直接返回
count == 1
,使用 ISEQUAL
比較查找的數據與 bucket->elements.one
count > 1
,依次與 bucket->elements.many
中的值進行比較
> 你可能以爲到這裏的時間複雜度比較糟糕,然而這個列表並不會很長,具體會在 [NXHashInsert](#nxhashinsert) 中解釋。
其實我一直以爲這個方法可能用處不是很大,尤爲是在使用默認的
NXHashTablePrototype
時,由於默認的NXHashTablePrototype
中的isEqual
函數指針只是比較兩個數據的指針是否相同。其最大做用就是查看當前
data
是否是在表中。若是當前數據在表中,那麼這個方法只會返回一個相同的指針,沒有太多的意義。
它的實現跟上面的 NXHashMember
區別並不大,這裏就不過多介紹了:
void *NXHashGet (NXHashTable *table, const void *data) { HashBucket *bucket = BUCKETOF(table, data); unsigned j = bucket->count; const void **pairs; if (! j) return NULL; if (j == 1) { return ISEQUAL(table, data, bucket->elements.one) ? (void *) bucket->elements.one : NULL; }; pairs = bucket->elements.many; while (j--) { if (ISEQUAL(table, data, *pairs)) return (void *) *pairs; pairs ++; }; return NULL; }
NXHashInsert
是 NXHashTable
中比較重要的方法,其做用就是向表中插入數據:
void *NXHashInsert (NXHashTable *table, const void *data) { HashBucket *bucket = BUCKETOF(table, data); unsigned j = bucket->count; const void **pairs; const void **newt; if (! j) { bucket->count++; bucket->elements.one = data; table->count++; return NULL; }; if (j == 1) { if (ISEQUAL(table, data, bucket->elements.one)) { const void *old = bucket->elements.one; bucket->elements.one = data; return (void *) old; }; newt = ALLOCPAIRS(z, 2); newt[1] = bucket->elements.one; *newt = data; bucket->count++; bucket->elements.many = newt; table->count++; if (table->count > table->nbBuckets) _NXHashRehash (table); return NULL; }; pairs = bucket->elements.many; while (j--) { if (ISEQUAL(table, data, *pairs)) { const void *old = *pairs; *pairs = data; return (void *) old; }; pairs ++; }; newt = ALLOCPAIRS(z, bucket->count+1); if (bucket->count) bcopy ((const char*)bucket->elements.many, (char*)(newt+1), bucket->count * PTRSIZE); *newt = data; FREEPAIRS (bucket->elements.many); bucket->count++; bucket->elements.many = newt; table->count++; if (table->count > table->nbBuckets) _NXHashRehash (table); return NULL; }
雖然這裏的實現比上面的兩個方法複雜得多,可是脈絡仍然很清晰,咱們將插入的過程分爲三種狀況:
bucket->count == 0
bucket->count == 1
bucket->count > 1
若是對應的 bucket
爲空:
if (! j) { bucket->count++; bucket->elements.one = data; table->count++; return NULL; };
將數據直接填入 bucket
,增長 bucket
中元素的數目,以及 table
中存儲的元素的數目:
若是原來的 buckets
中有一個元素,它會替換或者使用 many
替換原來的 one
:
if (j == 1) { if (ISEQUAL(table, data, bucket->elements.one)) { const void *old = bucket->elements.one; bucket->elements.one = data; return (void *) old; }; newt = ALLOCPAIRS(z, 2); newt[1] = bucket->elements.one; *newt = data; bucket->count++; bucket->elements.many = newt; table->count++; ... return NULL; };
當前數據 data
若是與 bucket
中存儲的數據相同,就會更新這個數據,不然就會使用 ALLOCPAIRS
初始化一個新的數組,而後將 data
和原來的數據傳入。
可是若是原來的 bucket
中存儲的元素大於 1,那麼會在鏈表的頭部追加一個新的元素:
while (j--) { if (ISEQUAL(table, data, *pairs)) { const void *old = *pairs; *pairs = data; return (void *) old; }; pairs ++; }; newt = ALLOCPAIRS(z, bucket->count+1); if (bucket->count) bcopy ((const char*)bucket->elements.many, (char*)(newt+1), bucket->count * PTRSIZE); *newt = data; FREEPAIRS (bucket->elements.many); bucket->count++; bucket->elements.many = newt; table->count++;
上面的代碼使用 bcopy
將原鏈表中元素拷貝到新的數組 newt
中。
在每次添加完一個元素以後,都會進行下面的判斷:
if (table->count > table->nbBuckets) _NXHashRehash (table);
上面的這行代碼會保證哈希表中的元素數據小於等於表中的 bucket 數量。
這就是 buckets
後面的列表很是短的緣由,在理想狀況下,每個 buckets
中都只存儲一個或零個元素。
若是哈希表在添加元素後,其中的數據多於 buckets
數量,就會對 NXHashTable
進行 _NXHashRehash
操做。
static void _NXHashRehash (NXHashTable *table) { _NXHashRehashToCapacity (table, MORE_CAPACITY(table->nbBuckets)); }
它調用 _NXHashRehashToCapacity
方法來擴大 NXHashTable
的容量(HashBucket
的個數)。
#define MORE_CAPACITY(b) (b*2+1)
而 MORE_CAPACITY
會將當前哈希表的容量翻倍,並將新的容量傳入 _NXHashRehashToCapacity
中:
void _NXHashRehashToCapacity (NXHashTable *table, unsigned newCapacity) { NXHashTable *old; NXHashState state; void *aux; __unused void *z = ZONE_FROM_PTR(table); old = ALLOCTABLE(z); old->prototype = table->prototype; old->count = table->count; old->nbBuckets = table->nbBuckets; old->buckets = table->buckets; table->nbBuckets = newCapacity; table->count = 0; table->buckets = ALLOCBUCKETS(z, table->nbBuckets); state = NXInitHashState (old); while (NXNextHashState (old, &state, &aux)) (void) NXHashInsert (table, aux); freeBuckets (old, NO); free (old->buckets); free (old); }
建立一個 NXHashTable
的指針指向原哈希表
改變哈希表的 nbBuckets
,並從新初始化哈希表的 buckets
數組
從新將元素插入到哈希表中
釋放原哈希表 old
以及 buckets
在將元素從新插入到哈希表中涉及了一個很是奇怪的結構體 NXHashState
,這個結構體主要做用是遍歷 NXHashTable
中的元素。
typedef struct { int i; int j; } NXHashState;
咱們可使用以下的代碼對哈希表中的元素進行遍歷:
unsigned count = 0; MyData *data; NXHashState state = NXInitHashState(table); while (NXNextHashState(table, &state, &data)) { count++; }
代碼片斷中調用了兩個方法,分別是 NXInitHashState
以及 NXNextHashState
:
NXHashState NXInitHashState (NXHashTable *table) { NXHashState state; state.i = table->nbBuckets; state.j = 0; return state; };
NXInitHashState
會將 NXHashState
指向哈希表的最末端:
這個位置其實並不屬於
NXHashTable
,它必定會爲空。
而每次調用 NXNextHashState
都會向『前』移動一次:
int NXNextHashState (NXHashTable *table, NXHashState *state, void **data) { HashBucket *buckets = (HashBucket *) table->buckets; while (state->j == 0) { if (state->i == 0) return NO; state->i--; state->j = buckets[state->i].count; } state->j--; buckets += state->i; *data = (void *) ((buckets->count == 1) ? buckets->elements.one : buckets->elements.many[state->j]); return YES; };
下面的 gif 爲我麼麼展現了每一次調用 NXNextHashState
方法以後當前的 NXHashState
:
這裏的 NXHashRemove
在某種意義上是 NXHashInsert
的逆操做:
void *NXHashRemove (NXHashTable *table, const void *data) { HashBucket *bucket = BUCKETOF(table, data); unsigned j = bucket->count; const void **pairs; const void **newt; __unused void *z = ZONE_FROM_PTR(table); if (! j) return NULL; if (j == 1) { if (! ISEQUAL(table, data, bucket->elements.one)) return NULL; data = bucket->elements.one; table->count--; bucket->count--; bucket->elements.one = NULL; return (void *) data; }; pairs = bucket->elements.many; if (j == 2) { if (ISEQUAL(table, data, pairs[0])) { bucket->elements.one = pairs[1]; data = pairs[0]; } else if (ISEQUAL(table, data, pairs[1])) { bucket->elements.one = pairs[0]; data = pairs[1]; } else return NULL; FREEPAIRS (pairs); table->count--; bucket->count--; return (void *) data; }; while (j--) { if (ISEQUAL(table, data, *pairs)) { data = *pairs; /* we shrink this bucket */ newt = (bucket->count-1) ? ALLOCPAIRS(z, bucket->count-1) : NULL; if (bucket->count-1 != j) bcopy ((const char*)bucket->elements.many, (char*)newt, PTRSIZE*(bucket->count-j-1)); if (j) bcopy ((const char*)(bucket->elements.many + bucket->count-j), (char*)(newt+bucket->count-j-1), PTRSIZE*j); FREEPAIRS (bucket->elements.many); table->count--; bucket->count--; bucket->elements.many = newt; return (void *) data; }; pairs ++; }; return NULL; }
它的實現也分爲三種狀況,不過在這裏就很少說了。
在已經熟悉了 NXHashTable
的具體實現以後,咱們要分析插入不一樣數據量級的狀況下,所須要的時間,這裏是主程序的代碼,分別測試了在 100, 1000, 10000, 100000, 1000000, 2000000, 3000000, 5000000, 10000000
數據下 NXHashTable
的性能表現:
#import <Foundation/Foundation.h> #import "hashtable2.h" int main(int argc, const char * argv[]) { @autoreleasepool { NSArray<NSNumber *> *capacities = @[ @100, @1000, @10000, @100000, @1000000, @2000000, @3000000, @5000000, @10000000 ]; for (NSNumber *capacity in capacities) { NXHashTable *hashTable = NXCreateHashTable(NXPtrPrototype, 0, NULL); NSDate *methodStart = [NSDate date]; for (NSInteger i = 0; i < capacity.integerValue; i++) { NSString *value = [NSString stringWithFormat:@"%ld", (long)i]; NXHashInsert(hashTable, (__bridge void *)value); } NSDate *methodFinish = [NSDate date]; NSTimeInterval executionTime = [methodFinish timeIntervalSinceDate:methodStart]; NSLog(@"Capacities: %@, executionTime = %f, meanTime = %.10f", capacity, executionTime, executionTime / capacity.integerValue); free(hashTable); } } return 0; }
代碼中初始化了一個 capacities
存儲須要測量的數據量級,而後調用 NXHashInsert
方法將至關數量級的數據添加到哈希表中:
Capacities | Execution Time | Mean Time |
---|---|---|
100 | 0.000334 | 0.0000033402 |
1000 | 0.001962 | 0.0000019619 |
10000 | 0.022001 | 0.0000022001 |
100000 | 0.349998 | 0.0000035000 |
1000000 | 2.622551 | 0.0000026226 |
2000000 | 4.165023 | 0.0000020825 |
3000000 | 6.973098 | 0.0000023244 |
5000000 | 13.179743 | 0.0000026359 |
10000000 | 53.387356 | 0.0000053387 |
在對 NXHashTable
的性能測試中,當數據量小於 5000000 時,執行時間的增加仍是線性的,平均時間也基本穩定,可是一旦數據量達到了千萬級,執行時間就會出現顯著的增加。
若是僅僅在哈希表中插入數據,相信其時間增加應該都是線性的,這裏出現問題的緣由推測是在對哈希表進行 Rehash 的時候,遷移原數據至新的數組所形成的。
如何避免哈希表的 Rehash 呢,從新回顧一下建立哈希表的函數:
NXHashTable *NXCreateHashTable (NXHashTablePrototype prototype, unsigned capacity, const void *info);
這個函數的簽名中包含一個 capacity
的參數,咱們在上面的代碼中傳入了 0,也就是最開始的 buckets
數爲 0,可是它的數目並非固定的,它會隨着哈希表中數據的增多,逐漸變大。
capacity
只是一個提示,幫助 NXHashTable 瞭解其中會存儲多少數據。
若是在建立 NXHashTable
時傳入 capacity.integerValue
:
NXHashTable *hashTable = NXCreateHashTable(NXPtrPrototype, capacity.integerValue, NULL);
從新運行代碼,測量性能:
Capacities | Execution Time | Mean Time |
---|---|---|
100 | 0.000740 | 0.0000073999 |
1000 | 0.003442 | 0.0000034420 |
10000 | 0.023341 | 0.0000023341 |
100000 | 0.215209 | 0.0000021521 |
1000000 | 1.836802 | 0.0000018368 |
2000000 | 3.683246 | 0.0000018416 |
3000000 | 5.474610 | 0.0000018249 |
5000000 | 10.576254 | 0.0000021153 |
10000000 | 46.725459 | 0.0000046725 |
雖然在測試 10,000,000
數據時其平均時間依然是 5,000,000
時的二倍,不過總體的性能都有所提高,然而這部分性能的損耗暫時還不是很清楚緣由。
若是咱們使用 Instrument 對有無 capacity
的狀況進行比較(這是在使用 2,000,000
數據時進行的測試):
沒有傳入 capacity
的哈希表會在屢次插入以後出現一個峯值(因爲 Rehash 引發的,其寬度就是 Rehash 使用的時間),而傳入 capacity
的哈希表會在代碼剛運行時就初始化足夠大的數組。
這部分只算是一個小插曲,你能夠選擇跳過這一小節的內容。
NSMutableArray
的構造器 - (instancetype)initWithCapacity:(NSUInteger)numItems
也有一個參數 capacity
,雖然數組和哈希表是兩種數據結構。
不過咱們這裏主要研究的是:傳入
capacity
是否會對性能形成影響。
首先是使用 init
建立的 NSMutableArray
數組,也就是沒有傳入 capacity
:
Capacities | Execution Time | Mean Time |
---|---|---|
100 | 0.000539 | 0.0000053900 |
1000 | 0.003185 | 0.0000031850 |
10000 | 0.074033 | 0.0000074033 |
100000 | 0.370899 | 0.0000037090 |
1000000 | 1.504855 | 0.0000015049 |
2000000 | 2.852519 | 0.0000014263 |
3000000 | 3.995536 | 0.0000013318 |
5000000 | 6.833879 | 0.0000013668 |
10000000 | 14.444605 | 0.0000014445 |
下面是使用 initWithCapacity:
建立的數組:
Capacities | Execution Time | Mean Time |
---|---|---|
100 | 0.000256 | 0.0000025600 |
1000 | 0.001775 | 0.0000017750 |
10000 | 0.015906 | 0.0000015906 |
100000 | 0.174376 | 0.0000017438 |
1000000 | 1.650481 | 0.0000016505 |
2000000 | 2.802310 | 0.0000014012 |
3000000 | 4.451261 | 0.0000014838 |
5000000 | 7.093753 | 0.0000014188 |
10000000 | 14.598415 | 0.0000014598 |
你能夠在表格中看到,二者在執行效率上並無顯著的差別或者區別。
可是若是使用 instrument 來查看二者的內存分配,能夠很明顯的看到,沒有傳入 capacity
的 NSMutableArray
會在可變數組內存佔用增長前出現一個短暫的內存分配峯值。
致使這一現象的原始多是:在將原數組中的內容移入新數組時,臨時變量申請了大量的內存控件。
在以後關於 CoreFoundation 源代碼分析的文中會介紹它們是怎麼實現的。
在整個 objc/runtime 中,做爲私有的數據結構 NXHashTable
,直接使用了它的就是存儲全部類或者元類的哈希表(在這裏會忽略對元類的存儲,由於實現幾乎徹底相同):
static NXHashTable *realized_class_hash = nil;
我麼可使用 objc_copyClassList
獲取類的數組:
Class * objc_copyClassList(unsigned int *outCount) { rwlock_writer_t lock(runtimeLock); realizeAllClasses(); Class *result = nil; NXHashTable *classes = realizedClasses(); unsigned int count = NXCountHashTable(classes); if (count > 0) { Class cls; NXHashState state = NXInitHashState(classes); result = (Class *)malloc((1+count) * sizeof(Class)); count = 0; while (NXNextHashState(classes, &state, (void **)&cls)) { result[count++] = cls; } result[count] = nil; } if (outCount) *outCount = count; return result; }
調用 realizedClasses
返回 realized_class_hash
哈希表
使用 NSHashState
遍歷 realized_class_hash
中的類,並將全部的類存入 result
接下來使用上面的方法,打印出 realized_class_hash
中存儲的全部類:
NXHashTable
在 OS X 10.1 中就已經標記爲棄用了,可是依舊支持着 runtime 底層的工做。
NXHashTable
能夠說有着很是很是久遠的歷史了,最先能夠追溯到將近 30 多年前 NeXT 時代:
// hashtable2.mm 文件中 hashtable2.m Copyright 1989-1996 NeXT Software, Inc. Created by Bertrand Serlet, Feb 89
NSHashTable
對哈希表的實現仍是很是優雅的,能夠說很是標準的使用了拉鍊法實現哈希表。
不過如今,咱們會使用 NSHashTable
來取代這個上古時代的產物。
關注倉庫,及時得到更新:iOS-Source-Code-Analyze
Follow: Draveness · Github