你不知道的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,几个比较重要的计算公式为:
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;这个例子我们在搬迁过程还会用到。
内存布局类似于这样:
2. 创建 - makemap
map的创建比较简单,在参数校验之后,需要找到合适的B来申请桶的内存空间,接着便是穿件hmap这个结构,以及对它的初始化。
3. 访问 - mapaccess
对于给定的一个key,可以通过下面的操作找到它是否存在
方法定义为
// 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中查找,这是因为首先要进行扩容判断和操作;如下:
扩容是整个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,并没有减少内存;如下:
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,其搬迁结果类似这样。
源码中有些变量的命名比较简单,容易扰乱思路,我们注明一下便于理解。
变量 | 释义 |
---|---|
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地址 |
搬迁过程如下:
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
方法:
- 定义原 hmap 中指向 buckets 数组的指针
- 创建 bucket 数组并设置为 hmap 的 bucket 字段
- 将 extra 中的 oldoverflow 指向 overflow,overflow 指向 nil
- 如果正在 growing 的话,开始渐进式的迁移,在
growWork
方法里是 bucket 中 key/value 的迁移 - 在全部迁移完成后,释放内存
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 := ; i < count; i++ {
m[i] = i
}
return m
}
func BenchmarkGrows(b *testing.B) {
b.ResetTimer()
for i := ; i < b.N; i++ {
m := make(map[int]int)
addition(m)
}
}
func BenchmarkNoGrows(b *testing.B) {
b.ResetTimer()
for i := ; 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 := ; i < count; i++ {
m[i] = i
}
return m
} func main() {
for i := ; i < ; 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= ./growth
round
size map gc @.009s %: 0.008+0.11+0.014 ms clock, 0.035+0.041/0.012/0.11+0.056 ms cpu, ->-> MB, MB goal, P
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
gc @.014s %: 0.002+0.16+0.026 ms clock, 0.009+0.051/0.009/0.093+0.10 ms cpu, ->-> MB, MB goal, P
round
size map gc @.028s %: 0.002+0.10+0.017 ms clock, 0.011+/0.015/0.10+0.070 ms cpu, ->-> MB, MB goal, P
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
gc @.033s %: 0.002+0.18+0.011 ms clock, 0.011+0.074/0.011/0.15+0.046 ms cpu, ->-> MB, MB goal, P
round
size map gc @.047s %: 0.002+0.21+0.011 ms clock, 0.011+0.032/0.016/0.079+0.045 ms cpu, ->-> MB, MB goal, P
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
gc @.052s %: 0.004+0.11+0.003 ms clock, 0.016+0.062/0.008/0.10+0.015 ms cpu, ->-> MB, MB goal, P
round
gc @.066s %: 0.002+0.12+0.033 ms clock, 0.011+0.047/0.043/0.052+0.13 ms cpu, ->-> MB, MB goal, P
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
size map scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
gc @.070s %: 0.002+0.075+0.004 ms clock, 0.010+0.060/0.007/0.061+0.019 ms cpu, ->-> MB, MB goal, P
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: MB released
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
gc @.075s %: 0.003+0.16+0.004 ms clock, 0.012+0.11/0.009/0.10+0.019 ms cpu, ->-> MB, MB goal, 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 := ; 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= ./growth
gc @.005s %: 0.007+0.12+0.012 ms clock, 0.028+0.039/0.014/0.23+0.048 ms cpu, ->-> MB, MB goal, P
gc @.469s %: 0.004+1.1+0.009 ms clock, 0.018+/0.99/0.76+0.036 ms cpu, ->-> MB, MB goal, P
delete map item
gc @.101s %: 0.003+0.18+0.037 ms clock, 0.012+/0.077/0.068+0.14 ms cpu, ->-> MB, MB goal, P (forced)
forced scvg: MB released
forced scvg: inuse: , idle: , sys: , released: , consumed: (MB)
delete map
gc @.102s %: 0.001+0.14+0.002 ms clock, 0.007+/0.12/0.002+0.011 ms cpu, ->-> MB, MB goal, P (forced)
scvg: inuse: , idle: , sys: , released: , consumed: (MB)
forced scvg: MB released
forced scvg: inuse: , idle: , sys: , released: , consumed: (MB)
删除了所有kv,堆大小(goal)并无变化
设置为nil,才会真正释放map内存。(本身每2分钟强制 runtime.GC(),每5分钟 scavenge 释放内存,其实不必太过纠结是否真正释放,未真正释放也是为了后面有可能的重用, 但有时需要真实释放时,清楚怎么做才能解决问题)
总结
通过分析,我们了解了map是由数组+链表实现的HashTable,其大小和B息息相关,同时也了解了map的创建、查询、分配、删除以及扩容搬迁原理。总的来说,Golang通过hashtop快速试错加快了查找过程,利用空间换时间的思想解决了扩容的问题,利用将8个key(8个value)依次放置减少了padding空间等等。
你不知道的Golang map的更多相关文章
- 【GoLang】GoLang map 非线程安全 & 并发度写优化
Catena (时序存储引擎)中有一个函数的实现备受争议,它从 map 中根据指定的 name 获取一个 metricSource.每一次插入操作都会至少调用一次这个函数,现实场景中该函数调用更是频繁 ...
- golang map输出排序
由于GoLang Map 内部存储是无序的,当需要按顺序获得map存储的key -value值时,应该对遍历出来的结果进行重新排序: 在go 1.8版本后,提供的slice sort 功能使排序更简单 ...
- Golang Map实现(一)
本文学习 Golang 的 Map 数据结构,以及map buckets 的数据组织结构. hash 表是什么 从大学的课本里面,我们学到:hash 表其实就是将key 通过hash算法映射到数组的某 ...
- Golang Map实现(四) map 的赋值和扩容
title: Golang Map 实现 (四) date: 2020-04-28 18:20:30 tags: golang map 操作,是map 实现中较复杂的逻辑.因为当赋值时,为了减少has ...
- golang map getkeys
golang 获取map的keys package main import "fmt" import "reflect" func main() { abc : ...
- golang map
Our friend Monk has been made teacher for the day today by his school professors . He is going to te ...
- golang map 读写锁与深度拷贝的坑
0X01 golang中,map(字典)无法并发读写 简单来说,新建万条线程对同一个map又读又写,会报错. 为此,最好加锁,其实性能影响并不明显. type taskCache struct{ sy ...
- Golang map 如何进行删除操作?
Cyeam 关注 2017.11.02 10:02* 字数 372 阅读 2784评论 0喜欢 3 map 的删除操作 Golang 内置了哈希表,总体上是使用哈希链表实现的,如果出现哈希冲突,就把冲 ...
- Golang map并发 读写锁
golang并发 一:只有写操作 var ( count int l = sync.Mutex{} m = make(map[int]int) ) //全局变量并发写 导致计数错误 func vari ...
随机推荐
- SPA项目开发登陆注册
使用vue-cli脚手架工具创建一个vue项目 vue init webpack pro01 npm安装elementUI cd pro01 #进入新建项目的根目录 安装: npm install a ...
- 完整版的CAD技巧!3天轻松玩转CAD,零基础也能学会
最近有很多小伙伴反应,CAD图纸学起来有点小困难,也许你还没能掌握技巧,CAD大神带你3天轻松玩转CAD,零基础也能快速学会. 一.看懂图纸是关键 CAD制图首先得让自己知道要绘制什么,如果心中对图纸 ...
- SpringBoot(四) SpringBoot整合JdbcTemplate
一.数据准备CREATE TABLE `tb_user` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID', `username` varchar ...
- 关于在 ASP.NET 的 Global.asax 中 Application_Error 方法内,设置跳转到自定义错误页无效的问题
转自:https://www.cnblogs.com/OpenCoder/p/5070645.html 在 Global.asax 中的 Application_Error 方法中,使用 Respon ...
- YYLable 的使用 以及注意点
NSString *title = @"不得不说 YYKit第三方框架确实很牛,YYLabel在富文本显示和操作方面相当强大,尤其是其异步渲染,让界面要多流畅有多流畅,这里我们介绍下简单的使 ...
- Mac遇到挖矿程序的应急方法
Mac遇到挖矿程序应急的方法 工作笔记: 1.起因:监控发现jsonrpc挖矿报警,询问当事人描述当时情况是安装了sketch软件. 网上可以定位到该IOC 运行后该IOC流量依然可以观测 ...
- nginx location 配置详解
指令作用 匹配指定的请求uri(请求uri不包含查询字符串,如http://localhost:8080/test?id=10,请求uri是/test) 语法形式 location [ = | ~ | ...
- AtCoder - 2037 (dp)
题意 https://vjudge.net/problem/AtCoder-2037 选一些数使得和的平均值等于a,问方案数. 思路 设dp[i][j]为选i个数和为j的方案数,如果当前选了x,那么d ...
- 【洛谷P4148】简单题(kd-tree)
传送门 题意: 给出一个\(n*n\)的棋盘,现在有两种操作:一种是某个格子里的数字加上\(A\),另一种是询问矩阵和. 空间限制:\(20MB\),强制在线. 思路: 直接\(kd-tree\)来搞 ...
- 6.gitlab 备份
在配置文件有有默认的 备份路劲 sudo vim /etc/gitlab/gitlab.rb 默认在这个 路径下面 /var/opt/gitlab/backups 然后默认的备份时间是 7天 , ...