Go 面试中,经常会被问到数组和 Map 的扩容策略。本文就来总结说明下数组和 Map 的扩容策略。
数组扩容
Go 语言中,动态数组被称为切片(slice),它提供了一种灵活、动态大小的数组解决方案。使用append函数向切片添加元素时,Go 语言会根据切片的当前容量(capacity)来决定是否需要扩容。
Go 中切片扩容的策略是这样的:
- 首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容量;
- 否则判断,如果旧切片的长度小于 1024,则最终容量就是旧容量的两倍;
- 否则判断,如果旧切片长度大于等于 1024,则最终容量从旧容量开始循环增加原来的 1/4,直到最终容量大于等于新申请的容量;
- 如果最终容量计算值溢出,则最终容量就是新申请容量。
哈希表扩容
在 Go 语言中,Map 是一种常用的数据结构,用于存储键值对。其扩容机制对于理解 Map 的性能和内存使用至关重要。
Go 语言中 Map 的两种扩容方式:
- 双倍扩容: 当键值对数量超过当前桶数组容量的6.5倍时,说明桶即将被填满,此时会触发扩容,桶数量翻倍。目的是减少哈希冲突,提升查询效率;
- 等量扩容: 当溢出桶过多(如频繁插入删除导致数据分散)但键值对总数较少时,桶数量不变,但会重新排列数据,合并冗余的溢出桶,使数据分布更紧凑,从而提高查询性能。
Map 的底层结构
Go 语言中的 Map 底层是一个哈希表(hashmap),其结构如下:
Go 代码实现如下:
// runtime/map.go
// A header for a Go map.
type hmap struct {
count int // 当前哈希表中键值对的数量
flags uint8
B uint8 // 当前哈希表持有的 buckets 数量, 因为哈希表中桶的数量都按2倍扩容,改字段存储对数,也就是 len(buckets) == 2^B
noverflow uint16 // 溢出桶的大致数量
hash0 uint32 // hash seed
buckets unsafe.Pointer // 存储 2^B 个桶的数组
oldbuckets unsafe.Pointer // 扩容时用于保存之前 buckets 的字段 , 大小事buckets的一般
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
// mapextra 主要维护,当hmap中的buckets中有一些桶发生溢出,但有达不到扩容阈值时,存储溢出的桶
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
// nextOverflow holds a pointer to a free overflow bucket.
nextOverflow *bmap
}
一些关键字段释义如下:
- count:表示哈希表中键值对的数量;
- B:这是以 2 为底的对数,用于确定桶(bucket)的数量。例如,当 B = 1 时,桶的数量为 2^1 = 2 个;当 B = 2 时,桶的数量为 2^2 = 4 个,以此类推;
- hash0:是计算键的哈希值时用到的一个因子;
- buckets:是一个指向桶数组的指针,每个桶用于存储键值对;
- overflow:当桶中装不下更多元素,且 key 又被 hash 到这个桶时,就会放到溢出桶,原桶的 overflow 字段指向溢出桶(链地址法)。
扩容机制:双倍扩容
触发条件:
当 Map 的负载超过一定阈值时会触发双倍扩容。负载的计算方式是元素个数(count)除以桶的数量(2^B),如果这个比值大于等于 6.5,则认为负载超了。例如,当 B = 2,桶有 2^2 = 4 个,如果元素个数 count 为 26(26/4 = 6.5),就会触发双倍扩容。
源码中相关逻辑如下:
// 假设h为map结构体指针
if!h.flags&hashWriting && h.count > int(float64(1<<h.B)*loadFactor) {
// 触发双倍扩容逻辑
}
扩容过程:
- 双倍扩容时,B 的值会加 1,从而使桶的数量翻倍。例如,原来 B = 2,桶有 4 个,扩容后 B = 3,桶的数量变为 2^3 = 8个;
- 数据迁移时,会将原来桶中的数据重新分配到新的桶中。具体是根据键的哈希值重新计算在新桶中的位置。假设键的哈希值为 hash,原来桶的数量为 2^B1,扩容后桶的数量为 2^B2,则数据会从原来的桶(hash % 2^B1)迁移到新的桶(hash % 2^B2)。例如,原来哈希值为 10,B = 2(桶数量为 4)时,10 % 4 = 2,数据在索引为 2 的桶中;扩容后 B = 3(桶数量为 8),10 % 8 = 2,数据仍在索引为 2 的桶中,但此时桶的分布更稀疏,减少了哈希冲突的概率。
扩容机制:等量扩容
触发条件:
当有大量的键被删除,导致溢出桶过多时,可能会触发等量扩容。这里的溢出桶是指当一个桶存满 8 个元素后,新的元素会存储到溢出桶中,溢出桶不占用桶的数量计数。当溢出桶的数量大于等于 2^B 时,可能触发等量扩容。但如果是由于哈希冲突导致溢出桶过多且负载超了,会优先触发双倍扩容。
源码中相关逻辑如下:
// 假设h为map结构体指针,noverflow为溢出桶数量
if noverflow >= uint16(1)<<(h.B) {
if h.B < 15 {
// 可能触发等量扩容或双倍扩容逻辑
}
}
扩容过程:
等量扩容主要是对桶进行整理,去除空的位置。它会创建一个新的桶数组,然后遍历老的桶数组,将不为空的键值对重新放置到新的桶数组中,同时释放原来的溢出桶。由于哈希因子、B 值和哈希算法都没有变化,键值对在新桶中的位置计算方式与原来相同,只是去除了空的存储位置,使键值对更加紧凑,提高后续操作的效率。