Featured image of post Go语言——Map的底层介绍及扩缩容机制

Go语言——Map的底层介绍及扩缩容机制

Go语言map数据结构的实现以及扩缩容机制,和slice的机制区别

Map

  Map是go语言的一种数据结构,它是一种无序的键值对集合。go语言的map是使用hashmap实现的。使用数组+链表的形式,用拉链法消除hash冲突。go语言中的map主要是由两种结构组成,hmapbmap。   以下结构内容和代码选自其他文章。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type hmap struct {
    count      int            //元素个数,调用len(map)时直接返回
    flags      uint8          //标志map当前状态,正在删除元素、添加元素.....
    B          uint8          //单元(buckets)的对数 B=5表示能容纳32个元素
    noverflow  uint16         //单元(buckets)溢出数量,如果一个单元能存8个key,此时存储了9个,溢出了,就需要再增加一个单元
    hash0      uint32         //哈希种子
    buckets    unsafe.Pointer //指向单元(buckets)数组,大小为2^B,可以为nil
    oldbuckets unsafe.Pointer //扩容的时候,buckets长度会是oldbuckets的两倍
    nevacute   uintptr        //指示扩容进度,小于此buckets迁移完成
    extra      *mapextra      //与gc相关 可选字段
}

//a bucket for a Go map
type bmap struct {
    tophash [bucketCnt]uint8
}

//实际上编辑期间会动态生成一个新的结构体
type bmap struct {
    topbits  [8]uint8 // 高位哈希值
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr // 指向扩容后的bucket
}

  由上面的结构可知,keyvalue都存在bmap中,且不是以指针形式,最多存放8个,key存放位置由高位哈希值决定,低位哈希值用于选择把kv存放在bmap数组中的哪一个里。具体为在存储key时不能重复,如果key重复,value会进行覆盖,通过key进行哈希运算,然后进行计算得到位置,但是这样会出现哈希冲突的问题,go语言的解决方法是用拉链法解决哈希冲突,也就是用链表,在冲突位置的元素上形成一个链表,通过指针互连接,在查找的时候如果发现key冲突了,会顺着链表一直向下找,直到尾节点,找不到则返回空。

  overflow指针指向的是扩容后的下一个bmap,会把overflow指针存到extra中。

1
2
3
4
5
6
7
8
type mapextra struct {
    // overflow[0] contains overflow buckets for hmap.buckets.
    // overflow[1] contains overflow buckets for hmap.oldbuckets.
    overflow [2]*[]*bmap

    // nextOverflow 包含空闲的 overflow bucket,这是预分配的 bucket
    nextOverflow *bmap
}

  overflow是一个指针,看结构的定义,在map哈希冲突过多时,会发生扩容,为了不全量搬迁数据,使用增量搬迁,所以按照解释说的,[0]时当前溢出桶的集合,[1]是发生扩容后,保留旧的溢出桶集合,overflow存在的意义在于防止溢出桶被gc。   而bmap的内存模型如下,

1
  |...|key0|key1|key2|key3|value0|value1|value2|value3|*overflow|

  可以观察到,keyvalue并不是键值对的形式存储,是独立分开按照顺寻存放,目的是为了减少pad字段,消除padding(内存对齐)带来的空间浪费。比如:map[int64]int8,如果以key-value的形式存储就必须在每个value后面添加padding7个字节,如果以上述的形式只需要在最后一个value后面添加padding就可以了。   下面是整个map的大体构造:   接下来简单说一下map的创建函数

1
2
3
4
5
6
7
8
func makemap(t *maptype, hint int, h *hmap) *hmap {
	mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
	if overflow || mem > maxAlloc {
		hint = 0
	}
	......
	return h
}

  我们观察到,其实每次make(map[key]value)返回的是指针,而每次复制,比如a := b这种操作,也是创建指针副本,复制了hmap的指针,都是共享一个map,使用起来很像引用,这个和slice的底层数组不同,每次map的扩容,所有都是共享一个map。   下面是go语言中map存储赋值的部分源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    //获取hash算法
    alg := t.key.alg
    //计算hash值
    hash := alg.hash(key, uintptr(h.hash0))
    //如果bucket数组一开始为空,则初始化
    if h.buckets == nil {
        h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
    }
again:
    // 定位存储在哪一个bucket中
    bucket := hash & bucketMask(h.B)
    //得到bucket的结构体
    b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) +bucket*uintptr(t.bucketsize)))
    //获取高八位hash值
    top := tophash(hash)
    var inserti *uint8
    var insertk unsafe.Pointer
    var val unsafe.Pointer
bucketloop:
    //死循环
    for {
        //循环bucket中的tophash数组
        for i := uintptr(0); i < bucketCnt; i++ {
            //如果hash不相等
            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))
                    val = add( unsafe.Pointer(b), 
                    dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize) )
                }
              //插入成功,终止最外层循环
                if b.tophash[i] == emptyRest {
                    break bucketloop
                }
                continue
            }
            //到这里说明高八位hash一样,获取已存在的key
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.indirectkey() {
                k = *((*unsafe.Pointer)(k))
            }
            //判断两个key是否相等,不相等就循环下一个
            if !alg.equal(key, k) {
                continue
            }
            // 如果相等则更新
            if t.needkeyupdate() {
                typedmemmove(t.key, k, key)
            }
            //获取已存在的value
            val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
            goto done
        }
        //如果上一个bucket没能插入,则通过overflow获取链表上的下一个bucket
        ovf := b.overflow(t)
        if ovf == nil {
            break
        }
        b = ovf
    }

    if inserti == nil {
        // all current buckets are full, allocate a new one.
        newb := h.newoverflow(t, b)
        inserti = &newb.tophash[0]
        insertk = add(unsafe.Pointer(newb), dataOffset)
        val = add(insertk, bucketCnt*uintptr(t.keysize))
    }

    // store new key/value at insert position
    if t.indirectkey() {
        kmem := newobject(t.key)
        *(*unsafe.Pointer)(insertk) = kmem
        insertk = kmem
    }
    if t.indirectvalue() {
        vmem := newobject(t.elem)
        *(*unsafe.Pointer)(val) = vmem
    }
    typedmemmove(t.key, insertk, key)
    //将高八位hash值存储
    *inserti = top
    h.count++
    return val
}

  有一个地方要注意一下go语言的map,通过key获取到valuevalue是不可寻址的,因为map会进行动态扩容,进行扩展后,mapvalue会进行内存迁移,其地址会发生变化,无法对这个value进行寻址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
	type test struct {
		t string
	}
	testmap := make(map[string]test)
	testmap["test"] = test{t: "ZonzeeLi"}

	// 这一行编译不通过
	testmap["test"].t = "Changed"
	
	// 这个不是复制map,不会对原map有影响
	p := testmap["test"]
	p.t = "Changed again"
}

Map的扩缩容机制

  map最重要且最多问的就是其扩容机制,这里把扩缩容都简单总结一下。   有两种情况要扩容,一种是kv太多,超过负载,另一种是overflowbucket过多。第一种情况当扩容时,go语言会将bucket数组的数量扩充一倍,产生一个新的bucket数组,并且将就数组的数据迁移至新数组中,如果是bucket过多,也就是第二种情况,扩容后的bucket数量不变,做内存整理。我们来解释一下扩充的过程,当map的长度增大到大于负载因子所需的长度,会去申请一个新的大数组作为bucket,即 newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil),回将旧的bucket数组存放在oldbucket中做填充,这里并不是立即把旧的数组中的数据转移到新的newbucket中,而是只有在访问到具体的某个bucket时,才会把数据转移到新的newbucket中,如下图:   注:这里并不会直接删除旧的bucket,而是把原来的引用去掉,利用gc清除内存。   那么在这里就可以联想到,map的删除操作,如果key是一个指针类型,则将其置空,等待gc清除,如果是值,则清楚相关内存,对value也是相关操作,最后会把key对应的高位值对应的数组index置为空。但是mapdelete操作,会有可能出现很多空的kv,会导致搬迁操作。

1
2
3
4
5
6
7
8
func hashGrow(t *maptype, h *hmap) {
	bigger := uint8(1)
	if !overLoadFactor(h.count+1, h.B) {
		bigger = 0
		h.flags |= sameSizeGrow
	}
	...
}

  无论是扩容还是缩容,对应的代码段都是这一段,如果是扩容,则bigger = 1,否则容量不变,所以缩容,对于内存空间的占用来说并没有发生改变。所以针对上述介绍的mapdelete操作,不注意管理会使分配的内存不断增加。

Map的并发安全问题

  go语言的map并不支持并发读写,也就是非线程安全的,这一点和slice真的是难兄难弟。如果有多个goroutine操作,代码写法不当会报错fatal error: concurrent map writes,无论是在读在写的过程中,甚至是写一个http的请求服务,每次都要读一个map,在高并发的场景下也会报这样的错,而在其他时候并不会,所以这是一个隐式问题。那么该如何处理呢?

1
2
3
4
var b = struct{
    sync.RWMutex
    m map[string]int
}{m: make(map[string]int)}

  最常见的就是加一个锁,定义一个结构体包含一个map结构体和一个嵌入读写锁sync.RWMutex。读写数据分别如下:

1
2
3
4
5
6
7
8
9
// 读数据
b.RLock()
a := b.m["test"]
b.RUnlock()

// 写数据
b.Lock()
b.m["test"] ++
b.Unlock()

  而go语言自身提供了一种type Map,即sync.Map,是一种支持并发读写的map,其中包含两个数据结构readdirty,减少锁对性能的影响。

1
2
3
4
5
6
type Map struct {
	mu Mutex
	read atomic.Value // readOnly
	dirty map[interface{}]*entry
	misses int
}
1
2
3
4
5
6
func (m *Map) Delete(key interface{})
func (m *Map) Load(key interface{}) (value interface{}, ok bool)
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool)
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
func (m *Map) Range(f func(key, value interface{}) bool)
func (m *Map) Store(key, value interface{})

  对于sync.Map结构,更适合读多写少的场景,我之前看到了煎鱼大佬对sync.Map内部结构的分析和为什么适合读多写少的性能测试,感兴趣的可以看一下这篇文章


  OK!有关go语言map的介绍就这么多了,至于扩容内部源码的分析、搬迁操作等可以再细致的对应代码去理解。

  Coding every day!

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy