筆記-集合NSSet、字典NSDictionary的底層實現原理

佐籩發表於2019-01-30

預備知識點

Foundation框架下提供了很多高階資料結構,很多都是和Core Foundation下的相對應,例如NSSet就是和_CFSet相對應,NSDictionary就是和_CFDictionary相對應。原始碼

瞭解集合NSSet和字典NSDictionary的底層實現原理前,如果不瞭解Hash表資料結構的話,建議先去了解一下
筆記-資料結構之 Hash(OC的粗略實現)

hash

這裡說的hash並不是之前說的hash表,而是一個方法。為什麼要有hash方法?

這個問題需要從hash表資料結構說起,首先看下如何在陣列中查詢某個成員

  • 先遍歷陣列中的成員
  • 將取出的值與目標值比較,如果相等,則返回改成員

在陣列未排序的情況下,查詢的時間複雜度是O(n)(n為陣列長度)。hash表的出現,提高了查詢速度,當成員被加入到hash表中時,會計算出一個hash值,hash值對陣列長度取模,會得到該成員在陣列中的位置。

通過這個位置可以將查詢的時間複雜度優化到O(1),前提是在不發生衝突的情況下。 這裡的hash值是通過hash方法計算出來的,且hash方法返回的hash值最好唯一

和陣列相比,基於hash值索引的hash表查詢某個成員的過程:

  • 通過hash值直接查詢到目標值的位置
  • 如果目標上有很多相同hash值成員,在利用hash表解決衝突的方式進行查詢

可以看出優勢比較明顯,最壞的情況和陣列也相差無幾。

hash方法什麼時候被呼叫

先看下幾個例子:

Person *person1 = [Person personWithName:kName1 birthday:self.date1];
Person *person2 = [Person personWithName:kName2 birthday:self.date2];

NSMutableArray *array1 = [NSMutableArray array];
[array1 addObject:person1];
NSMutableArray *array2 = [NSMutableArray array];
[array2 addObject:person2];
NSLog(@"array end -------------------------------");

NSMutableSet *set1 = [NSMutableSet set];
[set1 addObject:person1];
NSMutableSet *set2 = [NSMutableSet set];
[set2 addObject:person2];
NSLog(@"set end -------------------------------");

NSMutableDictionary *dictionaryValue1 = [NSMutableDictionary dictionary];
[dictionaryValue1 setObject:person1 forKey:kKey1];
NSMutableDictionary *dictionaryValue2 = [NSMutableDictionary dictionary];
[dictionaryValue2 setObject:person2 forKey:kKey2];
NSLog(@"dictionary value end -------------------------------");

NSMutableDictionary *dictionaryKey1 = [NSMutableDictionary dictionary];
[dictionaryKey1 setObject:kValue1 forKey:person1];
NSMutableDictionary *dictionaryKey2 = [NSMutableDictionary dictionary];
[dictionaryKey2 setObject:kValue2 forKey:person2];
NSLog(@"dictionary key end -------------------------------");
複製程式碼

重寫hash方法,方便檢視hash方法是否被呼叫:

- (NSUInteger)hash {
    NSUInteger hash = [super hash];
    NSLog(@"走過 hash");
    return hash;
}
複製程式碼

列印結果:

array end -------------------------------
走過 hash
走過 hash
走過 hash
走過 hash
set end -------------------------------
dictionary value end -------------------------------
走過 hash
走過 hash
走過 hash
走過 hash
dictionary key end -------------------------------
複製程式碼

可以瞭解到:hash方法只在物件被新增到NSSet和設定為NSDictionary的key時被呼叫

NSSet新增新成員時,需要根據hash值來快速查詢成員,以保證集合中是否已經存在該成員。
NSDictionary在查詢key時,也是利用了key的hash值來提高查詢的效率。

關於上面知識點詳細可參考 iOS開發 之 不要告訴我你真的懂isEqual與hash!

這裡可以得到這個結論:
相等變數的hash結果總是相同的,不相等變數的hash結果有可能相同

集合NSSet

struct __CFSet {
    CFRuntimeBase _base;
    CFIndex _count;		/* number of values */
    CFIndex _capacity;		/* maximum number of values */
    CFIndex _bucketsNum;	/* number of slots */
    uintptr_t _marker;
    void *_context;		/* private */
    CFIndex _deletes;
    CFOptionFlags _xflags;      /* bits for GC */
    const void **_keys;		/* can be NULL if not allocated yet */
};
複製程式碼

根據資料結構可以發現set內部使用了指標陣列來儲存keys,可以從原始碼中瞭解到採用的是連續儲存的方式儲存。

基於不同的初始化,hash值存在不同的運算,簡化原始碼可知道:

static CFIndex __CFSetFindBucketsXX(CFSetRef set, const void *key) {
    CFHashCode keyHash = (CFHashCode)key;
    
    const CFSetCallBacks *cb = __CFSetGetKeyCallBacks(set);
    CFHashCode keyHash = cb->hash ? (CFHashCode)INVOKE_CALLBACK2(((CFHashCode (*)(const void *, void *))cb->hash), key, set->_context) : (CFHashCode)key;
    
    const void **keys = set->_keys;
    CFIndex probe = keyHash % set->_bucketsNum;
}
複製程式碼

這個過程肯定會出現衝突,在筆記-資料結構之 Hash(OC的粗略實現)文章中,我也說明了兩種解決衝突的方法開放定址法、連結串列法

在陣列長度不大的情況下,連結串列法衍生出來的連結串列會非常龐大,而且需要二次遍歷,匹配損耗一樣很大,這樣等於沒有優化。官方說查詢演算法接近O(1),所以肯定不是連結串列法,那就是開放定址法。

開放定址法可以通過動態擴容陣列長度解決表儲存滿無法插入的問題,也符合O(1)的查詢速度。
也可以通過AddValue的實現,證實這一點,下面程式碼除去了無關的邏輯:

void CFSetAddValue(CFMutableSetRef set, const void *key) {
    // 通過 match、nomatch 判斷Set是否存在key
    CFIndex match, nomatch;
    
    __CFSetFindBuckets2(set, key, &match, &nomatch);
    if (kCFNotFound != match) {
        // 存在key,則什麼都不做
    } else {
        // 不存在,則新增到set中
        CF_OBJC_KVO_WILLCHANGE(set, key);
	    CF_WRITE_BARRIER_ASSIGN(keysAllocator, set->_keys[nomatch], newKey);
	    set->_count++;
	    CF_OBJC_KVO_DIDCHANGE(set, key);
    }
}

static void __CFSetFindBuckets2(CFSetRef set, const void *key, CFIndex *match, CFIndex *nomatch) {
    const CFSetCallBacks *cb = __CFSetGetKeyCallBacks(set);
    // 獲取hash值
    CFHashCode keyHash = cb->hash ? (CFHashCode)INVOKE_CALLBACK2(((CFHashCode (*)(const void *, void *))cb->hash), key, set->_context) : (CFHashCode)key;
    const void **keys = set->_keys;
    uintptr_t marker = set->_marker;
    CFIndex probe = keyHash % set->_bucketsNum;
    CFIndex probeskip = 1;	// See RemoveValue() for notes before changing this value
    CFIndex start = probe;
    *match = kCFNotFound;
    *nomatch = kCFNotFound;
    for (;;) {
	uintptr_t currKey = (uintptr_t)keys[probe];
	// 如果hash值對應的是空閒區域,那麼標記nomatch,返回不存在key
	if (marker == currKey) {		/* empty */
	    if (nomatch) *nomatch = probe;
	    return;
	} else if (~marker == currKey) {	/* deleted */
	    if (nomatch) {
		*nomatch = probe;
		nomatch = NULL;
	    }
	} else if (currKey == (uintptr_t)key || (cb->equal && INVOKE_CALLBACK3((Boolean (*)(const void *, const void *, void*))cb->equal, (void *)currKey, key, set->_context))) {
	    // 標記match,返回存在key
	    *match = probe;
	    return;
	}
	// 沒有匹配,說明發生了衝突,那麼將陣列下標後移,知道找到空閒區域位置
	probe = probe + probeskip;
    
	if (set->_bucketsNum <= probe) {
	    probe -= set->_bucketsNum;
	}
	if (start == probe) {
	    return;
	}
    }
}
複製程式碼

這裡涉及到的擴容,在筆記-資料結構之 Hash(OC的粗略實現)中,我也使用OC程式碼具體的實現了,上一篇實現的是連結串列法,其實仔細看的小夥伴就知道原理是一模一樣的。在CFSet內部結構裡還有個_capacity表示當前陣列的擴容閾值,當count達到這個值就擴容,看下原始碼,除去了無關邏輯:

// 新增元素的時候會判斷
void CFSetAddValue(CFMutableSetRef set, const void *key) {
        ...
	if (set->_count == set->_capacity || NULL == set->_keys) {
	    // 呼叫擴容
	    __CFSetGrow(set, 1);
	}
	...
}

// 擴容
static void __CFSetGrow(CFMutableSetRef set, CFIndex numNewValues) {
    // 儲存舊值key的資料
    const void **oldkeys = set->_keys;
    CFIndex idx, oldnbuckets = set->_bucketsNum;
    CFIndex oldCount = set->_count;
    CFAllocatorRef allocator = __CFGetAllocator(set), keysAllocator;
    void *keysBase;
    set->_capacity = __CFSetRoundUpCapacity(oldCount + numNewValues);
    set->_bucketsNum = __CFSetNumBucketsForCapacity(set->_capacity);
    set->_deletes = 0;
    void *buckets = _CFAllocatorAllocateGC(allocator, set->_bucketsNum * sizeof(const void *), (set->_xflags & __kCFSetWeakKeys) ? AUTO_MEMORY_UNSCANNED : AUTO_MEMORY_SCANNED);
    // 擴容key
    CF_WRITE_BARRIER_BASE_ASSIGN(allocator, set, set->_keys, buckets);
    keysAllocator = allocator;
    keysBase = set->_keys;
    if (NULL == set->_keys) HALT;
    if (__CFOASafe) __CFSetLastAllocationEventName(set->_keys, "CFSet (store)");
    
    // 重新計算key的hash值,存放到新陣列中
    for (idx = set->_bucketsNum; idx--;) {
        set->_keys[idx] = (const void *)set->_marker;
    }
    if (NULL == oldkeys) return;
    for (idx = 0; idx < oldnbuckets; idx++) {
        if (set->_marker != (uintptr_t)oldkeys[idx] && ~set->_marker != (uintptr_t)oldkeys[idx]) {
            CFIndex match, nomatch;
            __CFSetFindBuckets2(set, oldkeys[idx], &match, &nomatch);
            CFAssert3(kCFNotFound == match, __kCFLogAssertion, "%s(): two values (%p, %p) now hash to the same slot; mutable value changed while in table or hash value is not immutable", __PRETTY_FUNCTION__, oldkeys[idx], set->_keys[match]);
            if (kCFNotFound != nomatch) {
                CF_WRITE_BARRIER_BASE_ASSIGN(keysAllocator, keysBase, set->_keys[nomatch], oldkeys[idx]);
            }
        }
    }
    CFAssert1(set->_count == oldCount, __kCFLogAssertion, "%s(): set count differs after rehashing; error", __PRETTY_FUNCTION__);
    _CFAllocatorDeallocateGC(allocator, oldkeys);
}

複製程式碼

可以看出,NSSet新增key,key值會根據特定的hash函式算出hash值,然後儲存資料的時候,會根據hash函式算出來的值,找到對應的下標,如果該下標下已有資料,開放定址法後移動插入,如果陣列到達閾值,這個時候就會進行擴容,然後重新hash插入。查詢速度就可以和連續性儲存的資料一樣接近O(1)了

字典NSDictionary

話不多說,先看下dictionary的資料結構:

struct __CFDictionary {
    CFRuntimeBase _base;
    CFIndex _count;		/* number of values */
    CFIndex _capacity;		/* maximum number of values */
    CFIndex _bucketsNum;	/* number of slots */
    uintptr_t _marker;
    void *_context;		/* private */
    CFIndex _deletes;
    CFOptionFlags _xflags;      /* bits for GC */
    const void **_keys;		/* can be NULL if not allocated yet */
    const void **_values;	/* can be NULL if not allocated yet */
};
複製程式碼

是不是感覺特別的熟悉,和上面的集合NSSet相比較,多了一個指標陣列values。

通過比較集合NSSet和字典NSDictionary的原始碼可以知道兩者實現的原理差不多,而字典則用了兩個陣列keys和values,說明這兩個資料是被分開儲存的。

同樣的也是利用開放定址法來動態擴容陣列來解決陣列滿了無法插入的問題,也可以通過setValue的實現證實這一點,下面程式碼已除去無關邏輯:

void CFDictionarySetValue(CFMutableDictionaryRef dict, const void *key, const void *value) {
    // 通過match,nomatch來判斷是否存在key
    CFIndex match, nomatch;
    __CFDictionaryFindBuckets2(dict, key, &match, &nomatch);
    。。。
    if (kCFNotFound != match) {
        // key已存在,覆蓋newValue
	CF_OBJC_KVO_WILLCHANGE(dict, key);
	CF_WRITE_BARRIER_ASSIGN(valuesAllocator, dict->_values[match], newValue);
	CF_OBJC_KVO_DIDCHANGE(dict, key);
    } else {
        // key不存在,新增value
	CF_OBJC_KVO_WILLCHANGE(dict, key);
	CF_WRITE_BARRIER_ASSIGN(keysAllocator, dict->_keys[nomatch], newKey);
	CF_WRITE_BARRIER_ASSIGN(valuesAllocator, dict->_values[nomatch], newValue);
	dict->_count++;
	CF_OBJC_KVO_DIDCHANGE(dict, key);
    }
}

// 查詢key儲存的位置
static void __CFDictionaryFindBuckets2(CFDictionaryRef dict, const void *key, CFIndex *match, CFIndex *nomatch) {
    const CFDictionaryKeyCallBacks *cb = __CFDictionaryGetKeyCallBacks(dict);
    // 獲取hash值
    CFHashCode keyHash = cb->hash ? (CFHashCode)INVOKE_CALLBACK2(((CFHashCode (*)(const void *, void *))cb->hash), key, dict->_context) : (CFHashCode)key;
    const void **keys = dict->_keys;
    uintptr_t marker = dict->_marker;
    CFIndex probe = keyHash % dict->_bucketsNum;
    CFIndex probeskip = 1;	// See RemoveValue() for notes before changing this value
    CFIndex start = probe;
    *match = kCFNotFound;
    *nomatch = kCFNotFound;
    for (;;) {
	uintptr_t currKey = (uintptr_t)keys[probe];
	// 空桶,返回nomatch,未匹配
	if (marker == currKey) {		/* empty */
	    if (nomatch) *nomatch = probe;
	    return;
	} else if (~marker == currKey) {	/* deleted */
	    if (nomatch) {
		*nomatch = probe;
		nomatch = NULL;
	    }
	} else if (currKey == (uintptr_t)key || (cb->equal && INVOKE_CALLBACK3((Boolean (*)(const void *, const void *, void*))cb->equal, (void *)currKey, key, dict->_context))) {
	    // 匹配成功,返回match
	    *match = probe;
	    return;
	}
	
	// 未匹配,發生碰撞,將陣列下標後移,直到找到空閒區域位置
	probe = probe + probeskip;
	
	if (dict->_bucketsNum <= probe) {
	    probe -= dict->_bucketsNum;
	}
	if (start == probe) {
	    return;
	}
    }
}
複製程式碼

通過原始碼可以看到,當有重複的key插入到字典NSDictionary時,會覆蓋舊值,而集合NSSet則什麼都不做,保證了裡面的元素不會重複。

大家都知道,字典裡的鍵值對key-value是一一對應的關係,從資料結構可以看出,key和value是分別儲存在兩個不同的陣列裡,這裡面是如何對key、value進行繫結的呢?

首先key利用hash函式算出hash值,然後對陣列的長度取模,得到陣列下標的位置,同樣將這個地址對應到values陣列的下標,就匹配到相應的value。 注意到上面的這句話,要保證一點,就是keys和values這兩個陣列的長度要一致。所以擴容的時候,需要對keys和values兩個陣列一起擴容。

// setValue時判斷
void CFDictionarySetValue(CFMutableDictionaryRef dict, const void *key, const void *value) {
    ...
    if (dict->_count == dict->_capacity || NULL == dict->_keys) {
         __CFDictionaryGrow(dict, 1);
    }
    ...
}

// 擴容
static void __CFDictionaryGrow(CFMutableDictionaryRef dict, CFIndex numNewValues) {
    // 儲存舊值
    const void **oldkeys = dict->_keys;
    const void **oldvalues = dict->_values;
    CFIndex idx, oldnbuckets = dict->_bucketsNum;
    CFIndex oldCount = dict->_count;
    CFAllocatorRef allocator = __CFGetAllocator(dict), keysAllocator, valuesAllocator;
    void *keysBase, *valuesBase;
    dict->_capacity = __CFDictionaryRoundUpCapacity(oldCount + numNewValues);
    dict->_bucketsNum = __CFDictionaryNumBucketsForCapacity(dict->_capacity);
    dict->_deletes = 0;
    ...
    CF_WRITE_BARRIER_BASE_ASSIGN(allocator, dict, dict->_keys, _CFAllocatorAllocateGC(allocator, 2 * dict->_bucketsNum * sizeof(const void *), AUTO_MEMORY_SCANNED));
        dict->_values = (const void **)(dict->_keys + dict->_bucketsNum);
        keysAllocator = valuesAllocator = allocator;
        keysBase = valuesBase = dict->_keys;
    if (NULL == dict->_keys || NULL == dict->_values) HALT;
    ...
    // 重新計算keys資料的hash值,存放到新的陣列中
    for (idx = dict->_bucketsNum; idx--;) {
        dict->_keys[idx] = (const void *)dict->_marker;
        dict->_values[idx] = 0;
    }
    if (NULL == oldkeys) return;
    for (idx = 0; idx < oldnbuckets; idx++) {
        if (dict->_marker != (uintptr_t)oldkeys[idx] && ~dict->_marker != (uintptr_t)oldkeys[idx]) {
            CFIndex match, nomatch;
            __CFDictionaryFindBuckets2(dict, oldkeys[idx], &match, &nomatch);
            CFAssert3(kCFNotFound == match, __kCFLogAssertion, "%s(): two values (%p, %p) now hash to the same slot; mutable value changed while in table or hash value is not immutable", __PRETTY_FUNCTION__, oldkeys[idx], dict->_keys[match]);
            if (kCFNotFound != nomatch) {
                CF_WRITE_BARRIER_BASE_ASSIGN(keysAllocator, keysBase, dict->_keys[nomatch], oldkeys[idx]);
                CF_WRITE_BARRIER_BASE_ASSIGN(valuesAllocator, valuesBase, dict->_values[nomatch], oldvalues[idx]);
            }
        }
    }
    ...
}
複製程式碼

通過上面可以看出,字典把無序和龐大的資料進行了空間hash表對應,下次查詢的複雜度接近於O(1),但是不斷擴容的空間就是其弊端,因此開放地址法最好儲存的是臨時需要,儘快的釋放資源。

對於字典NSDictionary設定的key和value,key值會根據特定的hash函式算出hash值,keys和values同樣多,利用hash值對陣列長度取模,得到其對應的下標index,如果下標已有資料,開放定址法後移插入,如果陣列達到閾值,就擴容,然後重新hash插入。這樣的機制就把一些不連續的key-value值插入到能建立起關係的hash表中。
查詢的時候,key根據hash函式以及陣列長度,得到下標,然後根據下標直接訪問hash表的keys和values,這樣查詢速度就可以和連續線性儲存的資料一樣接近O(1)了。

__weak關鍵字的底層實現原理

這部分內容在筆記-更深層次的瞭解iOS記憶體管理裡也詳細的描述了,感興趣的小夥伴,可以看一下。

以上知識也算是對hash表知識的總結吧,如果有正確的地方,希望小夥伴們指出。

相關文章