1.创建
/*
:params t: 存储了key/value和桶大小等信息
:params hint: 申请的元素大小(0则只初始化,不分配hash数组)
:params h: 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
}
// 初始化map
if h == nil {
h = new(hmap)
}
// 随机hash种子
h.hash0 = fastrand()
// B的大小应满足 hint <= (2^B)*6.5 && hint > bucketCnt
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
// 如果B==0,则Buckets数组将会延迟初始化,直到调用mapassign给该map存值
if h.B != 0 {
var nextOverflow *bmap
// makeBucketArray会再hash数组后面预分配一些溢出桶
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
// h.extra.nextOverflow用来保存上述溢出桶的首地址
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
1.1 hash数组分配
为什么要预留溢出桶
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
// base代表用户预期的桶数量,即hash数组的真实大小
base := bucketShift(b)
// nbuckets表示实际分配的桶数量,这就可能会追加一些溢出桶作为溢出的预留(nbuckets >=base)
nbuckets := base
if b >= 4 {
// 这里追加了一定数量的桶,并做了内存对齐
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}
// 下边的都是进行内存分配:每个桶前面8字节的tophash数组,然后是8个key,8个value,最后存放一个溢出指针
// 桶大小(内存) = 8 + 8*keysize + 8*valuesize + 8
if dirtyalloc == nil {
buckets = newarray(t.bucket, int(nbuckets))
} else {
buckets = dirtyalloc
size := t.bucket.size * nbuckets
if t.bucket.ptrdata != 0 {
memclrHasPointers(buckets, size)
} else {
memclrNoHeapPointers(buckets, size)
}
}
if base != nbuckets {
// 我们预先分配了一些溢出桶。为了将跟踪这些溢出桶的开销降到最低,我们使用这样的约定:
// 如果预定分配的溢出桶的溢出指针为零,则通过碰撞指针可以获得更多的溢出桶
// 我们需要一个安全的非nil指针用于最后一个溢出桶;只需要使用桶。
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow
}
2.插入或更新
插入的内容偏多,根据代码逻辑分了几个主题进行描述,如果不关注细节,看每个小节总结,能囊括插入的所有逻辑。
在其中除了自身的处理逻辑外,在检查插入前做了一次迁移处理(满足条件时),在准备插入前做了一次扩容处理(满足条件时),可以看出来go map是属于 增量扩容
在这里有两个存疑的问题:
- 对于key/value的值或指针部分的保存并不是很了解,只是看到代码看到一个模糊的概念,后续需要回来补充这部分的理解
- 为什么需要抽离对 value 值的保存。
2.1 校验和初始化
除了做一些常规的检查以外,在这里你能看到:
- 如果是未初始化的map写入数据会异常退出
- go map不支持并发读写,会panic。如果一定要并发,请使用sync.map或其他方式解决
- 会对未初始化的桶进行初始化
/*
:params t: 存储了key/value和桶大小等信息
:params h: map指针
:params key: key值
*/
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 := abi.FuncPCABIInternal(mapassign)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled {
msanread(key, t.key.size)
}
if asanenabled {
asanread(key, t.key.size)
}
// 这里的并发检查时伪检测,不能保证所有并发都被检测出来
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
hash := t.hasher(key, uintptr(h.hash0))
// 写入writing标志,并在结束后清除标志。
h.flags ^= hashWriting
// 初始化时没有初始 buckets,那么它在第一次赋值就会对 bucket 分配
if h.buckets == nil {
h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
}
...
}
2.2 定位key所在的桶
- hash高8位定位桶位置,hash低8位定位key位置
- 这里检查了是否正在扩容,存在就会进行迁移两个桶(从这里可以看出来map的扩容是增量迁移,即不是一次性迁移)
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
again:
// hash底8位定位桶位置
bucket := hash & bucketMask(h.B)
// 扩容做的事情,单独进行描述
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
// hash高8位定位key位置
top := tophash(hash)
...
}
2.2 定位key所在位置
这里主要是找到key所在的位置,如果看过 go map结构体 就知道需要遍历链桶来确认 key 的位置。
- key的确认有两层,一是hash的高8位比较(也有可能重复),二是key的值比较(由于key>128(存指针),key<=128(存值)原因,需要转换)
- 值得注意的是在查找的过程中,会记录第一个空cell(如果遍历完了都没找着重复key,插入到空cell,有相同的key,就更新)
- 如果找到key=哈希表(key),属于更新动作,直接跳到第五步
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
// 下面的步骤是找到如下3个变量的地址或索引值,然后通过汇编语言进行赋值操作
var inserti *uint8 // tophash插入位置
var insertk unsafe.Pointer // key插入位置
var elem unsafe.Pointer // value插入位置
bucketloop:
for {
// 查找桶内的key值
for i := uintptr(0); i < bucketCnt; i++ {
// 对比 bucket.tophash与top(高八位)是否匹配
if b.tophash[i] != top {
// 先找个空槽记录下tophash/key/value的插入位置
// 但是要不要插入,需要遍历完所有才能确认,因为后面可能有重复的元素
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
}
// 找到哈希表存的key,因为存的可能是指针,需要转成值,再判断传参key和哈希表key比较
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if !t.key.equal(key, k) {
continue
}
// 来到这里已经是能在哈希表找到对应的key,就需要更新key/value
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
}
...
}
2.4 插入key
其实遍历到最后来到这里,可以确定这个key是属于插入操作了。
- 会在插入前做一次扩容检查,符合条件就会进行扩容
- 在第3步没找到插入的空 cell,就要往链条中新增一个溢出桶,用来保存新数据
- 当key或value的类型大小超过一定值时,桶只存储key或value的指针。
- 对go map的元素+1,取得新元素所在位置( key/value/tophash )
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
// 如果满足扩容条件就进行扩容,扩容完之后需要重新前面的步骤(后续阐述)
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // Growing the table invalidates everything, so try again
}
// 说明没找到空cell,整个链表是满的,需要添加一个新的溢出桶来保存元素
if inserti == nil {
// The current bucket and all the overflow buckets connected to it are full, allocate a new one.
// 当前存储桶及其连接的所有溢出存储桶已满,请分配一个新的存储桶。
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
// 当key或value的类型大小超过一定值时,桶只存储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) // 在桶中对应位置插入key
*inserti = top // 插入 tophash(hash高8位)
h.count++ // 插入了新元素,元素数量+1
...
}
2.5 结束插入
在结束插入后,做一些收尾动作
- 检查一下是不是处于插入状态,没的就可能存在其他协程,就要异常退出
- 将插入的标志位移除
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
done:
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
h.flags &^= hashWriting
if t.indirectelem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem
}
2.6 value 保存
在结束了整个的 map 插入逻辑中,似乎都没有看到针对 value 值的保存,这部分抽了出来,放在了reflect.mapssign去处理了
这块就有存疑了,为什么需要单独抽离处理?
func reflect_mapassign(t *maptype, h *hmap, key unsafe.Pointer, elem unsafe.Pointer) {
p := mapassign(t, h, key)
typedmemmove(t.elem, p, elem)
}
3.删除
当key/value过大时,hash表存储的是指针,这时候用软删除,置指针为nil,数据交给gc去删。对于外部来说是无感,拿到都是值拷贝。无论key/value是值类型还是指针类型,删除操作都只影响hash表,外部已经拿到的数据不受影响,尤其是指针类型,外层指针还能继续使用。
整体的删除中,有几点优势
- 针对最后一个 tophash[i] 做了一个标志位优化,不用遍历完所有溢出桶来确定
- 对于删除操作,该位置被删除但未释放,后续还能继续插入,对于这种删除方式,以少量空间来避免链桶和桶内的数据移动。
3.1 校验和初始化
删除操作前同样也需要做一些检查,大多数都是类同,下面摘录一些删除的本身的校验
- 像map未初始化或者没元素的情况,删除的处理相对于插入会友和一点,校验完会直接退出,不报异常
- 同样的,也会检查 hashWriting标志。
/*
:params t: 存储了key/value和桶大小等信息
:params h: map的指针
:params key: key值
*/
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
//
if raceenabled && h != nil {
callerpc := getcallerpc()
pc := abi.FuncPCABIInternal(mapdelete)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled && h != nil {
msanread(key, t.key.size)
}
if asanenabled && h != nil {
asanread(key, t.key.size)
}
// 这里检查map有没有初始化和元素数量,任意一个不满足退出
if h == nil || h.count == 0 {
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return
}
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
...
}
3.2 定位key所在的桶
和插入类似逻辑,有一个点值得提出来的,我们把目光聚焦在迁移处理(如果存在扩容),在map在删除前同样也做了一次迁移动作。
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
hash := t.hasher(key, uintptr(h.hash0))
h.flags ^= hashWriting
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)
...
}
3.3 定位key所在的位置和删除
遍历当前桶位置的链桶,找到key所在的位置,然后进行清除。
有一个优化点是,tophash[i] == emptyRest,就是最后一个位置了,而不是需要遍历完所有的桶内key
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
search:
// 针对链桶的循环
for ; b != nil; b = b.overflow(t) {
// 针对桶内key的查找
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
// 找完最后还是没找到就退出循环
// 这里有一个默认认知就是emptyRest标志,代表着最后一个未插入的空cell
if b.tophash[i] == emptyRest {
break search
}
continue
}
// hash高8位相同,也可能不同key,需要进一步对key进行判断
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
}
// 只有在键中有指针时才清除键,否则对key赋予nil操作(value同理)
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)
}
...
}
3.4 重置链桶tophash标志位
如果删除的元素是处于最后一个 tophash[i],就要进行回溯了,从后往前将链桶中的 tophash[i]== emptyOne 标志位更改为 tophash[i] == emptyRest,停止条件就是 tophash[i] != emptyOne。
这样做会带来一个好处,减少我们遍历的次数。
至于为什么需要这样做,可以参考 tophash标志位
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
...
// 删除key后,需要将tophash置于emptyOne标志(证明曾经插入过)
b.tophash[i] = emptyOne
// 检查tophash[i]后一个的标志位是否等于 emptyRest,如果不是证明后面还有元素,就不用重置前面的标志位了
if i == bucketCnt-1 {
// tophash[i]是当前桶的最后一个,需要找到下一个桶的第一个
if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
goto notLast
}
} else {
// tophash[i]不是最后一个,直接找下一个
if b.tophash[i+1] != emptyRest {
goto notLast
}
}
//在当前链桶中,一直往前找到所有的tophash[i]==emptyOne,将其标志为 emptyRest,
// 停止条件为 tophash[i] != emptyOne
for {
b.tophash[i] = emptyRest
if i == 0 {
if b == bOrig {
break
}
// 找到上一个桶,继续它的最后一个条目。
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
i = bucketCnt - 1
} else {
i--
}
if b.tophash[i] != emptyOne {
break
}
}
...
}
3.5 结束删除
结束删除需要将map的元素数量减一,同时清除 hashWriting标志。
这里值得关注的一个点,如果没元素了,会重置哈希种子,按照官网的描述是让攻击者的更加难去重复触发哈希冲突。
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
...
notLast:
h.count--
// Reset the hash seed to make it more difficult for attackers to
// repeatedly trigger hash collisions. See issue 25237.
if h.count == 0 {
h.hash0 = fastrand()
}
break search
}
}
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
h.flags &^= hashWriting
}