这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天, 今天主要学习总结了Go的map类型
map
数据结构
struct Hmap
{
uint8 B; // 可以容纳2^B个项
uint16 bucketsize; // 每个桶的大小
byte *buckets; // 2^B个Buckets的数组
byte *oldbuckets; // 前一个buckets,只有当正在扩容时才不为空
};
struct Bucket
{
uint8 tophash[BUCKETSIZE]; // hash值的高8位....低位从bucket的array定位到bucket
Bucket *overflow; // 溢出桶链表,如果有
byte data[1]; // BUCKETSIZE keys followed by BUCKETSIZE values
};
注意一个细节是Bucket中key/value的放置顺序,是将keys放在一起,values放在一起,为什么不将key和对应的value放在一起呢?如果那么做,存储结构将变成key1/value1/key2/value2… 设想如果是这样的一个map[int64]int8,考虑到字节对齐,会浪费很多存储空间。不得不说通过上述的一个小细节,可以看出Go在设计上的深思熟虑。
查找过程
- 根据key计算出hash值。
- 如果存在old table, 首先在old table中查找,如果找到的bucket已经evacuated,转到步骤3。 反之,返回其对应的value。
- 在new table中查找对应的value。
这里一个细节需要注意一下。不认真看可能会以为低位用于定位bucket在数组的index,那么高位就是用于key/valule在bucket内部的offset。事实上高8位不是用作offset的,而是用于加快key的比较的。
插入过程
- 根据key算出hash值,进而得出对应的bucket。
- 如果bucket在old table中,将其重新散列到new table中。
- 在bucket中,查找空闲的位置,如果已经存在需要插入的key,更新其对应的value。
- 根据table中元素的个数,判断是否grow table。
- 如果对应的bucket已经full,重新申请新的bucket作为overbucket。
- 将key/value pair插入到bucket中。
这里也有几个细节需要注意一下。
在扩容过程中,oldbucket是被冻结的,查找时会在oldbucket中查找,但不会在oldbucket中插入数据。如果在oldbucket是找到了相应的key,做法是将它迁移到新bucket后加入evalucated标记。并且还会额外的迁移另一个pair。
然后就是只要在某个bucket中找到第一个空位,就会将key/value插入到这个位置。也就是位置位于bucket前面的会覆盖后面的(类似于存储系统设计中做删除时的常用的技巧之一,直接用新数据追加方式写,新版本数据覆盖老版本数据)。找到了相同的key或者找到第一个空位就可以结束遍历了。不过这也意味着做删除时必须完全的遍历bucket所有溢出链,将所有的相同key数据都删除。所以目前map的设计是为插入而优化的,删除效率会比插入低一些。
扩容
扩容触发条件
- 负载因子大于6.5,也就是元素总数 / 总桶数 > 6.5时,触发翻倍扩容
- 溢出桶过多,一般发生于持续写入数据又全部删除时,触发等量扩容
桶的迁移
在写入函数mapassign和删除函数mapdelete代码中,每次定位桶bmp位置之前都会检查是否处于扩容状态,如果处于则进行旧桶的数据迁移操作。
if h.growing() {
growWork(t, h, bucket) //如果哈希表处于扩容状态,要进行数据迁移工作
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize))) //b指向对应bmp结构体
growing函数判断是否存在旧桶来确定是否处于扩容状态。
func (h *hmap) growing() bool {
return h.oldbuckets != nil
}
growWork函数负责调用两次evacuate函数来进行两个桶的数据迁移,分别是:
- 当前写入或删除key值对应的旧桶
- nevacuate计数值对应的旧桶,每次数据迁移后nevacuate+1,如果和上面的旧桶相同则略过
func growWork(t *maptype, h *hmap, bucket uintptr) {
evacuate(t, h, bucket&h.oldbucketmask()) //最后一个参数是扩容前的桶编号
//设hash值为....1110,扩容前B=2,对应掩码11,翻倍扩容后B=3,对应掩码111
//bucket为当前对应的桶编号 -> ....1110&111 = 110 bucket=110
//找110对应扩容前的桶编号 -> 110&11 = 10 扩容前旧桶编号为10
//evacuate函数负责旧桶到新桶数据的拷贝复制
if h.growing() {
evacuate(t, h, h.nevacuate)
//nevacuate表示迁移进度,从0号桶开始计数,每次触发加自动+1
//每一次写和删除操作不仅会触发上面的evacuate函数对key值对应旧桶的数据迁移,
//也会根据nevacuate迁移进度额外触发一次数据迁移,额外的迁移会检查迁移完成状态,
//如果nevacuate值等于旧桶总数,则对所有旧桶进行清空操作,结束扩容过程
}
}
接下来看evacuate函数,该函数负责对指定旧桶进行数据的迁移:
- 等量扩容时把数据全部迁移到新桶中
- 翻倍扩容时会根据hash值低位来确定分流到哪个新桶中
缺点
- 并没有实现缩容