NSDictionary实现原理

转自:http://blog.csdn.net/linshaolie/article/details/41494303


 NSDictionary(字典)是使用 hash表来实现key和value之间的映射和存储的, hash函数设计的好坏影响着数据的查找访问效率。数据在hash表中分布的越均匀,其访问效率越高。而在Objective-C中,通常都是利用NSString 来作为键值,其内部使用的hash函数也是通过使用 NSString对象作为键值来保证数据的各个节点在hash表中均匀分布。

见NSDictionary中最常用的一个方法原型:

[objc]  view plain  copy
  在CODE上查看代码片 派生到我的代码片
  1. - (void)setObject:(id)anObject forKey:(id <NSCopying>)aKey;  

从这个方法中可以知道, 要作为 Key 值,必须遵循 NSCopying 协议。也就是说在NSDictionary内部,会对 aKey 对象 copy 一份新的。而  anObject 对象在其内部是作为强引用(retain或strong)。所以在MRC下,向该方法发送消息之后,我们会向anObject发送 release 消息进行释放。

既然知道了作为 key 值,必须遵循 NSCopying 协议,说明除了 NSString 对象之外,我们还可以使用其他类型对象来作为 NSDictionary 的 key值。不过这还不够,作为 key 值,该类型还必须继承于 NSObject 并且要重载一下两个方法:

[objc]  view plain  copy
  在CODE上查看代码片 派生到我的代码片
  1. - (NSUInteger)hash;  

[objc]  view plain  copy
  在CODE上查看代码片 派生到我的代码片
  1. - (BOOL)isEqual:(id)object;  

其中,hash 方法是用来计算该对象的 hash 值,最终的 hash 值决定了该对象在 hash 表中存储的位置。所以同样,如果想重写该方法,我们尽量设计一个能让数据分布均匀的 hash 函数。

isEqual 方法是为了通过 hash 值来找到 对象 在hash 表中的位置。

下面,我根据一个小Demo来自定义一个作为 NSDictionary 的 key 值的类。见代码:

KeyObject.h文件:

[objc]  view plain  copy
  在CODE上查看代码片 派生到我的代码片
  1. //  
  2. //  KeyObject.h  
  3. //  KeyDemo  
  4. //  
  5. //  Created by linshaolie on 14-11-26.  
  6. //  Copyright (c) 2014年 lin. All rights reserved.  
  7. //  
  8.   
  9. #import <Foundation/Foundation.h>  
  10.   
  11. @interface KeyObject : NSObject<NSCopying>  //实现Copying协议  
  12.   
  13. //作为hash值  
  14. @property(nonatomicreadonly) NSUInteger hashValue;  
  15.   
  16. - (instancetype)initWithHashI:(NSUInteger)i;  
  17.   
  18. @end  
KeyObject.m文件:

[objc]  view plain  copy
  在CODE上查看代码片 派生到我的代码片
  1. //  
  2. //  KeyObject.m  
  3. //  KeyDemo  
  4. //  
  5. //  Created by linshaolie on 14-11-26.  
  6. //  Copyright (c) 2014年 lin. All rights reserved.  
  7. //  
  8.   
  9. #import "KeyObject.h"  
  10.   
  11. @interface KeyObject ()  
  12.   
  13. //作为hash值  
  14. @property(nonatomic) NSUInteger hashValue;  
  15.   
  16. @end  
  17.   
  18. @implementation KeyObject  
  19.   
  20.   
  21. - (instancetype)initWithHashValue:(NSUInteger)hashValue  
  22. {  
  23.     self = [super init];  
  24.     if ( self )  
  25.     {  
  26.         _hashValue = hashValue;  
  27.     }  
  28.     return self;  
  29. }  
  30.   
  31. #pragma mark -overload methor  
  32. - (BOOL)isEqual:(id)object  
  33. {  
  34.     //根据hash值判断是否是同一个键值  
  35.     return ([self hashKeyValue] == [(typeof(self))object hashKeyValue]);  
  36. }  
  37.   
  38. - (NSUInteger)hash  
  39. {  
  40.     return [self hashKeyValue];     //返回哈希值  
  41. }  
  42.   
  43. #pragma mark -NSCopying  
  44. - (id)copyWithZone:(NSZone *)zone  
  45. {  
  46.     KeyObject *obj = [KeyObject allocWithZone:zone];  
  47.     obj.hashValue = self.hashValue;  
  48.     return obj;  
  49. }  
  50.   
  51. #pragma mark -private methor  
  52. //哈希函数  
  53. - (NSUInteger)hashKeyValue  
  54. {  
  55.     return _hashValue % 7;      //这里哈希函数用了简单的对7取余  
  56. }  
  57.   
  58. @end  

ViewController.m文件:(测试)

[objc]  view plain  copy
  在CODE上查看代码片 派生到我的代码片
  1. //  
  2. //  ViewController.m  
  3. //  KeyDemo  
  4. //  
  5. //  Created by lin on 14-11-26.  
  6. //  Copyright (c) 2014年 linshaolie. All rights reserved.  
  7. //  
  8.   
  9. #import "ViewController.h"  
  10. #import "KeyObject.h"  
  11.   
  12. @interface ViewController ()  
  13. {  
  14.     CGPoint _oldPoint;  
  15.     CGMutablePathRef _pathRef;  
  16.     UIImageView *_imageView;  
  17. }   
  18. @end  
  19.   
  20. @implementation ViewController  
  21.   
  22. - (void)viewDidLoad  
  23. {  
  24.     [super viewDidLoad];  
  25.       
  26.     //Test...  
  27.     KeyObject *key1 = [[KeyObject alloc] initWithHashValue:1];  
  28.     NSMutableDictionary *dic = [[NSMutableDictionary alloc] init];  
  29.     [dic setObject:@"AAA" forKey:key1];  
  30.       
  31.     KeyObject *key2 = [[KeyObject alloc] initWithHashValue:8];  
  32.     [dic setObject:@"BBB" forKey:key2];  
  33.       
  34.     KeyObject *key3 = [[KeyObject alloc] initWithHashValue:3];  
  35.     [dic setObject:@"CCC" forKey:key3];  
  36.       
  37.       
  38.     NSLog(@"%@", [dic objectForKey:key2]);  
  39.     NSLog(@"%@", [dic allKeys]);  
  40.     NSLog(@"%@", [dic allValues]);  
  41. }  
  42. @end  

输出结果:


在调用 setObject: forKey:  后,内部会去调用 key 对象的 hash 方法确定 object 在hash表内的入口位置,然后会调用 isEqual 来确定该值是否已经存在于 NSDictionary中。



具体深入请看: 深入理解哈希表http://ios.jobbole.com/87716/


这篇文章由一个简单的问题引出:

有两个字典,分别存有 100 条数据和 10000 条数据,如果用一个不存在的 key 去查找数据,在哪个字典中速度更快?

有些计算机常识的读者都会立刻回答: “一样快,底层都用了哈希表,查找的时间复杂度为 O(1)”。然而实际情况真的是这样么?

答案是否定的,存在少部分情况两者速度不一致,本文首先对哈希表做一个简短的总结,然后思考 Java 和 Redis 中对哈希表的实现,最后再得出结论,如果对某个话题已经很熟悉,可以直接跳到文章末尾的对比和总结部分。

哈希表概述

Objective-C 中的字典 NSDictionary 底层其实是一个哈希表,实际上绝大多数语言中字典都通过哈希表实现,比如我曾经分析过的 Swift 字典的实现原理

在讨论哈希表之前,先规范几个接下来会用到的概念。哈希表的本质是一个数组,数组中每一个元素称为一个箱子(bin),箱子中存放的是键值对。

哈希表的存储过程如下:

  1. 根据 key 计算出它的哈希值 h。
  2. 假设箱子的个数为 n,那么这个键值对应该放在第 (h % n) 个箱子中。
  3. 如果该箱子中已经有了键值对,就使用开放寻址法或者拉链法解决冲突。

在使用拉链法解决哈希冲突时,每个箱子其实是一个链表,属于同一个箱子的所有键值对都会排列在链表中。

哈希表还有一个重要的属性: 负载因子(load factor),它用来衡量哈希表的 空/满 程度,一定程度上也可以体现查询的效率,计算公式为:

负载因子 = 总键值对数 / 箱子个数

负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。因此,一般来说,当负载因子大于某个常数(可能是 1,或者 0.75 等)时,哈希表将自动扩容。

哈希表在自动扩容时,一般会创建两倍于原来个数的箱子,因此即使 key 的哈希值不变,对箱子个数取余的结果也会发生改变,因此所有键值对的存放位置都有可能发生改变,这个过程也称为重哈希(rehash)。

哈希表的扩容并不总是能够有效解决负载因子过大的问题。假设所有 key 的哈希值都一样,那么即使扩容以后他们的位置也不会变化。虽然负载因子会降低,但实际存储在每个箱子中的链表长度并不发生改变,因此也就不能提高哈希表的查询性能。

基于以上总结,细心的读者可能会发现哈希表的两个问题:

  1. 如果哈希表中本来箱子就比较多,扩容时需要重新哈希并移动数据,性能影响较大。
  2. 如果哈希函数设计不合理,哈希表在极端情况下会变成线性表,性能极低。

我们分别通过 Java 和 Redis 的源码来理解以上问题,并看看他们的解决方案。

Java 8 中的哈希表

JDK 的代码是开源的,可以从这里下载到,我们要找的 HashMap.java 文件的目录在 openjdk/jdk/src/share/classes/java/util/HashMap.java

HashMap 是基于 HashTable 的一种数据结构,在普通哈希表的基础上,它支持多线程操作以及空的 key 和 value。

在 HashMap 中定义了几个常量:

依次解释以上常量:

  1. DEFAULT_INITIAL_CAPACITY: 初始容量,也就是默认会创建 16 个箱子,箱子的个数不能太多或太少。如果太少,很容易触发扩容,如果太多,遍历哈希表会比较慢。
  2. MAXIMUM_CAPACITY: 哈希表最大容量,一般情况下只要内存够用,哈希表不会出现问题。
  3. DEFAULT_LOAD_FACTOR: 默认的负载因子。因此初始情况下,当键值对的数量大于 16 * 0.75 = 12 时,就会触发扩容。
  4. TREEIFY_THRESHOLD: 上文说过,如果哈希函数不合理,即使扩容也无法减少箱子中链表的长度,因此 Java 的处理方案是当链表太长时,转换成红黑树。这个值表示当某个箱子中,链表长度大于 8 时,有可能会转化成树。
  5. UNTREEIFY_THRESHOLD: 在哈希表扩容时,如果发现链表长度小于 6,则会由树重新退化为链表。
  6. MIN_TREEIFY_CAPACITY: 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

学过概率论的读者也许知道,理想状态下哈希表的每个箱子中,元素的数量遵守泊松分布:

当负载因子为 0.75 时,上述公式中 λ 约等于 0.5,因此箱子中元素个数和概率的关系如下:

数量概率
00.60653066
10.30326533
20.07581633
30.01263606
40.00157952
50.00015795
60.00001316
70.00000094
80.00000006

这就是为什么箱子中链表长度超过 8 以后要变成红黑树,因为在正常情况下出现这种现象的几率小到忽略不计。一旦出现,几乎可以认为是哈希函数设计有问题导致的。

Java 对哈希表的设计一定程度上避免了不恰当的哈希函数导致的性能问题,每一个箱子中的链表可以与红黑树切换。

Redis

Redis 是一个高效的 key-value 缓存系统,也可以理解为基于键值对的数据库。它对哈希表的设计有非常多值得学习的地方,在不影响源代码逻辑的前提下我会尽可能简化,突出重点。

数据结构

在 Redis 中,字典是一个 dict 类型的结构体,定义在 src/dict.h 中:

这里的 dictht 是用于存储数据的结构体。注意到我们定义了一个长度为 2 的数组,它是为了解决扩容时速度较慢而引入的,其原理后面会详细介绍,rehashidx 也是在扩容时需要用到。先看一下 dictht 的定义:

可见结构体中有一个二维数组 table,元素类型是 dictEntry,对应着存储的一个键值对:

从 next 指针以及二维数组可以看出,Redis 的哈希表采用拉链法解决冲突。

整个字典的层次结构如下图所示:

添加元素

向字典中添加键值对的底层实现如下:

dictIsRehashing 函数用来判断哈希表是否正在重新哈希。所谓的重新哈希是指在扩容时,原来的键值对需要改变位置。为了优化重哈希的体验,Redis 每次只会移动一个箱子中的内容,下一节会做详细解释。

仔细阅读指针操作部分就会发现,新插入的键值对会放在箱子中链表的头部,而不是在尾部继续插入。这个细节上的改动至少带来两个好处:

  1. 找到链表尾部的时间复杂度是 O(n),或者需要使用额外的内存地址来保存链表尾部的位置。头插法可以节省插入耗时。
  2. 对于一个数据库系统来说,最新插入的数据往往更有可能频繁的被获取。头插法可以节省查找耗时。

增量式扩容

所谓的增量式扩容是指,当需要重哈希时,每次只迁移一个箱子里的链表,这样扩容时不会出现性能的大幅度下降。

为了标记哈希表正处于扩容阶段,我们在 dict 结构体中使用 rehashidx 来表示当前正在迁移哪个箱子里的数据。由于在结构体中实际上有两个哈希表,如果添加新的键值对时哈希表正在扩容,我们首先从第一个哈希表中迁移一个箱子的数据到第二个哈希表中,然后键值对会被插入到第二个哈希表中。

在上面给出的 dictAddRaw 方法的实现中,有两句代码:

第二句就是用来选择插入到哪个哈希表中,第一句话则是迁移 rehashidx 位置上的链表。它实际上会调用 dictRehash(d,1),也就是说是单步长的迁移。dictRehash 函数的实现如下:

这段代码比较长,但是并不难理解。它由一个 while 循环和 if 语句组成。在单步迁移的情况下,最外层的 while 循环没有意义,而它内部又可以分为两个 while 循环。

第一个循环用来更新 rehashidx 的值,因为有些箱子为空,所以 rehashidx 并非每次都比原来前进一个位置,而是有可能前进几个位置,但最多不超过 10。第二个循环则用来复制链表数据。

最外面的 if 判断中,如果发现旧哈希表已经全部完成迁移,就会释放旧哈希表的内存,同时把新的哈希表赋值给旧的哈希表,最后把 rehashidx 重新设置为 -1,表示重哈希过程结束。

默认哈希函数

与 Java 不同的是,Redis 提供了 void * 类型 key 的哈希函数,也就是通过任何类型的 key 的指针都可以求出哈希值。具体算法定义在 dictGenHashFunction 函数中,由于代码过长,而且都是一些位运算,就不展示了。

它的实现原理是根据指针地址和这一块内存的长度,获取内存中的值,并且放入到一个数组当中,可见这个数组仅由 0 和 1 构成。然后再对这些数字做哈希运算。因此即使两个指针指向的地址不同,但只要其中内容相同,就可以得到相同的哈希值。

归纳对比

首先我们回顾一下 Java 和 Redis 的解决方案。

Java 的长处在于当哈希函数不合理导致链表过长时,会使用红黑树来保证插入和查找的效率。缺点是当哈希表比较大时,如果扩容会导致瞬时效率降低。

Redis 通过增量式扩容解决了这个缺点,同时拉链法的实现(放在链表头部)值得我们学习。Redis 还提供了一个经过严格测试,表现良好的默认哈希函数,避免了链表过长的问题。

Objective-C 的实现和 Java 比较类似,当我们需要重写 isEqual() 方法时,还需要重写 hash 方法。这两种语言并没有提供一个通用的、默认的哈希函数,主要是考虑到 isEqual() 方法可能会被重写,两个内存数据不同的对象可能在语义上被认为是相同的。如果使用默认的哈希函数就会得到不同的哈希值,这两个对象就会同时被添加到 NSSet 集合中,这可能违背我们的期望结果。

根据我的了解,Redis 并不支持重写哈希方法,难道 Redis 就没有考虑到这个问题么?实际上还要从 Redis 的定位说起。由于它是一个高效的,Key-Value 存储系统,它的 key 并不会是一个对象,而是一个用来唯一确定对象的标记。

一般情况下,如果要存储某个用户的信息,key 的值可能是这样: user:100001。Redis 只关心 key 的内存中的数据,因此只要是可以用二进制表示的内容都可以作为 key,比如一张图片。Redis 支持的数据结构包括哈希表和集合(Set),但是其中的数据类型只能是字符串。因此 Redis 并不存在对象等同性的考虑,也就可以提供默认的哈希函数了。

Redis、Java、Objective-C 之间的异同再次证明了一点:

没有完美的架构,只有满足需求的架构。

总结

回到文章开头的问题中来,有两个字典,分别存有 100 条数据和 10000 条数据,如果用一个不存在的 key 去查找数据,在哪个字典中速度更快?

完整的答案是:

在 Redis 中,得益于自动扩容和默认哈希函数,两者查找速度一样快。在 Java 和 Objective-C 中,如果哈希函数不合理,返回值过于集中,会导致大字典更慢。Java 由于存在链表和红黑树互换机制,搜索时间呈对数级增长,而非线性增长。在理想的哈希函数下,无论字典多大,搜索速度都是一样快。

最后,整理了一下本文提到的知识点,希望大家读完文章后对以下问题有比较清楚透彻的理解:

  1. 哈希表中负载因子的概念
  2. 哈希表扩容的过程,以及对查找性能的影响
  3. 哈希表扩容速度的优化,拉链法插入新元素的优化,链表过长时的优化
  4. 不同语言、使用场景下的取舍

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值