你不知道的Golang map

在开发过程中,map是必不可少的数据结构,在Golang中,使用map或多或少会遇到与其他语言不一样的体验,比如访问不存在的元素会返回其类型的空值、map的大小究竟是多少,为什么会报"cannot take the address of"错误,遍历map的随机性等等。
本文希望通过研究map的底层实现,以解答这些疑惑。
基于Golang 1.8.3

1. 数据结构及内存管理

hashmap的定义位于 src/runtime/hashmap.go 中,首先我们看下hashmap和bucket的定义:

type hmap struct {
    count     int    // 元素的个数
    flags     uint8  // 状态标志
    B         uint8  // 可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子
    noverflow uint16 // 溢出的个数
    hash0     uint32 // 哈希种子

    buckets    unsafe.Pointer // 桶的地址
    oldbuckets unsafe.Pointer // 旧桶的地址,用于扩容
    nevacuate  uintptr        // 搬迁进度,小于nevacuate的已经搬迁
    overflow *[2]*[]*bmap 
}

其中,overflow是一个指针,指向一个元素个数为2的数组,数组的类型是一个指针,指向一个slice,slice的元素是桶(bmap)的地址,这些桶都是溢出桶;为什么有两个?因为Go map在hash冲突过多时,会发生扩容操作,为了不全量搬迁数据,使用了增量搬迁,[0]表示当前使用的溢出桶集合,[1]是在发生扩容时,保存了旧的溢出桶集合;overflow存在的意义在于防止溢出桶被gc。

// A bucket for a Go map.
type bmap struct {
    // 每个元素hash值的高8位,如果tophash[0] < minTopHash,表示这个桶的搬迁状态
    tophash [bucketCnt]uint8
    // 接下来是8个key、8个value,但是我们不能直接看到;为了优化对齐,go采用了key放在一起,value放在一起的存储方式,
    // 再接下来是hash冲突发生时,下一个溢出桶的地址
}

tophash的存在是为了快速试错,毕竟只有8位,比较起来会快一点。

从定义可以看出,不同于STL中map以红黑树实现的方式,Golang采用了HashTable的实现,解决冲突采用的是链地址法。也就是说,使用数组+链表来实现map。特别的,对于一个key,几个比较重要的计算公式为:

keyhashhashtopbucket index
key hash := alg.hash(key, uintptr(h.hash0)) top := uint8(hash >> (sys.PtrSize*8 - 8)) bucket := hash & (uintptr(1)<<h.B - 1),即 hash % 2^B

例如,对于B = 3,当hash(key) = 4时, hashtop = 0, bucket = 4,当hash(key) = 20时,hashtop = 0, bucket = 4;这个例子我们在搬迁过程还会用到。

内存布局类似于这样:

 

 
hashmap-buckets

2. 创建 - makemap

map的创建比较简单,在参数校验之后,需要找到合适的B来申请桶的内存空间,接着便是穿件hmap这个结构,以及对它的初始化。

 
makemap

3. 访问 - mapaccess

对于给定的一个key,可以通过下面的操作找到它是否存在

 

 
image.png

方法定义为

// returns key, if not find, returns nil
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer 

// returns key and exist. if not find, returns nil, false
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)

// returns both key and value. if not find, returns nil, nil
func mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer)

可见在找不到对应key的情况下,会返回nil

4. 分配 - mapassign

为一个key分配空间的逻辑,大致与查找类似;但增加了写保护和扩容的操作;注意,分配过程和删除过程都没有在oldbuckets中查找,这是因为首先要进行扩容判断和操作;如下:

 

 
assign

扩容是整个hashmap的核心算法,我们放在第6部分重点研究。

新建一个溢出桶,并将其拼接在当前桶的尾部,实现了类似链表的操作:

// 获取当前桶的溢出桶
func (b *bmap) overflow(t *maptype) *bmap {
    return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize))
}

// 设置当前桶的溢出桶
func (h *hmap) setoverflow(t *maptype, b, ovf *bmap) {
    h.incrnoverflow()
    if t.bucket.kind&kindNoPointers != 0 {
        h.createOverflow()
        //重点,这里讲溢出桶append到overflow[0]的后面
        *h.overflow[0] = append(*h.overflow[0], ovf)
    }
    *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize)) = ovf
}

5. 删除 - mapdelete

删除某个key的操作与分配类似,由于hashmap的存储结构是数组+链表,所以真正删除key仅仅是将对应的slot设置为empty,并没有减少内存;如下:

 

 
mapdelete

6. 扩容 - growWork

首先,判断是否需要扩容的逻辑是

func (h *hmap) growing() bool {
    return h.oldbuckets != nil
}

何时h.oldbuckets不为nil呢?在分配assign逻辑中,当没有位置给key使用,而且满足测试条件(装载因子>6.5或有太多溢出通)时,会触发hashGrow逻辑:

func hashGrow(t *maptype, h *hmap) {
    //判断是否需要sameSizeGrow,否则"真"扩
    bigger := uint8(1)
    if !overLoadFactor(int64(h.count), h.B) {
        bigger = 0
        h.flags |= sameSizeGrow
    }
        // 下面将buckets复制给oldbuckets
    oldbuckets := h.buckets
    newbuckets := newarray(t.bucket, 1<<(h.B+bigger))
    flags := h.flags &^ (iterator | oldIterator)
    if h.flags&iterator != 0 {
        flags |= oldIterator
    }
    // 更新hmap的变量
    h.B += bigger
    h.flags = flags
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
    h.nevacuate = 0
    h.noverflow = 0
        // 设置溢出桶
    if h.overflow != nil {
        if h.overflow[1] != nil {
            throw("overflow is not nil")
        }
// 交换溢出桶
        h.overflow[1] = h.overflow[0]
        h.overflow[0] = nil
    }
}

OK,下面正式进入重点,扩容阶段;在assign和delete操作中,都会触发扩容growWork:

func growWork(t *maptype, h *hmap, bucket uintptr) {
    // 搬迁旧桶,这样assign和delete都直接在新桶集合中进行
    evacuate(t, h, bucket&h.oldbucketmask())
        //再搬迁一次搬迁过程中的桶
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

6.1 搬迁过程

一般来说,新桶数组大小是原来的2倍(在!sameSizeGrow()条件下),新桶数组前半段可以"类比"为旧桶,对于一个key,搬迁后落入哪一个索引中呢?

假设旧桶数组大小为2^B, 新桶数组大小为2*2^B,对于某个hash值X
若 X & (2^B) == 0,说明 X < 2^B,那么它将落入与旧桶集合相同的索引xi中;
否则,它将落入xi + 2^B中。

例如,对于旧B = 3时,hash1 = 4,hash2 = 20,其搬迁结果类似这样。

 
example.png

源码中有些变量的命名比较简单,容易扰乱思路,我们注明一下便于理解。

变量释义
x *bmap 桶x表示与在旧桶时相同的位置,即位于新桶前半段
y *bmap 桶y表示与在旧桶时相同的位置+旧桶数组大小,即位于新桶后半段
xi int 桶x的slot索引
yi int 桶y的slot索引
xk unsafe.Pointer 索引xi对应的key地址
yk unsafe.Pointer 索引yi对应的key地址
xv unsafe.Pointer 索引xi对应的value地址
yv unsafe.Pointer 索引yi对应的value地址

搬迁过程如下:

 

 
evacuate

6.2 扩容

和 slice 一样,在 map 的元素持续增长时,每个bucket极端情况下会有很多overflow,退化成链表,需要 rehash。一般扩容是在 h.count > loadFactor(2^B)。 负载因子一般是:容量 / bucket数量,golang 的负载因子 loadFactorNum / loadFactorDen = 6.5,为什么不选择1呢,像 Redis 的 dictentry,只能保存一组键值对,golang的话,一个bucket正常情况下可以保存8组键值对; 那为什么选择6.5这个值呢,作者给出了一组数据。

loadFactor%overflowbytes/entryhitprobemissprobe
4.00 2.13 20.77 3.00 4.00
4.50 4.05 17.30 3.25 4.50
5.00 6.85 14.77 3.50 5.00
5.50 10.55 12.94 3.75 5.50
6.00 15.27 11.67 4.00 6.00
6.50 20.90 10.79 4.25 6.50
7.00 27.14 10.15 4.50 7.00
7.50 34.03 9.73 4.75 7.50
8.00 41.10 9.40 5.00 8.00

loadFactor:负载因子;
%overflow:溢出率,有溢出 bucket 的占比;
bytes/entry:每个 key/value 对占用字节比;
hitprobe:找到一个存在的key平均查找个数;
missprobe:找到一个不存在的key平均查找个数;

通常在负载因子 > 6.5时,就是平均每个bucket存储的键值对 超过6.5个或者是overflow的数量 > 2 ^ 15时会发生扩容(迁移)。它分为两种情况:
第一种:由于map在不断的insert 和 delete 中,bucket中的键值存储不够均匀,内存利用率很低,需要进行迁移。(注:bucket数量不做增加)
第二种:真正的,因为负载因子过大引起的扩容,bucket 增加为原 bucket 的两倍
不论上述哪一种 rehash,都是调用 hashGrow 方法:

  1. 定义原 hmap 中指向 buckets 数组的指针
  2. 创建 bucket 数组并设置为 hmap 的 bucket 字段
  3. 将 extra 中的 oldoverflow 指向 overflow,overflow 指向 nil
  4. 如果正在 growing 的话,开始渐进式的迁移,在 growWork 方法里是 bucket 中 key/value 的迁移
  5. 在全部迁移完成后,释放内存

7.建议

做两组试验,第一组是:提前分配好 map 的总容量后追加k/v;另一组是:初始化 0 容量的 map 后做追加

package main

import "testing"
var count int = 100_000
func addition(m map[int]int) map[int]int {
    for i := 0; i < count; i++ {
        m[i] = i
    }
    return m
}
func BenchmarkGrows(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m := make(map[int]int)
        addition(m)
    }
}
func BenchmarkNoGrows(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m := make(map[int]int, count)
        addition(m)
    }
}
sh: go test -bench=. -run=none map_grow_test.go

goos: darwin
goarch: amd64
BenchmarkGrows-4 80 15825209 ns/op
BenchmarkNoGrows-4 160 7235485 ns/op
PASS
ok command-line-arguments 3.944s

提前定义容量的case平均执行时间比未定义容量的快了100% --- 扩容时的数据拷贝和重新哈希成本很高!
再看看内存的分配次数:

sh: go test -bench=. -benchmem -run=none map_grow_test.go

goos: darwin
goarch: amd64
BenchmarkGrows-4 98 11200304 ns/op 5766531 B/op 4004 allocs/op
BenchmarkNoGrows-4 172 9005691 ns/op 2829246 B/op 1679 allocs/op
PASS
ok command-line-arguments 3.366s

提前定义容量的case的内存操作次数要少1倍多。

两个方法执行相同的次数,GC的次数也会多出一倍

package main

var count int = 100_000
func addition(m map[int]int) map[int]int {
    for i := 0; i < count; i++ {
        m[i] = i
    }
    return m
}

func main() {
    for i := 0; i < 4; i++ {
        println("round ",i )
        n := make(map[int]int, count)
        addition(n)
        println("0 size map\n")
        m := make(map[int]int)
        addition(m)
    }
}
 go build -o growth map_grow.go && GODEBUG=gctrace=1 ./growth
round  0
0 size map

gc 1 @0.009s 0%: 0.008+0.11+0.014 ms clock, 0.035+0.041/0.012/0.11+0.056 ms cpu, 4->4->0 MB, 5 MB goal, 4 P
scvg: 0 MB released
scvg: inuse: 3, idle: 59, sys: 63, released: 59, consumed: 4 (MB)
scvg: 0 MB released
scvg: inuse: 2, idle: 61, sys: 63, released: 59, consumed: 4 (MB)
scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB)
scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB)
scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB)
scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB)
gc 2 @0.014s 0%: 0.002+0.16+0.026 ms clock, 0.009+0.051/0.009/0.093+0.10 ms cpu, 5->5->3 MB, 6 MB goal, 4 P
round  1
0 size map

gc 3 @0.028s 0%: 0.002+0.10+0.017 ms clock, 0.011+0/0.015/0.10+0.070 ms cpu, 7->7->0 MB, 8 MB goal, 4 P
scvg: 0 MB released
scvg: inuse: 7, idle: 56, sys: 63, released: 55, consumed: 7 (MB)
scvg: 0 MB released
scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 7 (MB)
scvg: 0 MB released
scvg: inuse: 2, idle: 60, sys: 63, released: 55, consumed: 7 (MB)
gc 4 @0.033s 0%: 0.002+0.18+0.011 ms clock, 0.011+0.074/0.011/0.15+0.046 ms cpu, 5->5->3 MB, 6 MB goal, 4 P
round  2
0 size map

gc 5 @0.047s 0%: 0.002+0.21+0.011 ms clock, 0.011+0.032/0.016/0.079+0.045 ms cpu, 7->7->0 MB, 8 MB goal, 4 P
scvg: 0 MB released
scvg: inuse: 7, idle: 56, sys: 63, released: 55, consumed: 8 (MB)
scvg: 0 MB released
scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 8 (MB)
scvg: 0 MB released
scvg: inuse: 5, idle: 58, sys: 63, released: 55, consumed: 8 (MB)
gc 6 @0.052s 0%: 0.004+0.11+0.003 ms clock, 0.016+0.062/0.008/0.10+0.015 ms cpu, 5->5->3 MB, 6 MB goal, 4 P
round  3
gc 7 @0.066s 0%: 0.002+0.12+0.033 ms clock, 0.011+0.047/0.043/0.052+0.13 ms cpu, 6->6->2 MB, 7 MB goal, 4 P
scvg: 0 MB released
scvg: inuse: 6, idle: 56, sys: 63, released: 55, consumed: 8 (MB)
0 size map

scvg: 0 MB released
scvg: inuse: 4, idle: 59, sys: 63, released: 55, consumed: 8 (MB)
gc 8 @0.070s 0%: 0.002+0.075+0.004 ms clock, 0.010+0.060/0.007/0.061+0.019 ms cpu, 5->5->1 MB, 6 MB goal, 4 P
scvg: 0 MB released
scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 8 (MB)
scvg: 0 MB released
scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 7 (MB)
scvg: inuse: 4, idle: 58, sys: 63, released: 55, consumed: 7 (MB)
gc 9 @0.075s 0%: 0.003+0.16+0.004 ms clock, 0.012+0.11/0.009/0.10+0.019 ms cpu, 4->4->3 MB, 5 MB goal, 4 P

0长度的map每次都触发gc,  但定长的不会gc.

 

有个1千万kv的 map,测试在什么情况下会回收内存

package main

import "runtime/debug"

var count = 10_000_000
var dict = make(map[int]int, count)
func addition() {
    for i := 0; i < count; i++ {
        dict[i] = i
    }
}
func clear() {
    for k := range dict {
        delete(dict, k)
    }
}
func main() {
    addition()
    println("delete map item")
    clear()
    debug.FreeOSMemory()
    println("delete map")
    dict = nil
    debug.FreeOSMemory()

}
go build -o growth big_map.go && GODEBUG=gctrace=1 ./growth
gc 1 @0.005s 0%: 0.007+0.12+0.012 ms clock, 0.028+0.039/0.014/0.23+0.048 ms cpu, 306->306->306 MB, 307 MB goal, 4 P
gc 2 @1.469s 0%: 0.004+1.1+0.009 ms clock, 0.018+0/0.99/0.76+0.036 ms cpu, 307->307->306 MB, 612 MB goal, 4 P
delete map item
gc 3 @2.101s 0%: 0.003+0.18+0.037 ms clock, 0.012+0/0.077/0.068+0.14 ms cpu, 309->309->306 MB, 612 MB goal, 4 P (forced)
forced scvg: 4 MB released
forced scvg: inuse: 306, idle: 77, sys: 383, released: 77, consumed: 306 (MB)
delete map
gc 4 @2.102s 0%: 0.001+0.14+0.002 ms clock, 0.007+0/0.12/0.002+0.011 ms cpu, 306->306->0 MB, 612 MB goal, 4 P (forced)
scvg: inuse: 306, idle: 77, sys: 383, released: 77, consumed: 306 (MB)
forced scvg: 306 MB released
forced scvg: inuse: 0, idle: 383, sys: 383, released: 383, consumed: 0 (MB)

删除了所有kv,堆大小(goal)并无变化

设置为nil,才会真正释放map内存。(本身每2分钟强制 runtime.GC(),每5分钟 scavenge 释放内存,其实不必太过纠结是否真正释放,未真正释放也是为了后面有可能的重用, 但有时需要真实释放时,清楚怎么做才能解决问题

总结

通过分析,我们了解了map是由数组+链表实现的HashTable,其大小和B息息相关,同时也了解了map的创建、查询、分配、删除以及扩容搬迁原理。总的来说,Golang通过hashtop快速试错加快了查找过程,利用空间换时间的思想解决了扩容的问题,利用将8个key(8个value)依次放置减少了padding空间等等。

golang在rehash时,没有一次性迁移所有的buckets,而是把key的迁移分摊到每次插入或删除时, 在 bucket 中的 key/value 全部迁移完成释放oldbucket和extra.oldoverflow(尽可能不去使用map存储大量数据;最好在初始化一次性声明cap,避免频繁扩容, 多用make,少用new

 

posted @ 2019-11-07 19:54  sunsky303  阅读(2034)  评论(0编辑  收藏  举报
显示搜索