3.go map源码-创建/删除/添加/修改

113 阅读11分钟

上集回顾:结构体

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是属于 增量扩容

在这里有两个存疑的问题:

  1. 对于key/value的值或指针部分的保存并不是很了解,只是看到代码看到一个模糊的概念,后续需要回来补充这部分的理解
  2. 为什么需要抽离对 value 值的保存。

2.1 校验和初始化

除了做一些常规的检查以外,在这里你能看到:

  1. 如果是未初始化的map写入数据会异常退出
  2. go map不支持并发读写,会panic。如果一定要并发,请使用sync.map或其他方式解决
  3. 会对未初始化的桶进行初始化
/*
: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所在的桶

  1. hash高8位定位桶位置,hash低8位定位key位置
  2. 这里检查了是否正在扩容,存在就会进行迁移两个桶(从这里可以看出来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 的位置。

  1. key的确认有两层,一是hash的高8位比较(也有可能重复),二是key的值比较(由于key>128(存指针),key<=128(存值)原因,需要转换
  2. 值得注意的是在查找的过程中,会记录第一个空cell(如果遍历完了都没找着重复key,插入到空cell,有相同的key,就更新
  3. 如果找到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是属于插入操作了。

  1. 会在插入前做一次扩容检查,符合条件就会进行扩容
  2. 在第3步没找到插入的空 cell,就要往链条中新增一个溢出桶,用来保存新数据
  3. 当key或value的类型大小超过一定值时,桶只存储key或value的指针。
  4. 对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 结束插入

在结束插入后,做一些收尾动作

  1. 检查一下是不是处于插入状态,没的就可能存在其他协程,就要异常退出
  2. 将插入的标志位移除
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表,外部已经拿到的数据不受影响,尤其是指针类型,外层指针还能继续使用。

整体的删除中,有几点优势

  1. 针对最后一个 tophash[i] 做了一个标志位优化,不用遍历完所有溢出桶来确定
  2. 对于删除操作,该位置被删除但未释放,后续还能继续插入,对于这种删除方式,以少量空间来避免链桶和桶内的数据移动。

3.1 校验和初始化

删除操作前同样也需要做一些检查,大多数都是类同,下面摘录一些删除的本身的校验

  1. 像map未初始化或者没元素的情况,删除的处理相对于插入会友和一点,校验完会直接退出,不报异常
  2. 同样的,也会检查 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

}