Golang - Map 内部实现原理解析
Golang - Map 内部实现原理解析
一.前言
- Golang中Map存储的是kv键值对,采用哈希表作为底层实现,用拉链法解决hash冲突
本文Go版本:gov1.14.4,源码位于src/runtime/map.go
二.Map的内存模型
在源码中,表示map的结构体是hmap,是hashmap的缩写
const (
// 一个桶(bucket)内 可容纳kv键值对 的最大数量
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
)
// map的底层结构
type hmap struct {
count int // map中kv键值对的数量
flags uint8 // 状态标识符,比如正在被写,buckets和oldbuckets正在被遍历或扩容
B uint8 // 2^B=len(buckets)
noverflow uint16 // 溢出桶的大概数量,当B小于16时是准确值,大于等于16时是大概的值
hash0 uint32 // hash因子
buckets unsafe.Pointer // 指针,指向一个[]bmap类型的数组,数组大小为2^B,我们将一个bmap叫做一个桶,buckets字段我们称之为正常桶,正常桶存满8个元素后,正常桶指向的下一个桶,我们将其叫做溢出桶(拉链法)
oldbuckets unsafe.Pointer // 类型同上,用途不同,用于在扩容时存放之前的buckets
nevacuate uintptr // 计数器,表示扩容进度
extra *mapextra // 用于gc,指向所有的溢出桶,正常桶里面某个bmap存满了,会使用这里面的内存空间存放键值对
}
// 溢出桶结构
type mapextra struct {
overflow *[]*bmap // 指针数组,指向所有溢出桶
oldoverflow *[]*bmap // 指针数组,发生扩容时,指向所有旧的溢出桶
nextOverflow *bmap // 指向 所有溢出桶中 下一个可以使用的溢出桶
}
// 桶结构
type bmap struct {
tophash [bucketCnt]uint8 // 存放key哈希值的高8位,用于决定kv键值对放在桶内的哪个位置
// 以下属性,编译时动态生成,在源码中不存在
keys [bucketCnt]keytype // 存放key的数组
values [bucketCnt]valuetype // 存放value的数组
pad uintptr // 用于对齐内存
overflow uintptr // 指向下一个桶,即溢出桶,拉链法
}
用图表示一下map底层的内存模型:
解析:
-
map的内存模型中,其实总共就三种结构,hmap,bmap,mapextra
-
hmap表示整个map,bmap表示hmap中的一个桶,map底层其实是由很多个桶组成的
-
当一个桶存满之后,指向的下一个桶,就叫做溢出桶,溢出桶就是拉链法的具体表现
-
mapextra表示所有的溢出桶,之所以还要重新的指向,目的是为了用于gc,避免gc时扫描整个map,仅扫描所有溢出桶就足够了
-
桶结构的很多字段得在编译时才会动态生成,比如key和values等
-
桶结构中,之所以所有的key放一起,所有的value放一起,而不是key/value一对对的一起存放,目的便是在某些情况下可以省去pad字段,节省内存空间
-
golang中的map使用的内存是不会收缩的,只会越用越多。
三.Map的设计原理
1.hash值的使用
通过哈希函数,key可以得到一个唯一值,map将这个唯一值,分成高8位和低8位,分别有不同的用途
- 低8位:用于寻找当前key属于哪个bucket
- 高8位:用于寻找当前key在bucket中的位置,bucket有个tohash字段,便是存储的高8位的值,用来声明当前bucket中有哪些key,这样搜索查找时就不用遍历bucket中的每个key,只要先看看tohash数组值即可,提高搜索查找效率
map其使用的hash算法会根据硬件选择,比如如果cpu是否支持aes,那么采用aes哈希,并且将hash值映射到bucket时,会采用位运算来规避mod的开销
2.桶的细节设计
bmap结构,即桶,是map中最重要的底层实现之一,其设计要点如下:
-
桶是map中最小的挂载粒度:map中不是每一个key都申请一个结构通过链表串联,而是每8个kv键值对存放在一个桶中,然后桶再通以链表的形式串联起来,这样做的原因就是减少对象的数量,减轻gc的负担。
-
桶串联实现拉链法:当某个桶数量满了,会申请一个新桶,挂在这个桶后面形成链表,新桶优先使用预分配的桶。
-
哈希高8位优化桶查找key : 将key哈希值的高8位存储在桶的tohash数组中,这样查找时不用比较完整的key就能过滤掉不符合要求的key,tohash中的值相等,再去比较key值
-
桶中key/value分开存放 : 桶中所有的key存一起,所有的value存一起,目的是为了方便内存对齐
-
根据k/v大小存储不同值 : 当k或v大于128字节时,其存储的字段为指针,指向k或v的实际内容,小于等于128字节,其存储的字段为原值
-
桶的搬迁状态 : 可以根据tohash字段的值,是否小于minTopHash,来表示桶是否处于搬迁状态
3.map的扩容与搬迁策略
map底层扩容策略如下:
- map的扩容策略是新分配一个更大的数组,然后在插入和删除key的时候,将对应桶中的数据迁移到新分配的桶中去
map的搬迁策略如下:
- 由于map扩容需要将原有的kv键值对搬迁到新的内存地址,直接一下子全部搬完会非常的影响性能
- 采用渐进式的搬迁策略,将搬迁的O(N)开销均摊到O(1)的赋值和删除操作上
以下两种情况时,会进行扩容:
-
当装载因子超过6.5时,扩容一倍,属于增量扩容
-
当使用的溢出桶过多时间,重新分配一样大的内存空间,属于等量扩容,实际上没有扩容,主要是为了回收空闲的溢出桶
装载因子等于 map中元素的个数 / map的容量,即len(map) / 2^B
- 装载因子用来表示空闲位置的情况,装载因子越大,表明空闲位置越少,冲突也越多
- 随着装载因子的增大,哈希表线性探测的平均用时就会增加,这会影响哈希表的性能,当装载因子大于70%,哈希表的性能就会急剧下降,当装载因子达到100%,整个哈希表就会完全失效,这个时候,查找和插入任意元素的复杂度都是O(N),因为需要遍历所有元素.
为什么会出现以上两种情况?
-
情况1:确实是数据量越来越多,撑不住了
-
情况2:比较特殊,归根结底还是map删除的特性导致的,当我们不断向哈希表中插入数据,并且将他们又全部删除时,其内存占用并不会减少,因为删除只是将桶对应位置的tohash置nil而已,这种情况下,就会不断的积累溢出桶造成内存泄露。为了解决这种情况,采用了等量扩容的机制,一旦哈希表中出现了过多的溢出桶,她会创建新桶保存数据,gc会清理掉老的溢出桶,从而避免内存泄露。
如何定义溢出桶是否太多需要等量扩容呢?两种情况:
- 当B小于15时,溢出桶的数量超过2^B,属于溢出桶数量太多,需要等量扩容
- 当B大于等于15时,溢出桶数量超过2^B,属于溢出桶数量太多,需要等量扩容
4.map泛型的实现
- golang并没有实现泛型,为了支持map的泛型,底层定义了一个maptype类型,maptype定义了这类key使用什么hash函数,定义了bucket的大小,bucket如何比较。
type maptype struct {
typ _type
key *_type // key类型
elem *_type // value类型
bucket *_type // 桶内部使用的类型
hasher func(unsafe.Pointer, uintptr) uintptr // 哈希函数
keysize uint8 // key大小
elemsize uint8 // value大小
bucketsize uint16 // bucket大小
flags uint32
}
四.Map的源码实现
1.创建map
- 创建map,主要是创建hmap这个结构,以及对hmap的初始化
// 创建map
func makemap(t *maptype, hint int, h *hmap) *hmap {
// 参数校验,计算哈希占用的内存是否溢出或者超出能分配的最大值
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
// 初始化 hmap
if h == nil {
h = new(hmap)
}
// 获取一个随机的哈希种子
h.hash0 = fastrand()
// 确定B的大小
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
// 分配桶
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
makeBucketArray函数是给buckets字段分配桶空间的,知道大致功能就ok了
- 默认会创建2^B个bucket,如果b大于等于4,会预先创建一些溢出桶,b小于4的情况可能用不到溢出桶,没必要预先创建
2.map中赋值元素
- mapassign函数,从非常宏观的角度,抛开并发安全和扩容等操作不谈,大致可以分成下面五个步骤
// 往map中添加元素/修改元素值
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
if raceenabled {
callerpc := getcallerpc()
pc := funcPC(mapassign)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled {
msanread(key, t.key.size)
}
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
// 第一部分: 确认哈希值
hash := t.hasher(key, uintptr(h.hash0))
h.flags ^= hashWriting
if h.buckets == nil {
h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
}
again:
// 第二部分: 根据hash值确认key所属的桶
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
top := tophash(hash)
var inserti *uint8
var insertk unsafe.Pointer
var elem unsafe.Pointer
bucketloop:
// 第三部分: 遍历所属桶和此桶串联的溢出桶,寻找key(通过桶的tohash字段和key值)
for {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
}
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if !t.key.equal(key, k) {
continue
}
if t.needkeyupdate() {
typedmemmove(t.key, k, key)
}
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
goto done
}
ovf := b.overflow(t)
if ovf == nil {
break
}
b = ovf
}
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again
}
// 第四部分: 当前链上所有桶都满了,创建一个新的溢出桶,串联在末尾,然后更新相关字段
if inserti == nil {
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
// 第五部分 根据key是否存在,在桶中更新或者新增key/value值
if t.indirectkey() {
kmem := newobject(t.key)
*(*unsafe.Pointer)(insertk) = kmem
insertk = kmem
}
if t.indirectelem() {
vmem := newobject(t.elem)
*(*unsafe.Pointer)(elem) = vmem
}
typedmemmove(t.key, insertk, key)
*inserti = top
h.count++
done:
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
h.flags &^= hashWriting
if t.indirectelem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem
}
3.map中删除元素
- mapdelete函数,大致可以分为以下六步
// map中删除元素
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
if raceenabled && h != nil {
callerpc := getcallerpc()
pc := funcPC(mapdelete)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled && h != nil {
msanread(key, t.key.size)
}
if h == nil || h.count == 0 {
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return
}
// 第一部分: 写保护
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
// 第二部分: 获取hash值
hash := t.hasher(key, uintptr(h.hash0))
// Set hashWriting after calling t.hasher, since t.hasher may panic,
// in which case we have not actually done a write (delete).
h.flags ^= hashWriting
// 第三部分: 根据hash值确定桶,并看是否需要扩容
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
bOrig := b
top := tophash(hash)
// 第四部分:遍历桶和桶串联的溢出桶
search:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
// 快速试错
if b.tophash[i] == emptyRest {
break search
}
continue
}
// 第五部分: 找到key,然后将桶的该key的tohash值置空,相当于删除值了
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
if !t.key.equal(key, k2) {
continue
}
// Only clear key if there are pointers in it.
if t.indirectkey() {
*(*unsafe.Pointer)(k) = nil
} else if t.key.ptrdata != 0 {
memclrHasPointers(k, t.key.size)
}
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
*(*unsafe.Pointer)(e) = nil
} else if t.elem.ptrdata != 0 {
memclrHasPointers(e, t.elem.size)
} else {
memclrNoHeapPointers(e, t.elem.size)
}
b.tophash[i] = emptyOne
// If the bucket now ends in a bunch of emptyOne states,
// change those to emptyRest states.
// It would be nice to make this a separate function, but
// for loops are not currently inlineable.
if i == bucketCnt-1 {
if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
goto notLast
}
} else {
if b.tophash[i+1] != emptyRest {
goto notLast
}
}
for {
b.tophash[i] = emptyRest
if i == 0 {
if b == bOrig {
break // beginning of initial bucket, we're done.
}
// Find previous bucket, continue at its last entry.
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
i = bucketCnt - 1
} else {
i--
}
if b.tophash[i] != emptyOne {
break
}
}
notLast:
h.count--
break search
}
}
// 第六部分: 解除写保护
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
h.flags &^= hashWriting
}
需要注意的是:
- 删除key仅仅只是将其对应的tohash值置空,如果kv存储的是指针,那么会清理指针指向的内存,否则不会真正回收内存,内存占用并不会减少
- 如果正在扩容,并且操作的bucket没有搬迁完,那么会搬迁bucket
4.map中查询元素
- mapaccess1函数
// map中查找元素
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if raceenabled && h != nil {
callerpc := getcallerpc()
pc := funcPC(mapaccess1)
racereadpc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled && h != nil {
msanread(key, t.key.size)
}
if h == nil || h.count == 0 {
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return unsafe.Pointer(&zeroVal[0])
}
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
// 第一部分:计算hash值并根据hash值找到桶
hash := t.hasher(key, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// There used to be half as many buckets; mask down one more power of two.
m >>= 1
}
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
if !evacuated(oldb) {
b = oldb
}
}
top := tophash(hash)
// 第二部分:遍历桶和桶串联的溢出桶,寻找key
bucketloop:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if t.key.equal(key, k) {
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
return e
}
}
}
return unsafe.Pointer(&zeroVal[0])
}
需要注意的地方:
- 如果根据hash值定位到桶正在进行搬迁,并且这个bucket还没有搬迁到新哈希表中,那么就从老的哈希表中找。
- 在bucket中进行顺序查找,使用高八位进行快速过滤,高八位相等,再比较key是否相等,找到就返回value。如果当前bucket找不到,就往下找溢出桶,都没有就返回零值。
5.map的扩容与搬迁
- 通过上述的map赋值和删除流程,我们知道,触发扩容操作的是map的赋值和删除操作
- 扩容操作的要点其实在于搬迁
// 扩容
func growWork(t *maptype, h *hmap, bucket uintptr) {
// 搬迁正在使用的旧 bucket
evacuate(t, h, bucket&h.oldbucketmask())
// 再搬迁一个 bucket,以加快搬迁进程
if h.growing() {
evacuate(t, h, h.nevacuate)
}
}
// 是否需要扩容
func (h *hmap) growing() bool {
return h.oldbuckets != nil
}
// 搬迁bucket
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
// 定位老的 bucket 地址
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
// 计算容量 结果是 2^B,如 B = 5,结果为32
newbit := h.noldbuckets()
// 如果 b 没有被搬迁过
if !evacuated(b) {
// 默认是等 size 扩容,前后 bucket 序号不变
var xy [2]evacDst
// 使用 x 来进行搬迁
x := &xy[0]
x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
x.k = add(unsafe.Pointer(x.b), dataOffset)
x.v = add(x.k, bucketCnt*uintptr(t.keysize))
// 如果不是等 size 扩容,前后 bucket 序号有变
if !h.sameSizeGrow() {
// 使用 y 来进行搬迁
y := &xy[1]
// y 代表的 bucket 序号增加了 2^B
y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
y.k = add(unsafe.Pointer(y.b), dataOffset)
y.v = add(y.k, bucketCnt*uintptr(t.keysize))
}
// 遍历所有的 bucket,包括 overflow buckets b 是老的 bucket 地址
for ; b != nil; b = b.overflow(t) {
k := add(unsafe.Pointer(b), dataOffset)
v := add(k, bucketCnt*uintptr(t.keysize))
// 遍历 bucket 中的所有 cell
for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) {
// 当前 cell 的 top hash 值
top := b.tophash[i]
// 如果 cell 为空,即没有 key
if top == empty {
// 那就标志它被"搬迁"过
b.tophash[i] = evacuatedEmpty
continue
}
// 正常不会出现这种情况
// 未被搬迁的 cell 只可能是 empty 或是
// 正常的 top hash(大于 minTopHash)
if top < minTopHash {
throw("bad map state")
}
// 如果 key 是指针,则解引用
k2 := k
if t.indirectkey {
k2 = *((*unsafe.Pointer)(k2))
}
var useY uint8
// 如果不是等量扩容
if !h.sameSizeGrow() {
// 计算 hash 值,和 key 第一次写入时一样
hash := t.key.alg.hash(k2, uintptr(h.hash0))
// 如果有协程正在遍历 map 如果出现 相同的 key 值,算出来的 hash 值不同
if h.flags&iterator != 0 && !t.reflexivekey && !t.key.alg.equal(k2, k2) {
// useY =1 使用位置Y
useY = top & 1
top = tophash(hash)
} else {
// 第 B 位置 不是 0
if hash&newbit != 0 {
//使用位置Y
useY = 1
}
}
}
if evacuatedX+1 != evacuatedY {
throw("bad evacuatedN")
}
//决定key是裂变到 X 还是 Y
b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
dst := &xy[useY] // evacuation destination
// 如果 xi 等于 8,说明要溢出了
if dst.i == bucketCnt {
// 新建一个 bucket
dst.b = h.newoverflow(t, dst.b)
// xi 从 0 开始计数
dst.i = 0
//key移动的位置
dst.k = add(unsafe.Pointer(dst.b), dataOffset)
//value 移动的位置
dst.v = add(dst.k, bucketCnt*uintptr(t.keysize))
}
// 设置 top hash 值
dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check
// key 是指针
if t.indirectkey {
// 将原 key(是指针)复制到新位置
*(*unsafe.Pointer)(dst.k) = k2 // copy pointer
} else {
// 将原 key(是值)复制到新位置
typedmemmove(t.key, dst.k, k) // copy value
}
//value同上
if t.indirectvalue {
*(*unsafe.Pointer)(dst.v) = *(*unsafe.Pointer)(v)
} else {
typedmemmove(t.elem, dst.v, v)
}
// 定位到下一个 cell
dst.i++
dst.k = add(dst.k, uintptr(t.keysize))
dst.v = add(dst.v, uintptr(t.valuesize))
}
}
// Unlink the overflow buckets & clear key/value to help GC.
// bucket搬迁完毕 如果没有协程在使用老的 buckets,就把老 buckets 清除掉,帮助gc
if h.flags&oldIterator == 0 && t.bucket.kind&kindNoPointers == 0 {
b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
ptr := add(b, dataOffset)
n := uintptr(t.bucketsize) - dataOffset
memclrHasPointers(ptr, n)
}
}
// 更新搬迁进度
if oldbucket == h.nevacuate {
advanceEvacuationMark(h, t, newbit)
}
}
五.FQA
1.为什么map遍历是无序的?
- 因为map底层的扩容与搬迁
- map在扩容后,会发生key的搬迁,原来在同一个桶的key,搬迁后,有可能就不处于同一个桶了,而遍历map的过程,就是遍历这些桶,桶里的元素发生了变化,那么map遍历当然就是无序的啦
2.map并发访问安全吗?
- 不安全
- 有两个解决方法:
- 加锁
- 使用golang自带的sync.map
3.map元素为何无法取地址?
- 因为扩容后map元素的地址会发生变化,归根结底还是map底层的扩容与搬迁
六.小结
- Golang中,通过哈希表实现map,用拉链法解决哈希冲突
- 通过将key的哈希值散落到不同桶中,每个桶中8个cell,哈希值的低8位决定在哪个桶,哈希值的高八位决定在桶的的哪个位置
- 扩容分为等量扩容和2倍增量扩容
- 当向桶中添加了很多key,造成溢出桶太多,会触发等量扩容,扩容后,原来一个桶中的key会一分为二,重新分配到两个桶中
- 扩容过程是渐进式的,主要是防止一次扩容要搬迁的元素太多引发性能问题
- 触发扩容的时间是在新增元素,搬迁的时间是赋值和删除操作期间,每次最多搬迁两个bucket
- 查找,赋值,删除这些操作一个很核心的内容都是如何定位key的位置
七.参考文章
- https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-hashmap/#删除
- https://golang.design/go-questions/map/principal/
- https://juejin.cn/post/6972535873971847204#heading-11
- https://i6448038.github.io/2018/08/26/map-secret/
- https://segmentfault.com/a/1190000039101378
- https://aimuke.github.io/go/2019/05/16/map/#42-查询mapaccess1
- https://www.helloworld.net/p/3714029944
- https://yangxikun.com/golang/2019/10/07/golang-map.html