前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >你不知道的Golang map

你不知道的Golang map

作者头像
sunsky
发布2020-08-20 17:35:57
1.1K0
发布2020-08-20 17:35:57
举报
文章被收录于专栏:sunskysunsky

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

1. 数据结构及内存管理

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

代码语言:javascript
复制
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。

代码语言:javascript
复制
// 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,几个比较重要的计算公式为:

key

hash

hashtop

bucket 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

方法定义为

代码语言:javascript
复制
// 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部分重点研究。

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

代码语言:javascript
复制
// 获取当前桶的溢出桶
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

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

代码语言:javascript
复制
func (h *hmap) growing() bool {
    return h.oldbuckets != nil
}

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

代码语言:javascript
复制
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:

代码语言:javascript
复制
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,搬迁后落入哪一个索引中呢?

代码语言:javascript
复制
假设旧桶数组大小为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

%overflow

bytes/entry

hitprobe

missprobe

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 后做追加

代码语言:javascript
复制
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)
    }
}
代码语言:javascript
复制
sh: go test -bench=. -run=none map_grow_test.go

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

代码语言:javascript
复制
sh: go test -bench=. -benchmem -run=none map_grow_test.go

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

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

代码语言:javascript
复制
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)
    }
}
代码语言:javascript
复制
 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.

代码语言:javascript
复制

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

代码语言:javascript
复制
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()

}
代码语言:javascript
复制
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)

本文参与?腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2019-11-07 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客?前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 数据结构及内存管理
  • 2. 创建 - makemap
  • 3. 访问 - mapaccess
  • 4. 分配 - mapassign
  • 5. 删除 - mapdelete
  • 6. 扩容 - growWork
    • 6.1 搬迁过程
      • 6.2 扩容
      • 7.建议
      • 总结
      相关产品与服务
      云数据库 Redis
      腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
      http://www.vxiaotou.com