阅读 1869

笔记-集合NSSet、字典NSDictionary的底层实现原理

预备知识点

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表知识的总结吧,如果有正确的地方,希望小伙伴们指出。