这里写自定义目录标题
- 前言
- map的内存模型
- 增量扩容
- 查找过程分析
- 插入过程分析
前言
本篇将从底层讲解map的赋值、删除、查询、扩容的具体执行过程。结合源码,让你彻底明白map的原理。
map的内存模型
在源码中,表示map的结构体是hmap,其定义如下:
type hmap struct {
// 元素个数,调用 len(map) 时,直接返回此值
count int
flags uint8
// buckets 的对数 log_2
B uint8
// overflow 的 bucket 近似数
noverflow uint16
// 计算 key 的哈希的时候会传入哈希函数
hash0 uint32
// 指向 buckets 数组,大小为 2^B
// 如果元素个数为0,就为 nil
buckets unsafe.Pointer
// 扩容的时候,buckets 长度会是 oldbuckets 的两倍
oldbuckets unsafe.Pointer
// 指示扩容进度,小于此地址的 buckets 迁移完成
nevacuate uintptr
extra *mapextra // optional fields
}
这里需要注意的是B是bucket数组的长度的对数,也就是说bucket数组的长度就是2^B。bucket里面存储的是key和value。buckets又是什么呢?其实它里面是一个指针,最终它指向的是一个结构体。定义如下
type bmap struct {
tophash [bucketCnt]uint8
}
编译期间会给它加料,动态地创建一个结构
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
这里的bmap就是俗称的“桶”,可以看到桶最多装8个key。这些key之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是一致的。在桶内,又会根据key计算出来的哈希值的高8位来确定key到底落入桶的哪个位置。
当map的key和value都不是指针,而且size小于128字节的情况下,会把bmap标记为不含指针,这样的好处就是避免了GC时扫描整个hmap。但是bmap中有一个overflow字段,它是指针类型。因此,这时候会把它移动到extra字段去。
type mapextra struct {
// overflow[0] contains overflow buckets for hmap.buckets.
// overflow[1] contains overflow buckets for hmap.oldbuckets.
overflow [2]*[]*bmap
// nextOverflow 包含空闲的 overflow bucket,这是预分配的 bucket
nextOverflow *bmap
}
那么,bmap的组成是怎么样的,我们来看下
这就是bucket的内存模型,HOB Hash指的就是top hash。可以看到key和value是独自放在一起的。这样的好处是可以省略掉padding字段,从而节省了内存空间。
比如有一个map,比如map[int64]int8,按照key/value/key/value的方式存储,就需要额外padding7个字节。而所有的key,value分别绑定在一起,只需要在最后添加padding。这样避免了字节对齐造成的内存空间浪费。
每个bucket最多能放8个key-value对,如果需要把9个key-value落入当前的bucket,那就需要再构建一个bucket。bucket之间通过overflow指针连接起来。
增量扩容
哈希表的核心思想是空间换时间,访问速度是直接跟填充因子有关。当哈希表快满的时候需要进行扩容。假设,扩容前哈希表的大小是2^B 扩容后哈希表大小变成了2^(B+1),每次扩容都为原来的两倍,哈希表大小始终为2的指数倍。假设扩容之前容量是A,扩容后的容量为B,一般情况下hash mod A 不等于 hash mod B。因此扩容之后要计算每一项在哈希表中的位置,当hash表扩容之后要重新计算每一项在哈希表中的新位置。当hash表扩容之后,需要将那些旧的pair重新哈希到新的table上(源代码中称之为evacuate), 这个工作并没有在扩容之后一次性完成,而是逐步的完成(在insert和remove时每次搬移1-2个pair),Go语言使用的是增量扩容。
增量扩容的目的是为了缩短容器的响应时间。假设我们把map用来存储实时性要求比较高的应用服务器,如果不采用增量扩容的话,当map里面存储的元素很多之后,扩容时系统就会卡往,导致较长一段时间内无法响应请求。不过增量扩容本质上还是将总的扩容时间分摊到了每一次哈希操作上面。
扩容会建立一个原来2倍的新表,将旧的bucket搬到新的表中之后,并不会将旧的bucket从oldbucket中删除,而是加上一个已删除的标记。
由于这个工作是逐渐完成的,因此会造成一部分数据在old table中,一部分在new table中,new table在源码中又叫evacuated, 所以对于hash table的insert, remove, lookup操作的处理逻辑产生影响。只有当所有的bucket都从旧表移到新表之后,才会将oldbucket释放掉。
那么,扩容的填充因子该是多少呢?如果grow太频繁会导致空间利用率低,如果很久才grow,又会生成很多overflow buckets。查找的效率就会降低。在go中使用了一个宏控制的(#define LOAD 6.5), 它的意思是如果table中元素的个数大于table中能容纳的元素的个数, 那么就触发一次grow动作。
查找过程分析
1.根据key计算出hash值
2.如果存在old table则先在old table中查找,如果old table已经evacuated,转到步骤3。 反之,返回其对应的value。
3.在new table中查找对应的value。
我们来看看源码
do { //对每个桶b
//依次比较桶内的每一项存放的tophash与所求的hash值高位是否相等
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值,进而得出对应的bucket。
2.如果bucket在old table中,将其重新散列到new table中。
3.在bucket中,查找空闲的位置,如果已经存在需要插入的key,更新其对应的value。
4.根据table中元素的个数,判断是否grow table。
5.如果对应的bucket已满,则重新申请新的bucket作为overbucket。
6.将key/value pair插入到bucket中。
此外,有两点需要注意,old bucket是被冻结的,意味着查找时会在old bucket中查找,但是不会对old bucket插入数据。如果在old bucket中查找到了相应的key,则将它迁移到new table,并加上evacuate标志。额外的还会迁移另外一个pair。
此外,只要在某个bucket中查找到第一个空位,就会将key/value插入到这个位置。找到了相同的key或者第一个空位就可以结束遍历。所以这样容易造成删除的时候得完全遍历bucket的所有溢出链,将所有的相同key数据都删除。所以目前map的设计是为插入而优化的,删除效率会比插入低一些。