原文作者:EDDYCJY
从本文开始咱们一起探索 Go map 里面的奥妙吧,看看它的内在是怎么构成的,又分别有什么值得留意的地方?
第一篇将探讨初始化和访问元素相关板块,咱们带着疑问去学习,例如:
...
首先我们一起看看 Go map 的基础数据结构,先有一个大致的印象

hmap
1type hmap struct {
2 count int
3 flags uint8
4 B uint8
5 noverflow uint16
6 hash0 uint32
7 buckets unsafe.Pointer
8 oldbuckets unsafe.Pointer
9 nevacuate uintptr
10 extra *mapextra
11}
12
13type mapextra struct {
14 overflow *[]*bmap
15 oldoverflow *[]*bmap
16 nextOverflow *bmap
17}overflow 为 hmap.buckets (当前)溢出桶的指针地址oldoverflow 为 hmap.oldbuckets (旧)溢出桶的指针地址nextOverflow 为空闲溢出桶的指针地址在这里我们要注意几点,如下:
buckets 和 oldbuckets 也是与扩容相关的载体,一般情况下只使用 buckets,oldbuckets 是为空的。但如果正在扩容的话,oldbuckets 便不为空,buckets 的大小也会改变hint 大于 8 时,就会使用 *mapextra 做溢出桶。若小于 8,则存储在 buckets 桶中
1bucketCntBits = 3
2bucketCnt = 1 << bucketCntBits
3...
4type bmap struct {
5 tophash [bucketCnt]uint8
6}实际 bmap 就是 buckets 中的 bucket,一个 bucket 最多存储 8 个键值对
tophash 是个长度为 8 的数组,代指桶最大可容纳的键值对为 8。
存储每个元素 hash 值的高 8 位,如果 tophash [0] <minTopHash,则 tophash [0] 表示为迁移进度
在这里我们留意到,存储 k 和 v 的载体并不是用 k/v/k/v/k/v/k/v 的模式,而是 k/k/k/k/v/v/v/v 的形式去存储。这是为什么呢?
1map[int64]int8在这个例子中,如果按照 k/v/k/v/k/v/k/v 的形式存放的话,虽然每个键值对的值都只占用 1 个字节。但是却需要 7 个填充字节来补齐内存空间。最终就会造成大量的内存 “浪费”

但是如果以 k/k/k/k/v/v/v/v 的形式存放的话,就能够解决因对齐所 "浪费" 的内存空间
因此这部分的拆分主要是考虑到内存对齐的问题,虽然相对会复杂一点,但依然值得如此设计

overflow
可能会有同学疑惑为什么会有溢出桶这个东西?实际上在不存在哈希冲突的情况下,去掉溢出桶,也就是只需要桶、哈希因子、哈希算法。也能实现一个简单的 hash table。但是哈希冲突(碰撞)是不可避免的...
而在 Go map 中当 hmap.buckets 满了后,就会使用溢出桶接着存储。我们结合分析可确定 Go 采用的是数组 + 链地址法解决哈希冲突

1m := make(map[int32]int32)通过阅读源码可得知,初始化方法有好几种。函数原型如下:
1func makemap_small() *hmap
2func makemap64(t *maptype, hint int64, h *hmap) *hmap
3func makemap(t *maptype, hint int, h *hmap) *hmap
4hint 小于 8 时,会调用 makemap_small 来初始化 hmap。主要差异在于是否会马上初始化 hash tablehint 类型为 int64 时的特殊转换及校验处理,后续实质调用 makemap 1func makemap(t *maptype, hint int, h *hmap) *hmap {
2 if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) {
3 hint = 0
4 }
5
6 if h == nil {
7 h = new(hmap)
8 }
9 h.hash0 = fastrand()
10
11 B := uint8(0)
12 for overLoadFactor(hint, B) {
13 B++
14 }
15 h.B = B
16
17 if h.B != 0 {
18 var nextOverflow *bmap
19 h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
20 if nextOverflow != nil {
21 h.extra = new(mapextra)
22 h.extra.nextOverflow = nextOverflow
23 }
24 }
25
26 return h
27}bucket 类型,获取其类型能够申请的最大容量大小。并对其长度 make(map[k]v, hint) 进行边界值检验hint,计算一个可以放下 hint 个元素的桶 B 的最小值B 为 0 将在后续懒惰分配桶,大于 0 则会马上进行分配在这里可以注意到,(当 hint 大于等于 8 )第一次初始化 map 时,就会通过调用 makeBucketArray 对 buckets 进行分配。因此我们常常会说,在初始化时指定一个适当大小的容量。能够提升性能。
若该容量过少,而新增的键值对又很多。就会导致频繁的分配 buckets,进行扩容迁移等 rehash 动作。最终结果就是性能直接的下降(敲黑板)
而当 hint 小于 8 时,这种问题相对就不会凸显的太明显,如下:
1func makemap_small() *hmap {
2 h := new(hmap)
3 h.hash0 = fastrand()
4 return h
5}
1v := m[i]
2v, ok := m[i]在实现 map 元素访问上有好几种方法,主要是包含针对 32/64 位、string 类型的特殊处理,总的函数原型如下:
1mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
2mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)
3
4mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer)
5
6mapaccess1_fat(t *maptype, h *hmap, key, zero unsafe.Pointer) unsafe.Pointer
7mapaccess2_fat(t *maptype, h *hmap, key, zero unsafe.Pointer) (unsafe.Pointer, bool)
8
9mapaccess1_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
10mapaccess2_fast32(t *maptype, h *hmap, key uint32) (unsafe.Pointer, bool)
11mapassign_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
12mapassign_fast32ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
13
14mapaccess1_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
15...
16
17mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer
18...
19h[key] 的指针地址,如果键不在 map 中,将返回对应类型的零值h[key] 的指针地址,如果键不在 map 中,将返回零值和布尔值用于判断 1func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
2 ...
3 if h == nil || h.count == 0 {
4 return unsafe.Pointer(&zeroVal[0])
5 }
6 if h.flags&hashWriting != 0 {
7 throw("concurrent map read and map write")
8 }
9 alg := t.key.alg
10 hash := alg.hash(key, uintptr(h.hash0))
11 m := bucketMask(h.B)
12 b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
13 if c := h.oldbuckets; c != nil {
14 if !h.sameSizeGrow() {
15 // There used to be half as many buckets; mask down one more power of two.
16 m >>= 1
17 }
18 oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
19 if !evacuated(oldb) {
20 b = oldb
21 }
22 }
23 top := tophash(hash)
24 for ; b != nil; b = b.overflow(t) {
25 for i := uintptr(0); i < bucketCnt; i++ {
26 if b.tophash[i] != top {
27 continue
28 }
29 k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
30 if t.indirectkey {
31 k = *((*unsafe.Pointer)(k))
32 }
33 if alg.equal(key, k) {
34 v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
35 if t.indirectvalue {
36 v = *((*unsafe.Pointer)(v))
37 }
38 return v
39 }
40 }
41 }
42 return unsafe.Pointer(&zeroVal[0])
43} string) unsafe.Pointer
44...在上述步骤三中,提到了根据不同的类型计算出 hash 值,另外会计算出 hash 值的高八位和低八位。低八位会作为 bucket index,作用是用于找到 key 所在的 bucket。而高八位会存储在 bmap tophash 中
其主要作用是在上述步骤七中进行迭代快速定位。这样子可以提高性能,而不是一开始就直接用 key 进行一致性对比

在本章节,我们介绍了 map 类型的以下知识点:
从阅读源码中,得知 Go 本身对于一些不同大小、不同类型的属性,包括哈希方法都有编写特定方法去运行。总的来说,这块的设计隐含较多的思路,有不少点值得细细品尝 :)
注:本文基于 Go 1.11.5
版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。