Map
Map
是go语言的一种数据结构,它是一种无序的键值对集合。go语言的map
是使用hashmap
实现的。使用数组+链表的形式,用拉链法消除hash冲突。go语言中的map
主要是由两种结构组成,hmap
和bmap
。
以下结构内容和代码选自其他文章。
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
}
|
由上面的结构可知,key
和value
都存在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|
|
可以观察到,key
和value
并不是键值对的形式存储,是独立分开按照顺寻存放,目的是为了减少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
获取到value
,value
是不可寻址的,因为map
会进行动态扩容,进行扩展后,map
的value
会进行内存迁移,其地址会发生变化,无法对这个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
太多,超过负载,另一种是overflow
的bucket
过多。第一种情况当扩容时,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置为空。但是map
的delete
操作,会有可能出现很多空的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
,否则容量不变,所以缩容,对于内存空间的占用来说并没有发生改变。所以针对上述介绍的map
的delete
操作,不注意管理会使分配的内存不断增加。
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
,其中包含两个数据结构read
和dirty
,减少锁对性能的影响。
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!