Dig101: dig more, simplified more and know more
在golang中,map
是一个不可或缺的存在。html
它做为哈希表,简单易用,既能自动处理哈希碰撞,又能自动扩容或从新内存整理,避免读写性能的降低。android
这些都要归功于其内部实现的精妙。本文尝试去经过源码去分析一下其背后的故事。git
咱们不会过多在源码分析上展开,只结合代码示例对其背后设计实现上作些总结,但愿能够简单明了一些。github
但愿看完后,会让你对 map 的理解有一些帮助。网上也有不少不错的源码分析,会附到文末,感兴趣的同窗自行查看下。golang
(本文分析基于 Mac 平台上go1.14beta1版本。长文预警 ... )segmentfault
<!--more-->api
咱们先简单过下map实现hash表所用的数据结构,这样方便后边讨论。数组
<!-- 文章目录 -->
<!-- [[TOC]] -->安全
在这里咱们先弄清楚map实现的总体结构session
map本质是hash表(hmap
),指向一堆桶(buckets
)用来承接数据,每一个桶(bmap
)能存8组k/v。
当有数据读写时,会用key
的hash找到对应的桶。
为加速hash定位桶,bmap
里记录了tophash
数组(hash的高8位)
hash表就会有哈希冲突的问题(不一样key的hash值同样,即hash后都指向同一个桶),为此map使用桶后链一个溢出桶(overflow
)链表来解决当桶8个单元都满了,但还有数据须要存入此桶的问题。
剩下noverflow,oldbuckets,nevacuate,oldoverflow
会用于扩容,暂时先不展开
具体对应的数据结构详细注释以下:
(虽然多,先大体过一遍,后边遇到会在提到)
// runtime/map.go // A header for a Go map. type hmap struct { //用于len(map) count int //标志位 // iterator = 1 // 可能有遍历用buckets // oldIterator = 2 // 可能有遍历用oldbuckets,用于扩容期间 // hashWriting = 4 // 标记写,用于并发读写检测 // sameSizeGrow = 8 // 用于等大小buckets扩容,减小overflow桶 flags uint8 // 表明能够最多容纳loadFactor * 2^B个元素(loadFactor=6.5) B uint8 // overflow桶的计数,当其接近1<<15 - 1时为近似值 noverflow uint16 // 随机的hash种子,每一个map不同,减小哈希碰撞的概率 hash0 uint32 // 当前桶,长度为(0-2^B) buckets unsafe.Pointer // 若是存在扩容会有扩容前的桶 oldbuckets unsafe.Pointer // 迁移数,标识小于其的buckets已迁移完毕 nevacuate uintptr // 额外记录overflow桶信息,不必定每一个map都有 extra *mapextra } // 额外记录overflow桶信息 type mapextra struct { overflow *[]*bmap oldoverflow *[]*bmap // 指向下一个可用overflow桶 nextOverflow *bmap } const( // 每一个桶8个k/v单元 BUCKETSIZE = 8 // k或v类型大小大于128转为指针存储 MAXKEYSIZE = 128 MAXELEMSIZE = 128 ) // 桶结构 (字段会根据key和elem类型动态生成,见下边bmap) type bmap struct { // 记录桶内8个单元的高8位hash值,或标记空桶状态,用于快速定位key // emptyRest = 0 // 此单元为空,且更高索引的单元也为空 // emptyOne = 1 // 此单元为空 // evacuatedX = 2 // 用于表示扩容迁移到新桶前半段区间 // evacuatedY = 3 // 用于表示扩容迁移到新桶后半段区间 // evacuatedEmpty = 4 // 用于表示此单元已迁移 // minTopHash = 5 // 最小的空桶标记值,小于其则是空桶标志 tophash [bucketCnt]uint8 } // cmd/compile/internal/gc/reflect.go // func bmap(t *types.Type) *types.Type { // 每一个桶内k/v单元数是8 type bmap struct{ topbits [8]uint8 //tophash keys [8]keytype elems [8]elemtype // overflow 桶 // otyp 类型为指针*Type, // 若keytype及elemtype不含指针,则为uintptr // 使bmap总体不含指针,避免gc去scan此类map overflow otyp }
这里有几个字段须要解释一下:
这个为啥用2的对数来表示桶的数目呢?
这里是为了hash定位桶及扩容方便
比方说,hash%n
能够定位桶, 但%
操做没有位运算快。
而利用 n=2^B,则hash%n=hash&(n-1)
则可优化定位方式为: hash&(1<<B-1)
, (1<<B-1)
即源码中BucketMask
再比方扩容,hmap.B=hmap.B+1
即为扩容到二倍
在桶里存储k/v的方式不是一个k/v一组, 而是k放一块,v放一块。
这样的相对k/v相邻的好处是,方便内存对齐。好比map[int64]int8
, v是int8
,放一块就避免须要额外内存对齐。
另外对于大的k/v也作了优化。
正常状况key和elem直接使用用户声明的类型,但当其size大于128(MAXKEYSIZE/MAXELEMSIZE
)时,
则会转为指针去存储。(也就是indirectkey、indirectelem
)
这个额外记录溢出桶意义在哪?
具体是为解决让gc
不须要扫描此类bucket
。
只要bmap内不含指针就不需gc扫描。
当map
的key
和elem
类型都不包含指针时,但其中的overflow
是指针。
此时bmap的生成函数会将overflow
的类型转化为uintptr
。
而uintptr
虽然是地址,但不会被gc
认为是指针,指向的数据有被回收的风险。
此时为保证其中的overflow
指针指向的数据存活,就用mapextra
结构指向了这些buckets
,这样bmap有被引用就不会被回收了。
关于uintptr可能被回收的例子,能够看下 go101 - Type-Unsafe Pointers 中 Some Facts in Go We Should Know
了解map的基本结构后,咱们经过下边代码分析下map的hash
var m = map[interface{}]int{} var i interface{} = []int{} //panic: runtime error: hash of unhashable type []int println(m[i]) //panic: runtime error: hash of unhashable type []int delete(m, i)
为何不能够用[]int
做为key呢?
查找源码中hash的调用链注释以下:
// runtime/map.go // mapassign,mapaccess1中 获取key的hash hash := t.hasher(key, uintptr(h.hash0)) // cmd/compile/internal/gc/reflect.go func dtypesym(t *types.Type) *obj.LSym { switch t.Etype { // ../../../../runtime/type.go:/mapType case TMAP: ... // 依据key构建hash函数 hasher := genhash(t.Key()) ... } } // cmd/compile/internal/gc/alg.go func genhash(t *types.Type) *obj.LSym { switch algtype(t) { ... //具体针对interface调用interhash case AINTER: return sysClosure("interhash") ... } } // runtime/alg.go func interhash(p unsafe.Pointer, h uintptr) uintptr { //获取interface p的实际类型t,此处为slice a := (*iface)(p) tab := a.tab t := tab._type // slice类型不可比较,没有equal函数 if t.equal == nil { panic(errorString("hash of unhashable type " + t.string())) } ... }
如上,咱们会发现map的hash函数并不惟一。
它会对不一样key类型选取不一样的hash方式,以此加快hash效率
这个例子slice
不可比较,因此不能做为key。
也对,不可比较的类型做为key的话,找到桶但无法比较key是否相等,那map用这个key读写都会是个问题。
还有哪些不可比较?
cmd/compile/internal/gc/alg.go
的 algtype1
函数中能够找到返回ANOEQ
(不可比较类型)的类型,以下:
map
不能够对其值取地址;
若是值类型为slice
或struct
,不能直接操做其内部元素
咱们用代码验证以下:
m0 := map[int]int{} // ❎ cannot take the address of m0[0] _ = &m0[0] m := make(map[int][2]int) // ✅ m[0] = [2]int{1, 0} // ❎ cannot assign to m[0][0] m[0][0] = 1 // ❎ cannot take the address of m[0] _ = &m[0] type T struct{ v int } ms := make(map[int]T) // ✅ ms[0] = T{v: 1} // ❎ cannot assign to struct field ms[0].v in map ms[0].v = 1 // ❎ cannot take the address of ms[0] _ = &ms[0] }
为何呢?
这是由于map
内部有渐进式扩容,因此map
的值地址不固定,取地址没有意义。
也所以,对于值类型为slice
和struct
, 只有把他们各自当作总体去赋值操做才是安全的。 go有个issue讨论过这个问题:issues-3117
针对扩容的方式,有两类,分别是:
过多的overflow
使用,使用等大小的buckets从新整理,回收多余的overflow
桶,提升map读写效率,减小溢出桶占用
这里借助hmap.noverflow
来判断溢出桶是否过多
hmap.B<=15
时,判断是溢出桶是否多于桶数1<<hmap.B
不然只判断溢出桶是否多于 1<<15
这也就是为啥hmap.noverflow
,当其接近1<<15 - 1
时为近似值, 只要能够评估是否溢出桶过多不合理就好了
count/size > 6.5
(装载因子 :overLoadFactor
), 避免读写效率下降。
扩容一倍,并渐进的在赋值和删除(mapassign和mapdelete
)期间,
对每一个桶从新分流到x
(原来桶区间)和y
(扩容后的增长的新桶区间)
这里overLoadFactor
(count/size)是评估桶的平均装载数据能力,即map平均每一个桶装载多少个k/v。
这个值太大,则桶不够用,会有太多溢出桶;过小,则分配了太多桶,浪费了空间。
6.5是测试后对map装载能力最大化的一个的选择。
源码中扩容代码注释以下:
// mapassign 中建立新bucket时检测是否须要扩容 if !h.growing() && //非扩容中 (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { // 提交扩容,生成新桶,记录旧桶相关。但不开始 // 具体开始是后续赋值和删除期间渐进进行 hashGrow(t, h) } //mapassign 或 mapdelete中 渐进扩容 bucket := hash & bucketMask(h.B) if h.growing() { growWork(t, h, bucket) } // 具体迁移工做执行,每次最多两个桶 func growWork(t *maptype, h *hmap, bucket uintptr) { // 迁移对应旧桶 // 若无迭代器遍历旧桶,可释放对应的overflow桶或k/v // 所有迁移完则释放整个旧桶 evacuate(t, h, bucket&h.oldbucketmask()) // 若是还有旧桶待迁移,再迁移一个 if h.growing() { evacuate(t, h, h.nevacuate) } }
具体扩容evacuate
(迁移)时,判断是否要将旧桶迁移到新桶后半区间(y
)有段代码比较有趣, 注释以下:
newbit := h.noldbuckets() var useY uint8 if !h.sameSizeGrow() { // 获取hash hash := t.hasher(k2, uintptr(h.hash0)) if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) { // 这里 key != key 是指key为NaNs, // 此时 useY = top & 1 意味着有50%的概率到新桶区间 useY = top & 1 top = tophash(hash) } else { if hash&newbit != 0 { // 举例来看 若扩容前h.B=3时, newbit=1<<3 // hash&newbit != 0 则hash形如 xxx1xxx // 新hmap的BucketMask= 1<<4 - 1 (1111: 15) // 则 hash&新BucketMask > 原BucketMask 1<<3-1 (111: 7) // 因此去新桶区间 useY = 1 } } } // 补充一个 key != key 的代码示例 n1, n2 := math.NaN(), math.NaN() m := map[float64]int{} m[n1], m[n2] = 1, 2 println(n1 == n2, m[n1], m[n2]) // output: false 0 0 // 因此NaN作key没有意义。。。
弄清楚map的结构、hash和扩容,剩下的就是初始化、读写、删除和遍历了,咱们就不详细展开了,简单过下。
map不初始化时为nil,是不能够操做的。能够经过make方式初始化
// 不指定大小 s := make(map[int]int) // 指定大小 b := make(map[int]int,10)
对于这两种map内部调用方式是不同的
当不指定大小或者指定大小不大于8时,调用
func makemap_small() *hmap {
只须要直接在堆上初始化hmap
和hash种子(hash0
)就行。
当大小大于8, 调用
func makemap(t *maptype, hint int, h *hmap) *hmap {
hint
溢出则置0
初始化hmap
和hash种子
根据overLoadFactor:6.5
的要求, 循环增长h.B
, 获取 hint/(1<<h.B)
最接近 6.5的h.B
预分配hashtable的bucket数组
h.B
大于4的话,多分配至少1<<(h.B-4)
(须要内存对齐)个bucket,用于可能的overflow
桶使用,
并将 h.nextOverflow
设置为第一个可用的overflow
桶。
最后一个overflow
桶指向h.buckets
(方便后续判断已无overflow
桶)
对于map的读取有着三个函数,主要区别是返回参数不一样
mapaccess1: m[k] mapaccess2: a,b = m[i] mapaccessk: 在map遍历时若grow已发生,key可能有更新,需用此函数从新获取k/v
计算key的hash,定位当前buckets里桶位置
若是当前处于扩容中,也尝试去旧桶取对应的桶,需考虑扩容前bucket大小是否为如今一半,且其所指向的桶未迁移
而后就是按照bucket->overflow链表的顺序去遍历,直至找到tophash
匹配且key相等的记录(entry)
期间,若是key或者elem是转过指针(size大于128),需转回对应值。
map为空或无值返回elem类型的零值
计算key的hash,拿到对应的桶
若是此时处于扩容期间,则执行扩容growWork
对桶bucket->overflow链表遍历
最后若使用了空桶或新overflow
桶,则要将对应tophash
更新回去, 若是须要的话,也更新count
获取待删除key对应的桶,方式和mapassign的查找方式基本同样,找到则清除k/v。
这里还有个额外操做:
若是当前tophash状态是:当前cell为空(emptyOne
),
若其后桶或其后的overflow桶状态为:当前cell为空前索引高于此cell的也为空(emptyRest
),则将当前状态也更新为emptyRest
倒着依次往前如此处理,实现 emptyOne -> emptyRest
的转化
这样有什么好处呢?
答案是为了方便读写删除(mapaccess,mapassign,mapdelete
)时作桶遍历(bucketLoop
)能减小没必要要的空bucket遍历
截取代码以下:
bucketloop: for ; b != nil; b = b.overflow(t) { for i := uintptr(0); i < bucketCnt; i++ { if b.tophash[i] != top { // 减小空cell的遍历 if b.tophash[i] == emptyRest { break bucketloop } continue } ... }
先调用mapiterinit
初始化用于遍历的 hiter
结构体, 这里会用随机定位出一个起始遍历的桶hiter.startBucket
, 这也就是为啥map遍历无序。
随机获取起始桶的代码以下:
r := uintptr(fastrand()) // 随机数不够用得再加一个32位 if h.B > 31-bucketCntBits { r += uintptr(fastrand()) << 31 } it.startBucket = r & bucketMask(h.B)
在调用mapiternext
去实现遍历, 遍历中若是处于扩容期间,若是当前桶已经迁移了,那么就指向新桶,没有迁移就指向旧桶
至此,map的内部实现咱们就过完了。
里边有不少优化点,设计比较巧妙,简单总结一下:
趁热打铁,建议你再阅读一遍源码,加深一下理解。
附上几篇不错的源码分析文章,代码对应的go
版本和本文不一致,但变化不大,能够对照着看。
<!-- 若有问题欢迎关注留言交流。
本文代码见 NewbMiao/Dig101-Go
文章首发公众号: newbmiao (欢迎关注,获取及时更新内容)推荐阅读:Dig101-Go系列