Go map底层原理(哈希表)

了解hash表的实现机制,有助于使用哈希表进行深层次的调优

数据结构

Go语言的map使用Hash表作为底层实现,可以在 $GOROOT/src/pkg/runtime/hashmap.goc 找到它的实现:

type hmap struct {
	count int   	//map中键值对数量
	flags uint8		//map当前是否处于写入状态等
	B     uint8		//2的B次幂表示当前map中桶的数量
	noverflow uint16//map中溢出桶的数量,当溢出桶太多时,map会进行等量扩容
	hash0 uint32	//生成hash的随机数种子
	buckets unsafe.Pointer	//当前map对应的桶的指针
	oldbuckets unsafe.Pointer//map扩容时指向旧桶的指针,当所有旧桶中的数据转移到新桶时,清空
	nevacuate uintptr	//扩容时,用于标记当前旧桶中小于nevacute的数据都已经转移到了新桶
	extra *mapextra	//存储map的溢出桶
}

需要注意到的是,这里直接使用的是Bucket的数组,而不是Bucket*指针的数组。这意味着,第一个Bucket和后面溢出链的Bucket分配有些不同。第一个Bucket是用的一段连续的内存空间,而后面溢出链的Bucket的空间是使用mallocgc分配的。

这个hash结构使用的是一个可扩展哈希的算法,由hash值mod当前hash表大小决定某一个值属于哪个桶,而hash表大小是2的指数,即上面结构体中的2^B。每次扩容,会增大到上次大小的两倍。结构体中有一个buckets和一个oldbuckets是用来实现增量扩容的。正常情况下直接使用buckets,而oldbuckets为空。如果当前哈希表正在扩容中,则oldbuckets不为空,并且buckets大小是oldbuckets大小的两倍。

具体的Bucket结构如下所示:

type bmap struct {
	tophash [8]uint8 //存储Hash值的高8位
	data []byte	//key value数据:key/key/key.../value/value/value...
	overflow *bmap	//溢出bucket的地址
}
  1. tophash是一个长度为8的整形数组,Hash值相同的键(准确的说是Hash值低位相同的键)存入当前bucket时会将Hash值的高位存储在该数组中,以便后续匹配。
  2. data区存放的是key—value数据,其中keys放在一起,values放在一起,如此存储是为了节省字节对齐带来的空间浪费。例如map[int64]int8
  3. overflow指针指向的是下一个bucket,据此将所有冲突的键连接起来

Hash冲突

当有两个或以上数量的键“Hash”到了同一个bucket时,我们称这些键发生了冲突,Go使用链地址法来解决键冲突,由于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个bucket,用类似链表的方式将bucket连接起来,产生冲突后hash的存储如下图所示:

按key的类型采用相应的hash算法得到key的hash值。将hash值的低位当作hmap结构体中buckets数组的index,找到key所在的bucket。将hash的高8位存储在了bucket的tophash中。注意,这里高8位不是用来当作key/value在bucket内部的offset的,而是作为一个主键,在查找时对tophash数组的每一项进行顺序匹配的。先比较hash值高位与bucket的tophash[i]是否相等,如果相等则再比较bucket的第i个的key与所给的key是否相等。如果相等,则返回其对应的value,反之,在overflow buckets中按照上述方法继续寻找。

根据hash值计算桶数组下标index

计算桶数组下标的方法不是取模,而是用hash值和2^B-1进行按位与操作,这里对此进行一个解释:首先bucket数组的长度为2^B,即2的次幂数,而2^B-1转换成二进制后一定是低位全1,高位全0的形式,因此在进行按位与操作后,一定能求得一个在[0,2^B-1]区间上的任意一个数,也就是数组中的下标位置,相较之下,能获得比取模更加优秀的执行效率。

然而为什么前面要求buckets数组长度必须是2的次幂数呢?这是因为若不是2的次幂数,则buckets数组长度n转成二进制不是一个低位全1,高位全0的形式,则将其作为掩码与任意key的hash值进行按位与后,虽然得出来的数仍然在[0,2^B-1]区间上,但反过来,这些区间上的数,必定有些数永远不会出现,这也就意味着map的buckets数组里某些位置上永远不可能存数据,这显然是不合理的(既浪费了空间,又影响了散列性,大大降低查询效率),这也是Go把buckets数组长度设计成为2^B的主要原因。

举例说明:

buckets数组长度为16(2^4)时
2^B-1:	        0000 1111
key.hashcode:	**** ****
&
index:			0000 ****
//即根据hash值的低 B 位确定index
//hash值低 B 位相同的键进入buckets数组相同的bucket
============================
buckets数组长度为 10 时
buckets.length-1:	0000 1001
key.hashcode:	    **** ****
&
index:			    0000 *00*
//中间两位必是0,即这些位是1的下标值必定不可能求得,
//这个例子中,按位与得出的index只可能是0,1,8,9四个值

哈希表大小始终为2的指数倍,则有(hash mod 2^B)等价于(hash & (2^B-1))。这样可以简化运算,避免了取模操作。

负载因子

负载因子用于衡量一个hash表冲突的情况:

负载因子 = 键数量 / bucket数量

  • 负载因子过小,说明空间利用率低
  • 负载因子过大,说明冲突严重,存取效率低

负载因子过小,可能是预分配的空间太大,也可能是大部分元素被删除造成的。随着元素不断添加到map中,负载因子会逐渐升高。

扩容

哈希表就是以空间换时间,访问速度是直接跟负载因子相关的,为了保证访问效率,当新元素将要添加进map时,都会检查是否需要扩容。

扩容条件

触发扩容需满足以下任一条件:

  1. 负载因子大于6.5时
  2. overflow的数量达到2^min(15,B)

增量扩容

扩容时新建一个bucket数组,新的bucket数组的长度是原来的两倍,扩容前的哈希表大小为2^B,扩容之后的大小为2^(B+1)。然后旧bucket数组中的数据搬迁到新bucket数组中。

考虑到如果map存储了数以亿计的键值对,那么一次性搬迁会造成比较大的延时,所以Go采用逐步搬迁策略,即每次访问map时(insert、remove、lookup)都会触发一次搬迁,每次搬迁1-2键值对。

 

扩容时先让hmap数据结构中的oldbuckets指向原buckets数组,然后申请新的buckets数组(长度为原来的两倍),并将数组指针保存到hmap数据结构的buckets成员中。这样就完成了新老buckets数组的交接。后续的迁移工作是从oldbuckets数组中逐步搬迁到buckets数组中,并想办法分散转移到buckets数组中。当oldbuckets数组中所有键值对搬迁完毕就可以安全的释放oldbuckets数组了。

等量扩容

所谓等量扩容,并不是扩大容量,而是bucket数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,从而保证更快的存取速度。

在极端场景下,比如经过大量的元素增删后,键值对刚好集中在一小部分bucket中,这样会造成溢出的bucket数量增多。

上图中,overflow的bucket中大部分是空的,访问效率很差。此时会进行一次等量扩容,bucket数量不变,经过重新排列后的overflow的bucket数量会减少,这样既提高了访问效率,又节省了空间。

查找过程

  1. 根据key值计算hash值;
  2. 如果不存在oldbuckets,转到步骤3,如果存在oldbuckets, 首先在oldbuckets中查找,转到步骤4,如果在oldbuckets找到的bucket已经搬迁,转到步骤3;
  3. 在新的buckets数组中中查找;
  4. 根据hash & 2^B-1 确定bucket的位置;
  5. 取hash的高8位,在tophash数组中查询;
  6. 如果hash的高8位与tophash[i]中存储的hash值相等,则获取tophash[i]对应的key进行比较,如果key相等则返回对应的value;
  7. 如果当前bucket没有找到,则依次从溢出的bucket中查找,重复步骤6
  8. 如果最终查找不到,会返回相应类型的零值

这里一个细节需要注意一下。不认真看可能会以为低位用于定位bucket在数组的index,那么高位就是用于key/valule在bucket内部的offset。事实上高8位不是用作offset的,而是用于加快key的比较的。

do { //对每个桶b
    //依次比较桶内的每一项存放的tophash与所求的hash值高位是否相等
		//BUCKETSIZE := 8
    for(i = 0, k = b->data, v = k + h->keysize * BUCKETSIZE; i < BUCKETSIZE; i++, k += h->keysize, v += h->valuesize) {
        if(b->tophash[i] == top) { 
            k2 = IK(h, k);
            t->key->alg->equal(&eq, t->key->size, key, k2);
            if(eq) { //相等的情况下再去做key比较...
                *keyp = k2;
                return IV(h, v);
            }
        }
    }
    b = b->overflow; //b设置为它的下一下溢出链
} while(b != nil);

插入过程(更新过程)

  1. 根据key值计算hash值;
  2. 从oldbuckets数组开始查找,如果在oldbuckets数组查找到了,先进行扩容过程,即将其重新散列到新的buckets数组中,如果oldbuckets不存在或者在oldbuckets数组中没找到,继续在新的bucket数组中查找;
  3. 如果key值已经存在,那么更新其对应的value;
  4. 如果key值不存在,先根据负载因子判断是否需要扩容,如果需要扩容,则先进行扩容;
  5. 如果bucket中已经没有了空位置,那么需要先申请一个bucket;
  6. 如果不需扩容,也不需要申请bucket,那么在bucket中第一个空tophash中插入hash高8位,并在对应位置插入key和value的值

注意map处于搬迁过程时,在查找key的过程仍是从oldbuckets数组开始,但是新元素会直接添加到新的buckets数组中。

删除过程

删除元素实际上也是先查找元素,如果元素存在则把元素从相应的bucket中删除,如果不存在则什么也不做。

map设计中的性能优化

HMap中是Bucket的数组,而不是Bucket指针的数组。好的方面是可以一次分配较大内存,减少了分配次数,避免多次调用mallocgc。但相应的缺点,其一是可扩展哈希的算法并没有发生作用,扩容时会造成对整个数组的值拷贝(如果用Bucket指针的数组就是指针拷贝了,代价小很多)。其二是首个bucket与后面产生了不一致性。这个会使删除逻辑变得复杂一点。比如删除后面的溢出链可以直接删除,而对于首个bucket,要等到搬迁完毕后,整个oldbucket删除时进行。

没有重用删除的结点。作者把这个加了一个TODO的注释,不过想了一下觉得这个做的意义不大。因为一方面,bucket大小并不一致,重用比较麻烦。另一方面,下层存储已经做过内存池的实现了,所以这里不做重用也会在内存分配那一层被重用的。

bucket直接key/value和间接key/value优化。这个优化做得蛮好的。注意看代码会发现,如果key或value小于128字节,则它们的值是直接使用的bucket存储的。否则bucket中存储的是指向实际key/value数据的指针。

bucket存8个key/value对。查找时进行顺序比较。第一次发现高位居然不是用作offset,而是用于加快比较的。定位到bucket之后,居然是一个顺序比较的查找过程。后面仔细想了想,觉得还行。由于bucket只有8个,顺序比较下来也不算过分。仍然是O(1),只不过前面系数大一点点。相当于hash到一个小范围之后,在这个小范围内顺序查找。

插入删除的优化。前面已经提过了,插入只要找到相同的key或者第一个空位,bucket中如果存在一个以上的相同key,前面覆盖后面的(只是如果,实际上不会发生)。而删除就需要遍历完所有bucket溢出链了。这样map的设计就是为插入优化的。考虑到一般的应用场景,这个应该算是很合理的。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值