1. Map 简介
由一组 <key, value> 对组成的数据结构,并且同一个 key 只出现一次。
想一想下面的程序将输出什么呢?
func main(){
m := make(map[int]int)
modifyMap(m)
fmt.Print(m)
}
func modifyMap(m map[int]int){
m[1] = 1
m[2] = 2
}
答案是:map[1:1 2:2]。但我们有没有想过为什么能这样赋值呢?
-
map的任务是设计一种数据结构来维护一个集合的数据,并且可以同时对集合进行增删查改的操作。最主要的数据结构有两种:
哈希查找表、搜索树。 -
哈希查找表用哈希函数将 key 分配到不同的桶(
bucket,即数组的不同 index)。开销主要在hash的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。 -
哈希查找表一般会存在
碰撞的问题,即不同的 key 被哈希到同一个 bucket。两种应对方法:链表法:将 bucket 实现成链表,落在同一个 bucket 中的 key 都会插入这个链表。开放地址法:碰撞发生后,通过一定规律,在数组的后面挑选空位放置新的 key。
-
搜索树法一般采用自平衡搜索树,包括:AVL 树,红黑树。
-
自平衡搜索树法的最差搜索效率是
O(logN),而哈希查找表最差是O(N)。当然,哈希查找表的平均查找效率是O(1)。遍历自平衡搜索树返回的 key 序列,一般会按照从小到大的顺序;而哈希查找表则是乱序的。
2. Map的底层实现
2.1 map内存模型
源码中表示 map 的结构体是 hmap,hashmap 的缩写:
type hmap struct {
// 元素个数,调用 len(map) 时返回此值
count int
flags uint8
// buckets数组长度 的对数
B uint8
// overflow 的 bucket 近似数
noverflow uint16
// 哈希函数
hash0 uint32
// 指向 buckets 数组,大小为 2^B
buckets unsafe.Pointer
// 扩容时,buckets 长度会是 oldbuckets 的两倍
oldbuckets unsafe.Pointer
// 指示扩容进度,小于此地址的 buckets 迁移完成
nevacuate uintptr
extra *mapextra // optional fields
}
B 是 buckets 数组的长度的对数,即 buckets 数组的长度就是 2^B。bucket 里面存储了 key 和 value。
buckets 是一个指针,最终指向该结构体
type bmap struct {
tophash [bucketCnt]uint8
}
但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
bmap 就是所谓的“桶”,桶里最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过hash后的结果相同。在桶内,又会根据 key 计算出来的 hash 值的高8位来决定 key 到底落入桶内的哪个位置。
bmap 是存放 k-v 的地方,我们把视角拉近,仔细看 bmap 的内部组成。
HOB Hash 指 top hash。 注意到 key 和 value 是各自放在一起的。这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间。
例如,有这样一个类型的 map:
map[int64]int8
如果按照 key/value... 模式存储,那在每个key/value对后都要额外 padding 7 个字节;而将所有的 key,value 分别绑定到一起,则只需在最后添加 padding。
bucket最多能放8个键值,如果有超出的键值落入当前的 bucket,则再构建一个 bucket ,通过 overflow 指针连接。
2.2 创建 Map
底层调用 makemap 函数,主要工作是初始化 hmap 的各种字段:
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
// 省略各种条件检查...
// 找到一个 B,使得 map 的装载因子在正常范围内
B := uint8(0)
for ; overLoadFactor(hint, B); B++ {
}
// 初始化 hash table
// 如果 B 等于 0,那么 buckets 就会在赋值的时候再分配
buckets := bucket
var extra *mapextra
if B != 0 {
var nextOverflow *bmap
buckets, nextOverflow = makeBucketArray(t, B)
if nextOverflow != nil {
extra = new(mapextra)
extra.nextOverflow = nextOverflow
}
}
// 初始化 hamp
if h == nil {
h = (*hmap)(newobject(t.hmap))
}
//此处省略对结构体的赋值...
return h
}
注意!该函数返回*hmap,它是一个指针,而之前讲过的 makeSlice 函数返回的是 Slice 结构体:
func makeslice(et *_type, len, cap int) slice
作为函数参数时,在函数内部对 map 的操作会影响 map 自身;而切片只能改变不扩容情况下的底层数组。
主要原因:一个是指针(*hmap),一个是结构体(slice)。Go 语言中的函数传参都是值传递。*hmap指针 copy 后仍指向同一个 map,因此函数内部对 map 的操作会影响实参。而 slice 被 copy 后,会成为一个新的 slice,对它进行的操作不会影响到实参。
2.3 哈希函数
map 的一个关键点在于哈希函数的选择。程序启动时,会检测 cpu 是否支持 aes,支持则使用aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。
选择 hash 函数主要考察的是两点:性能、碰撞概率。
之前讲过表示类型的结构体:
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
gcdata *byte
str nameOff
ptrToThis typeOff
}
其中 alg 字段就和哈希相关,它是指向如下结构体的指针:
// src/runtime/alg.go
type typeAlg struct {
// (ptr to object, seed) -> hash
hash func(unsafe.Pointer, uintptr) uintptr
// (ptr to object A, ptr to object B) -> ==?
equal func(unsafe.Pointer, unsafe.Pointer) bool
}
两个函数:
- hash 计算类型的哈希值
- equal 计算两个类型是否
哈希相等。
对于 string 类型,它的 hash、equal 函数如下:
func strhash(a unsafe.Pointer, h uintptr) uintptr {
x := (*stringStruct)(a)
return memhash(x.str, h, uintptr(x.len))
}
func strequal(p, q unsafe.Pointer) bool {
return *(*string)(p) == *(*string)(q)
}
根据 key 的类型,_type 结构体的 alg 字段会被设置对应类型的 hash 和 equal 函数。
2.4 key 定位过程
key 经过哈希计算后得到哈希值,共 64 个 bit 位(64位机),计算落在哪个桶时,只会用到最后 B个 bit 位。还记得前面提到过的 B 吗?如果 B = 5,那么桶的数量,即 buckets 数组的长度是 2^5 = 32。
例:一个 key 经过hash后得到的结果:
10010111 | 000011110110110010001111001010100010010110010101010 │ 01010
最后 5 个 bit 位 01010,值为 10,即 10 号桶。该操作实际上就是取余操作,但取余开销太大,所以用位操作代替。
高 8 位定位 key 在 bucket 中的位置。最开始桶内没有 key,新加入的 key 找到第一个空位并放入,并把该位置的 HOB Hash 填入key 的高八位。
当两个不同的 key 落在同一个桶中,即发生了哈希冲突。冲突的解决手段是用链表法:在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。
上图中,假定 B = 5,所以 bucket 总数是 32。首先计算出待查找 key 的哈希,使用低 5 位 00110,找到对应的 6 号 bucket,使用高 8 位 10010111,对应十进制 151,在 6 号 bucket 中寻找 tophash 值(HOB hash)为 151 的 key,找到了 2 号槽位,这样整个查找过程就结束了。
如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找。
当定位到一个具体的 bucket 时,里层循环就是遍历这个 bucket 里所有的 cell,或者说所有的槽位。整个循环过程:
再说一下minTopHash,当一个 cell 的 tophash < minTopHash 时,标志这个 cell 的迁移状态。因为状态放在 tophash 数组里,为了和正常的哈希值区分开,会给 key 计算出来的哈希值一个增量:minTopHash。这样就能区分正常的 top hash 值和表示状态的哈希值。
// 空的 cell,也是初始时 bucket 的状态
empty = 0
// 空的 cell,表示 cell 已经被迁移到新的 bucket
evacuatedEmpty = 1
// key,value 已经搬迁完毕,但是 key 都在新 bucket 前半部分
evacuatedX = 2
// 同上,key 在后半部分
evacuatedY = 3
// tophash 的最小正常值
minTopHash = 4
源码里判断这个 bucket 是否已经搬迁完毕,用到的函数:
func evacuated(b *bmap) bool {
h := b.tophash[0] // 第一个都没有存储就更不用说之后的了
return h > empty && h < minTopHash
}
取 tophash 数组的第一个值,判断是否在 0-4 之间。当 top hash 是 evacuatedEmpty、evacuatedX、evacuatedY 三个值之一,说明此 bucket 中的 key 全部被搬迁到了新 bucket。
2.5 如何进行扩容
随着 key 越来越多,发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,增删查改 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大。
Go 语言采用一个 bucket 里装 8 个 key,定位到 bucket 后,还需要再定位到具体的 key,用时间换空间。
这样做要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。
因此,需要有一个指标来衡量前面描述的情况,这就是装载因子:
loadFactor := count / (2^B)
count :map 的元素个数,2^B :bucket 数量。
触发 map 扩容的时机:插入新 key 时,进行条件检测,符合下面这 2 个条件之一触发扩容:
- 装载因子超过阈值(6.5)。
- overflow 的 bucket 数量过多:当 B 小于 15,即 bucket 总数 < 2^15 时,如果 overflow 的 bucket 数量超过 2^B;或当 B >= 15,即 bucket 总数 >= 2^15,如果 overflow 的 bucket 数量超过 2^15。
对应的解释
-
每个 bucket 有 8 个空位,在没有溢出且所有桶都装满了的情况下,装载因子为 8。因此当装载因子> 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。
-
装载因子比较小的情况下, map 的查找和插入效率也很低,而第1点识别不出来这种情况。表面现象就是 map 里元素总数少,但是 bucket 数量多(大量的 overflow bucket)。
不难想像造成这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多 bucket,但装载因子达不到临界值,未触发扩容。之后,删除元素后再插入很多元素,导致创建很多的 overflow bucket,但依然没达到临界值。overflow bucket 数量太多,导致 key 很分散,查找插入效率即低。
不同扩容策略:
-
元素太多,bucket太少。将
B加 1,bucket 最大数量翻倍。于是就有新老 bucket了。这时元素都在老 bucket 里,还没迁移到新bucket。 -
元素没那么多,但 overflow bucket 数特别多,即很多bucket都没装满。开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使同一个 bucket 中的 key 排列地更紧密。这样原来在 overflow bucket 中的 key 可以移动到 bucket 中来。
扩容具体是怎么做的:map 扩容需要将原有的 key/value 搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,非常影响性能。因此 Go map 采取渐进式,原有的 key 并不会一次性搬迁完,每次最多只搬迁 2 个 bucket。
对于条件2,从老的 buckets 搬迁到新的 buckets,由于数量不变,可以按序号来搬,原来在0号bucktes,到新地方,仍然放在 0 号 buckets。
条件1就没这么简单了。要重新计算 key 的哈希才能决定落在哪个 bucket。例如,原来 B = 5,计算出 key 的哈希后,只用看它的低 5 位,就能决定它落在哪个 bucket。扩容后,B 变成了 6,因此需要多看一位,它的低 6 位决定 key 落在哪个 bucket。这称为 rehash。
因此,某个 key 在搬迁前后 bucket 序号可能和原来相等,也可能是相比原来加上 2^B(原来的 B 值),取决于 hash 值 第 6 bit 位是 0 还是 1。
理解了上面 bucket 序号的变化,我们就可以回答另一个问题了:
为什么遍历 map 是无序的?
map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,遍历 map 的结果就不可能按原来的顺序了。
当然,如果我不向 map 插入删除,按理说每次遍历 map 都会返回一个固定顺序的 key/value 序列吧。的确是这样,但是 Go 杜绝了这种做法,因为这样会给新手程序员带来误解,以为这是一定会发生的事情。
遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,而是从一个随机序号的 bucket 开始,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使一个写死的 map,也不太可能会返回一个固定序列的 key/value 对。
下面通过图来宏观地看一下扩容前后的变化。
扩容前,B = 2,共有 4 个 buckets,lowbits 表示 hash 值的低位。假设我们不关注其他 buckets 情况,专注在 2 号 bucket。并且假设 overflow 太多,触发了等量扩容(条件 2)。
扩容完成后,overflow bucket 消失了,key 都集中到了一个 bucket,更为紧凑了,提高了查找的效率。
假设触发了 2 倍的扩容,那么扩容完成后,老 buckets 中的 key 分裂到了 2 个 新的 bucket。一个在 x part,一个在 y 的 part。依据是 hash 的 lowbits。新 map 中 0-3 称为 x part,4-7 称为 y part。
上面的两张图忽略了其他 buckets 的搬迁情况,表示所有的 bucket 都搬迁完毕后的情形。实际上搬迁是一个渐进的过程,并不会一下全部搬完。所以在搬迁过程中,oldbuckets 指针还会指向老的 []bmap,已经搬迁完毕的 key 的 tophash 值会是一个状态值,表示 key 的搬迁去向。
2.6 map 的遍历
本来遍历 map 比较简单:遍历所有的 bucket 以及它后面挂的 overflow bucket,然后挨个遍历 bucket 中的cell。每个 bucket 中包含 8 个 cell,从有 key 的 cell 中取出 key 和 value。
但是,现实并没有这么简单。还记得前面讲过的扩容过程吗?扩容过程不是一个原子的操作,它每次最多只搬运 2 个 bucket,所以如果触发了扩容操作,在很长时间里,map 的状态都是处于一个中间态:有些 bucket 已经搬迁到新家,而有些 bucket 还待在老地方。
因此,遍历如果发生在扩容的过程中,就会涉及到遍历新老 bucket 的过程,这是难点所在。
我先写一个简单的代码样例,假装不知道遍历过程具体调用的是什么函数:
func main() {
ageMp := make(map[string]int)
ageMp["qcrao"] = 18
for name, age := range ageMp {
fmt.Println(name, age)
}
}
执行命令:
go tool compile -S main.go
得到汇编命令。这里就不逐行讲解了。
关键的几行汇编代码如下:
// ......
0x0124 00292 (test16.go:9) CALL runtime.mapiterinit(SB)
// ......
0x01fb 00507 (test16.go:9) CALL runtime.mapiternext(SB)
0x0200 00512 (test16.go:9) MOVQ ""..autotmp_4+160(SP), AX
0x0208 00520 (test16.go:9) TESTQ AX, AX
0x020b 00523 (test16.go:9) JNE 302
// ......
先是调用 mapiterinit 函数初始化迭代器,然后循环调用 mapiternext 函数进行 map 迭代。
迭代器的结构体定义:
type hiter struct {
// key 指针
key unsafe.Pointer
// value 指针
value unsafe.Pointer
// map 类型,包含如 key size 大小等
t *maptype
// map header
h *hmap
// 初始化时指向的 bucket
buckets unsafe.Pointer
// 当前遍历到的 bmap
bptr *bmap
overflow [2]*[]*bmap
// 起始遍历的 bucet 编号
startBucket uintptr
// 遍历开始时 cell 的编号(每个 bucket 中有 8 个 cell)
offset uint8
// 是否从头遍历了
wrapped bool
// B 的大小
B uint8
// 指示当前 cell 序号
i uint8
// 指向当前的 bucket
bucket uintptr
// 因为扩容,需要检查的 bucket
checkBucket uintptr
}
mapiterinit 对 hiter 结构体里的字段进行初始化。
假设我们有下图所示的一个 map,起始时 B = 1,有两个 bucket,后来触发了扩容,B 变成 2。并且 1 号 bucket 中的内容搬迁到了新的 bucket,1 号裂变成 1 号和 3 号;0 号 bucket 暂未搬迁。老的 bucket 挂在在 *oldbuckets 指针上面,新的 bucket 则挂在 *buckets 指针上面。
这时,我们对此 map 进行遍历。假设经过初始化后,startBucket = 3,offset = 2。于是,遍历的起点将是 3 号 bucket 的 2 号 cell,下面这张图就是开始遍历时的状态:
标红的表示起始位置,bucket 遍历顺序为:3 -> 0 -> 1 -> 2。
因为 3 号 bucket 对应老的 1 号 bucket,因此先检查老 1 号 bucket 是否已经被搬迁过:
func evacuated(b *bmap) bool {
h := b.tophash[0]
return h > empty && h < minTopHash
}
如果 b.tophash[0] 的值在标志值范围内,即在 (0,4) 区间里,说明已经被搬迁过了。
empty = 0
evacuatedEmpty = 1
evacuatedX = 2
evacuatedY = 3
minTopHash = 4
在本例中,老 1 号 bucket 已经被搬迁过了。所以它的 tophash[0] 值在 (0,4) 范围内,因此只用遍历新的 3 号 bucket。
依次遍历 3 号 bucket 的 cell,会找到第一个非空的 key:元素 e。到这里,mapiternext 函数返回,这时我们的遍历结果仅有一个元素:
由于返回的 key 不为空,所以会继续调用 mapiternext 函数。
继续从上次遍历到的地方往后遍历,从新 3 号 overflow bucket 中找到了元素 f 和 元素 g。
遍历结果集也因此壮大:
新 3 号 bucket 遍历完之后,回到了新 0 号 bucket。0 号 bucket 对应老的 0 号 bucket,经检查,老 0 号 bucket 并未搬迁,因此对新 0 号 bucket 的遍历就改为遍历老 0 号 bucket。那是不是把老 0 号 bucket 中的所有 key 都取出来呢?
并没有这么简单,回忆一下,老 0 号 bucket 在搬迁后将裂变成 2 个 bucket:新 0 号、新 2 号。而我们此时正在遍历的只是新 0 号 bucket(注意,遍历的是 *bucket 指针(新buckets))。所以,我们只会取出老 0 号 bucket 中那些在裂变之后,分配到新 0 号 bucket 中的那些 key。
因此,lowbits == 00 的将进入遍历结果集:
和之前的流程一样,遍历新 1 号 bucket,发现老 1 号已经搬迁,只用遍历新 1 号 bucket 中现有的元素就可以了。结果集变成:
继续遍历新 2 号 bucket,它来自老 0 号 bucket,因此需要在老 0 号 bucket 中那些会裂变到新 2 号 bucket 中的 key,也就是 lowbit == 10 的那些 key。
这样,遍历结果集变成:
最后,继续遍历到新 3 号 bucket 时,发现所有的 bucket 都已经遍历完毕,整个迭代过程执行完毕。
map 遍历的核心在于理解 2 倍扩容时,老 bucket 会分裂到 2 个新 bucket 中去。而遍历操作,会按照新 bucket 的序号顺序进行,碰到老 bucket 未搬迁的情况时,要在老 bucket 中找到将来要搬迁到新 bucket 来的 key。
2.7 map 的赋值
通过汇编语言可以看到,向 map 中插入或者修改 key,最终调用的是 mapassign 函数。
插入和修改 key 的语法是一样的,只不过前者操作的 key 在 map 中不存在,而后者存在。
mapassign 有一个系列的函数,根据 key 类型的不同,编译器会将其优化为相应的“快速函数”。
| key 类型 | 插入 |
|---|---|
| uint32 | mapassign_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer |
| uint64 | mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer |
| string | mapassign_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer |
我们只研究最一般的赋值函数 mapassign。
流程非常简单:对 key 计算 hash 值,根据 hash 值找到要赋值的位置(可能是插入新 key,也可能是更新老 key),对相应位置进行赋值。
函数首先检查 map 的标志位 flags。如果 flags 的写标志位为 1 ,说明有其他协程在执行“写”操作,进而导致程序 panic。这也说明 map 对协程是不安全的。
扩容是渐进式的,如果 map 处在扩容过程中,那么当 key 定位到某个 bucket 后,需确保这个 bucket 对应的老 bucket 完成了迁移过程。即老 bucket 里的 key 都要迁移到新的 bucket 中,才能在新的 bucket 中进行插入或者更新的操作。
定位 key 应该放置的位置。准备两个指针,一个(inserti)指向 key 的 hash 值在 tophash 数组所处的位置,另一个(insertk)指向 cell 的位置(即 key 最终放置的地址),当然,对应 value 的位置就很容易定位出来了。这三者实际上都是关联的,在 tophash 数组中的索引位置决定了 key 在整个 bucket 中的位置,而 value 的位置需要“跨过” 8 个 key 的长度。
循环过程中,inserti 和 insertk 指向第一个找到的空闲的 cell。如果之后在 map 没有找到 key 的存在,即原来 map 中没有此 key,这意味着插入新 key。那最终 key 的安置地址就是第一次发现的“空位”(tophash 是 empty)。
如果这个 bucket 的 8 个 key 都已经放置满了,那在跳出循环后,发现 inserti 和 insertk 都是空,这时需要在 bucket 后挂上 overflow bucket。也有可能是在 overflow bucket 后面再挂上一个 overflow bucket。说明太多 key hash 到了此 bucket。
正式安置 key 之前,还要检查 map 的状态,看它是否需要进行扩容。如果满足扩容的条件,就主动触发一次扩容操作。
这之后,整个之前的查找定位 key 的过程,还得再重新走一次。因为扩容之后,key 的分布都发生了变化。
最后,会更新 map 相关的值,如果是插入新 key,map 的元素数量字段 count 值会加 1;
有一个重要的点要说一下。前面说的找到 key 的位置,进行赋值操作,实际上并不准确。看 mapassign 函数的原型就知道,函数并没有传入 value 值
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
函数返回的指针就是指向的 key 所对应的 value 值位置,有了地址,就很好操作赋值了。
2.8 map 的删除
写操作底层的执行函数是 mapdelete:
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer)
首先检查 h.flags ,如果写标位是 1,直接 panic,因为这表明有其他协程同时在进行写操作。
计算 key 的哈希,找到落入的 bucket。检查此 map 如果正在扩容的过程中,直接触发一次搬迁操作。
删除操作同样是两层循环,核心还是找到 key 的具体位置。
找到对应位置后,对 key 或者 value 进行“清零”操作:
最后,将 count 值减 1,将对应位置的 tophash 值置成 Empty。
可以边遍历边删除吗?
map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。
一般而言,可以通过读写锁来解决:sync.RWMutex。
读之前调用 RLock() 函数,读完之后调用 RUnlock() 函数解锁;写之前调用 Lock() 函数,写完之后,调用 Unlock() 解锁。
另外,sync.Map 是线程安全的 map。
总结
Go 语言中,通过哈希查找表实现 map,用链表法解决哈希冲突。
通过 key 的哈希值将 key 散落到不同的桶中,每个桶中有 8 个 cell。哈希值的低位决定桶序号,高位标识同一个桶中的不同 key。
当向桶中添加了很多 key,造成元素过多,或者溢出桶太多,就会触发扩容。扩容分为等量扩容和 2 倍容量扩容。扩容后,原来一个 bucket 中的 key 一分为二,会被重新分配到两个桶中。
扩容过程是渐进的,主要是防止一次扩容需要搬迁的 key 数量过多,引发性能问题。触发扩容的时机是增加了新元素,bucket 搬迁的时机则发生在赋值、删除期间,每次最多搬迁两个 bucket。
本文转载自 饶全成老师的《深度揭秘Go语言之map》,进行了部分修改
原文链接:qcrao.com/post/dive-i…