基础知识
map的概念
map 的直观意思是映射,是<key, value> 对组成的抽象结构,且 key 不会重复,所有的操作也都是key/value的。map的底层实现一般有两种:
- 搜索树(search tree),天然有序,平均/最差的写入/查找复杂度是O(logN)
- 哈希表(hash table),无序存储,平均的写入/查找复杂度是O(1),最差是O(N)
在go语言中,map的底层实现是hash表。本文也将主要介绍go中是如何改进/优化hashtable的。
对于最朴素的hashtable,就是由数组+哈希函数+解决哈希冲突构成的。哈希函数,简单的讲就是将key(各种类型)转换为数组下标,从而通过array[hash[key]]实现O(1)的查找效率。哈希函数的实现有很多,其目的都是让哈希结果能够尽可能的均匀分布在0~length(array)间。一个完美的哈希函数就是将key和index一一对应;一个极坏的哈希函数就是将所有的key都映射到一个index上。
对于不同key映射到同一个index,就是所谓的hash冲突。常见的hash冲突的解决方式也有两种,分别是:
- 开放定址法。也称再散列,意思就是如果出现了hash冲突,那么再次进行hash,直到无冲突为止,放入对应存储。
开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 𝑂(𝑛)的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。
- 链地址法。意思就是将hash地址相同的key用链表形式串到一起。在查找时也要去遍历这个链来定位真正的key。而这也是go语言中map的冲突解决办法。
go map的基本操作
在go语言中对map有以下几种操作
1、声明
var m map[T]T
注意,此时的map为nil,对nil的map可以安全的进行读取/删除,但对nil的map写入时会导致panic。
package main
func main() {
var a map[int64]bool
delete(a, 10)
_ = a
a[10] = false
}
go run main.go
panic: assignment to entry in nil map//具体原因详见源码解析部分
2、初始化
var m = make(map[T]T,len)
var m = map[T]T{..}
3、新增/更新,直接赋值即可
m[key]=value
4、查询
golang map 拥有两种 get操作,分别是带comma和不带comma,如下
v := m[key]
v, ok := m[key]
对于第一种方式,未set的ket-value将得到零值;对于第二种方式,未set的ket-value,其ok=false。
5、delete
delete(m,key)
可以对相同的key进行多次的删除操作,而不会报错
与其他 map/hashtable 的对比
1、Java map 底层是hash表+链表(拉链法)
其特色是当链表长度超过阈值(8)会转为红黑树,避免O(n)的遍历
2、C++的map是红黑树实现
其unordered_map是hashtable实现的
3、php5中的数组也是基础的hashtable
在7版本的时候,其优化成内存连续的hashtable
4、redis的hash就是一个普通hashtable,其特色在于遍历时采用了高位迭代的顺序,以避免scan时出现重复元素。但是,缩容的话依旧有可能出现重复元素。
深入源码
基于golang 1.15.3,map相关的源码都在runtime/map{xxx}.go中
内存模型
runtime.hmap是map的头信息,数据结构如下
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin) map中kv的个数
flags uint8 //标记位
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items) bucket的个数为2^B个
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details 现在正在使用的溢出桶的个数
hash0 uint32 // hash seed hash种子,随机数,在make map时进行设置,会参与到key 的hash值计算中来,这样做可以使hash结果更离散一些
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.指向当前正在使用的bucket数组
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing golang map的扩容方式是渐进式扩容,oldbuckets指向的是老的bucket数组
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated) 同上,在渐进式扩容中会用到,标识扩容进度
extra *mapextra // optional fields 可选字段
}
// mapextra holds fields that are not present on all maps.
type mapextra struct {
// If both key and elem do not contain pointers and are inline, then we mark bucket
// type as containing no pointers. This avoids scanning such maps.
// However, bmap.overflow is a pointer. In order to keep overflow buckets
// alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
// overflow and oldoverflow are only used if key and elem do not contain pointers.
// 翻译一下,当k/v都不是指针的情况下,在gc时可以避免扫描整个map,但是bmap的最后一个字段是uintptr类型,为了保证gc效率同时对溢出桶进行保活,所以将overflow写入到下边这俩个数组里。
// overflow contains overflow buckets for hmap.buckets.
// oldoverflow contains overflow buckets for hmap.oldbuckets.
// The indirection allows to store a pointer to the slice in hiter.
overflow *[]*bmap //当前桶所用到的溢出桶,map在初始化时会分配一定的溢出桶,在地址上与常规桶连续
oldoverflow *[]*bmap //老桶所用到的溢出桶
// nextOverflow holds a pointer to a free overflow bucket.
nextOverflow *bmap //下一个可用的溢出桶
}
- hmap.count 当前map中kv的个数,在插入时会自增,在删除时会自减。
- hmap.B = log2(bucket数量)。hmap.B的类型为uint8,最大值为255,但golang中bucket的最大限制为pow(2,64)-1,具体源码如下,在使用时会对b做64位防溢出处理
// bucketShift returns 1<<b, optimized for code generation.
func bucketShift(b uint8) uintptr {
// Masking the shift amount allows overflow checks to be elided.
return uintptr(1) << (b & (sys.PtrSize*8 - 1))
}
//在写入/读取等操作时都会用bucketMask获取bucket的数量
// bucketMask returns 1<<b - 1, optimized for code generation.
func bucketMask(b uint8) uintptr {
return bucketShift(b) - 1
}
在map中对key的定位会先使用hash函数获取hash值,写入位置 index = hash%N,得到真正的写入位置。但是在golang中对index的定位有一点小优化,golang使用位与操作进行bucket的定位:
index = hash & (N-1) 为了保证key的足够散列,所以要保证N是2的整幂次,所以在上述的hmap结构中 hmap.B 指的就是这个整数次幂。
取模操作相对于位与操作,cpu的开销更大,可以简单的验证一下,go代码如下
package main
import "fmt"
func main() {
T(1000, 0b11)
}
func T(a, b int) (int, int) {
c := a & b
d := a % b
fmt.Println(c,d)
return c, d
}
将上述的go代码使用go tool compile -S 编译成plan9汇编代码,如下
见下图,main.go:10 是位与操作,main.go:11 是取模操作,可以看到位与操作需要的汇编指令更少
- hmap.flags是map当前的状态,也是经典的二进制位存储,对应位的枚举值分别有以下几种:
// flags
iterator = 1 // there may be an iterator using buckets 有迭代器正在使用bucket
oldIterator = 2 // there may be an iterator using oldbuckets 有迭代器正在使用old bucket
hashWriting = 4 // a goroutine is writing to the map map正在被写入,并发读写导致的panic就是去check这个标记位
sameSizeGrow = 8 // the current map growth is to a new map of the same size 当前map正在进行等量扩容
- hmap.bucket和 hmap.oldbucket 实际指向的是桶切片[]bmap,存储kv的地方。
其中 bmap的数据结构在runtime中如下
// A bucket for a Go map.
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// Followed by bucketCnt keys and then bucketCnt elems.
// NOTE: packing all the keys together and then all the elems together makes the
// code a bit more complicated than alternating key/elem/key/elem/... but it allows
// us to eliminate padding which would be needed for, e.g., map[int64]int8.
// Followed by an overflow pointer.
}
const (
// Maximum number of key/elem pairs a bucket can hold.
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
)
//其中bucketCnt在源码中是常量8,也就是每个bucket中有8个kv
但是在编译时,编译器会对runtime.bmap再增加一些字段,实际的bmap是这样的
type bmap struct {
topbits [8]uint8
keys [8]keytype
elems [8]valuetype
// pad uintptr
overflow uintptr //每个bmap最多存储8个kv对,为了减少因bmap满了而导致的扩容操作,如果这时有第9个kv,则需要使用溢出桶,溢出桶通过overflow字段进行连接,所以常规桶和溢出桶间是单链表结构,溢出桶数据结构与常规桶一致。
}
为runtime.bmap
增加字段的源代码:cmd/compile/internal/gc/reflect.go,核心片段如下所示
// bmap makes the map bucket type given the type of the map.
func bmap(t *types.Type) *types.Type {
//some init code...
field := make([]*types.Field, 0, 5)
// The first field is: uint8 topbits[BUCKETSIZE].
arr := types.NewArray(types.Types[TUINT8], BUCKETSIZE)
field = append(field, makefield("topbits", arr))
arr = types.NewArray(keytype, BUCKETSIZE)
arr.SetNoalg(true)
keys := makefield("keys", arr)
field = append(field, keys)
arr = types.NewArray(elemtype, BUCKETSIZE)
arr.SetNoalg(true)
elems := makefield("elems", arr)
field = append(field, elems)
// If keys and elems have no pointers, the map implementation
// can keep a list of overflow pointers on the side so that
// buckets can be marked as having no pointers.
// Arrange for the bucket to have no pointers by changing
// the type of the overflow field to uintptr in this case.
// See comment on hmap.overflow in runtime/map.go.
otyp := types.NewPtr(bucket)
if !elemtype.HasPointers() && !keytype.HasPointers() {
otyp = types.Types[TUINTPTR]
}
overflow := makefield("overflow", otyp)
field = append(field, overflow)
// link up fields
bucket.SetNoalg(true)
bucket.SetFields(field[:])
dowidth(bucket)
// some check code...
}
来看图示吧
map的内存布局
bmap内存布局
bmap中比较特殊的是kv的存储,bmap将8个key存在一起,将8个value存在一起,是key/key/....value/value...这种存储,相对于将key/value/key/value这种存储设计,将key和value分开存储能使bmap内存布局更加紧密,只需要在最后增加padding对齐即可。
可以计算下map[int64]int8 这种类型的map在上述两种存储设计中每个bmap分别占用的空间大小。
如果将kv存在一起,总共需要的每对kv后需要填充7字节的padding,总共的padding为7*8 = 56字节
但是如果将kv分开存储,8个key是对齐的,8个value也是对齐的,不需要额外的padding填充。
bmap.tophash 标识的是每个key的hash值前8位,这样在比较时可以先比较tophash,能减少一些比较的开销,同时tophash也会存储对应key的标记位,有以下几种枚举值
const (
emptyRest = 0 // this cell is empty, and there are no more non-empty cells at higher indexes or overflows.//当前cell为空,且后续cell都为空,在遍历时会用到,加快map遍历速度
emptyOne = 1 // this cell is empty //当前cell为空
evacuatedX = 2 // key/elem is valid. Entry has been evacuated to first half of larger table.翻倍扩容时会用到,指示cell迁移到新bucket的前半部分
evacuatedY = 3 // same as above, but evacuated to second half of larger table. 翻倍扩容时会用到,指示cell迁移到新bucket的后半部分
evacuatedEmpty = 4 // cell is empty, bucket is evacuated.//扩容时会用到。表示当前cell为空,且bucket已经迁移完成
minTopHash = 5 // minimum tophash for a normal filled cell.正常的tophash的最小值
)
//👇为了不与正常的top hash冲突,在计算tophash时如果小于minTopHash也会加上对应的值
// tophash calculates the tophash value for hash.
func tophash(hash uintptr) uint8 {
top := uint8(hash >> (sys.PtrSize*8 - 8))
if top < minTopHash {
top += minTopHash
}
return top
}
//判断某个bucket是否迁移完成,也是较简单的,只需要check tophash[0]的值即可
func evacuated(b *bmap) bool {
h := b.tophash[0]
return h > emptyOne && h < minTopHash
}
bmap.keys的相对bmap的位置在源码中是常量dataOffset:
// data offset should be the size of the bmap struct, but needs to be
// aligned correctly. For amd64p32 this means 64-bit alignment
// even though pointers are 32 bit.
dataOffset = unsafe.Offsetof(struct {
b bmap
v int64
}{}.v)
//获取v相对于struct的偏移量,也就是获取bmap的keys相对于bmap的偏移量
func (b *bmap) keys() unsafe.Pointer {
return add(unsafe.Pointer(b), dataOffset)
}
哈希
hash函数的原型如下,它接收两个参数,分别是我们的key和初始化时的hash seed,返回值则是对应的hash值
func(unsafe.Pointer, uintptr) uintptr
对计算出来的hash值,
- 前8位作为tophash存入bmap对应位置中,在查找时可以先进行tophash的对比,如果一致再进行key的对比,如果不一致则直接跳过,这样的好处是可以减少一些比较的开销。
- 后B位作为bucket index存入对应的bucket中。
hash值如何使用
至于hash函数的实现,各个类型都会有自己的hash函数实现,比如string类型的hash函数如下,是一个O(N)的函数,更详细的实现在src/runtime/alg.go中,感兴趣的可以自己去看一下~
// Testing adapters for hash quality tests (see hash_test.go)
func stringHash(s string, seed uintptr) uintptr {
return strhash(noescape(unsafe.Pointer(&s)), seed)
}
初始化
对map的make操作是go提供的语法糖,在编译时会转变为runtime.makemap
// makemap implements Go map creation for make(map[k]v, hint).
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If h.buckets != nil, bucket pointed to can be used as the first bucket.
//hint就是我们make时的第二个参数,代表预分配的空间大小
//maptype 是golang类型系统涉及的数据结构,感觉可以再来一次类型系统的学习😸
func makemap(t *maptype, hint int, h *hmap) *hmap {
//防溢出检测,如果有溢出,则将hint清0
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
// initialize Hmap
if h == nil {
h = new(hmap)
}
h.hash0 = fastrand()
//fashrand获取随机数 内部实现与调度器相关,感觉可以来一次调度器的学习😁
// Find the size parameter B which will hold the requested # of elements.
// For hint < 0 overLoadFactor returns false since hint < bucketCnt.
B := uint8(0)
for overLoadFactor(hint, B) { //根据
B++
}
h.B = B
// allocate initial hash table
// if B == 0, the buckets field is allocated lazily later (in mapassign)
// If hint is large zeroing this memory could take a while.
if h.B != 0 {
var nextOverflow *bmap
// 调用makeBucketArray函数生成常规桶和溢出桶,在map初始化时常规桶和溢出桶在内存上是连续的一块空间,只不过常规桶用了前边,溢出桶则用了后边
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
// 如果初始化时生成了溢出桶,会放置到map的extra字段里去
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
// 根据传入的 B 计算出的需要创建的桶数量,并在内存中分配一片连续的空间用于常规桶和溢出桶
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
base := bucketShift(b)
nbuckets := base
// For small b, overflow buckets are unlikely.Avoid the overhead of the calculation.
if b >= 4 { // bmap的数量大于了2^4,在初始化的时候才会生成溢出桶。溢出桶的大小为2^(b-4),b为常规桶的大小
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}
if dirtyalloc == nil {
buckets = newarray(t.bucket, int(nbuckets))
} else {
...
}
// preallocated some overflow buckets.
if base != nbuckets {
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow
}
读取
读取有两种形式,即返回值有两种形式,但是Go语言没有函数重载的概念,这种动态返回值很显然是go提供的语法糖,在编译时会根据用户使用的返回值的个数,转换为runtime的对应函数,源码位置依然位与runtime/map.go中,对应的函数分别是
-
runtime.mapaccess1,对应的是一个返回值的形式
-
runtime.mapaccess2,对应的是两个返回值的形式
-
runtime.mapaccessk,对应的是同时返回kv的形式,在遍历map时会用到
这3个函数的逻辑基本是一致的,下面我们以runtime.mapccess1为例来分析一下map
的读取流程。
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
//这还有一些gc相关的代码块,我们暂时可以忽略
// mapaccess1 我们拆成两部分来看
//1. 参数校验,数据准备
//2. 数据查找
}
- 参数校验,数据准备
if h == nil || h.count == 0 { //这就是上文中提到的,对nil的map做读取操作是安全的,会返回0值
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return unsafe.Pointer(&zeroVal[0])
}
if h.flags&hashWriting != 0 { //并发读写标记位check,如果已设置写标记位,则打印错误信息,并退出进程
throw("concurrent map read and map write")
}
// 通过hash函数计算当前key的哈希值,不同类型的key会有不同的hash函数
hash := t.hasher(key, uintptr(h.hash0))
m := bucketMask(h.B) // 计算出当前桶的个数,即(1<<B)-1
//t.bucketsize为bmap的大小,通过对哈希值和buckets起始地址进行运算,获取哈希值对应的bmap地址
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
//当map的oldbuckets存在时,证明map正在处于扩容中,会先去check旧桶中的数据
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// 如果oldbuckets不为nil且未设置sameSize标记位,证明map的扩容为翻倍扩容(具体在扩容一章会介绍到),老的bucket的数量为m>>1
// There used to be half as many buckets; mask down one more power of two.
m >>= 1
}
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
if !evacuated(oldb) { //判断旧桶是否已被迁移
b = oldb // 因为旧桶尚未迁移,所以需要在旧桶中查找数据
}
}
// 获取当前key的哈希的高8位,和bmap中的topbits作比较,可以减轻直接比较key带来的开销
top := tophash(hash)
其中上述用到的几个工具函数如下
//因为unsafe.Pointer是不支持加法运算的,所以需要将unsafe.Pointer进行强转
func add(p unsafe.Pointer, x uintptr) unsafe.Pointer {
return unsafe.Pointer(uintptr(p) + x)
}
// bucketShift returns 1<<b, optimized for code generation.
func bucketShift(b uint8) uintptr {
// Masking the shift amount allows overflow checks to be elided.
return uintptr(1) << (b & (sys.PtrSize*8 - 1))
}
// bucketMask returns 1<<b - 1, optimized for code generation.
func bucketMask(b uint8) uintptr {
return bucketShift(b) - 1
}
//判断入参bmap是否已完成迁移
func evacuated(b *bmap) bool {
h := b.tophash[0]
return h > emptyOne && h < minTopHash
}
// sameSizeGrow reports whether the current growth is to a map of the same size.
func (h *hmap) sameSizeGrow() bool {
return h.flags&sameSizeGrow != 0
}
// tophash calculates the tophash value for hash.
func tophash(hash uintptr) uint8 {
top := uint8(hash >> (sys.PtrSize*8 - 8))
if top < minTopHash {
//上文中已提到,避免标记位与正常的hashvalue冲突,需要加上minTopHash
top += minTopHash
}
return top
}
- 数据查找
分为两层循环,分别是:
-
第一层遍历对应hash所有的bucket
-
第二层遍历bucket的8个cell
bucketloop:
// 遍历当前hash的常规桶及全部溢出桶
for ; b != nil; b = b.overflow(t) {
// 遍历每一个桶的8个cell
for i := uintptr(0); i < bucketCnt; i++ {
// 对比tophash
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
// emptyRest 标志位:表示当前cell及当前cell后续的cell及bucket都为空,可以退出整个大循环,查找失败
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
//上述运算定位到当前bucket的第i个key的地址
//如果key的类型是指针,则需要进行解引用再进行下边的equal对比
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
//对比key是否一致,因为tophash也会存在冲突的情况,所以需要再次对比两个key是否一致
if t.key.equal(key, k) {
//key是一致,则获取对应索引位置的值
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
//如果value是指针,则需要对value进行解引用,拿到value真正的值
e = *((*unsafe.Pointer)(e))
}
return e
}
}
}
return unsafe.Pointer(&zeroVal[0])
值得一提的是,上述流程中,可以看到在对比前有一个t.indirectkey(),这个我们在下文也能看到大量的t.indirectkey()和t.indirectelem(),在这里做一个解释,这个函数的作用是判断是否有对类型进行指针优化,如果有,则需要解引用才能拿到对应的key/value真实的值。map会对大于128B的key和elem进行优化,在bmap中并不会直接存储对应的数据,而是会存指针,这样做的好处是在map初始化时可以不至于一次性申请过大的内存,而是将大内存的申请分配到每一次的写入操作中。
写入
//通过函数原型可以看到,对map的写入操作其实是会返回对应value的地址,再通过返回的地址进行简介写入
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 赋值操作,map不能为nil,这也是上述对nil的map进行写操作会panic的源码
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
...
//暂时忽略gc和race操作
// 检测是否写标记位已被设置,若已设置则进程直接crash
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
hash := t.hasher(key, uintptr(h.hash0))
// Set hashWriting after calling t.hasher, since t.hasher may panic, in which case we have not actually done a write.
h.flags ^= hashWriting // 设置写标记位
// 如果当前正在使用的桶为nil,则新建一个
if h.buckets == nil {
h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
}
again:
// 找到key对应的桶
bucket := hash & bucketMask(h.B)
// 如果map在扩容,则会触发一次搬迁操作
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
top := tophash(hash)
var inserti *uint8 //tophash的地址
var insertk unsafe.Pointer //key的地址
var elem unsafe.Pointer //elem的地址
bucketloop:
for {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
// 写入时会写入到第一个空cell
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
}
//emptyRest 标志位:表示当前cell及当前cell后续的cell及bucket都为空,
//可以退出整个大循环,查找失败
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if !t.key.equal(key, k) {
continue
}
// already have a mapping for key. Update it.
if t.needkeyupdate() {
typedmemmove(t.key, k, key)
}
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
goto done
}
ovf := b.overflow(t) // 当前bucket未找到,继续便利下一个溢出桶
if ovf == nil {
break
}
b = ovf
}
// 插入新元素前,先判断Map是否需要扩容,扩容相关的细节我们在扩容一章再介绍
// If we hit the max load factor or we have too many overflow buckets,
// and we're not already in the middle of growing, start growing.
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // Growing the table invalidates everything, so try again
}
// all current buckets are full, allocate a new one.
// 桶链上已无空cell,需要申请新的溢出桶
if inserti == nil {
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0] // 新桶的第一个位置
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
// store new key/elem at insert position
if t.indirectkey() {
kmem := newobject(t.key)
*(*unsafe.Pointer)(insertk) = kmem
insertk = kmem
}
if t.indirectelem() {
vmem := newobject(t.elem)
*(*unsafe.Pointer)(elem) = vmem
}
typedmemmove(t.key, insertk, key)
// 通过 runtime.typedmemmove 将键移动到对应的内存空间中并返回键对应值的地址 val
*inserti = top
h.count++
done:
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
h.flags &^= hashWriting // 写入操作已结束,清除写标记位
if t.indirectelem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem
}
上述用到的一些工具函数如下:
// growing reports whether h is growing. The growth may be to the same size or bigger.判断map是否处于扩容中,方法也很简单,hmap.oldbucket不为nil则正在扩容
func (h *hmap) growing() bool {
return h.oldbuckets != nil
}
删除
删除逻辑中,比较特殊的就是:对于空位置会置为emptyOne标示位;对于后面都是空位置会置为emptyRest标示位。这个标示位也是用于在查找的时候进行continue和break。
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
...
if h.flags&hashWriting != 0 { // 删除key也属于写操作,禁止并发
throw("concurrent map writes")
}
h.flags ^= hashWriting
if h.growing() { // 触发扩容
growWork(t, h, bucket)
}
hash := t.hasher(key, uintptr(h.hash0))
bucket := hash & bucketMask(h.B)
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
bOrig := b
top := tophash(hash)
search:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break search
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
if !t.key.equal(key, k2) {
continue
}
// Only clear key if there are pointers in it.
if t.indirectkey() {
*(*unsafe.Pointer)(k) = nil
} else if t.key.ptrdata != 0 {
memclrHasPointers(k, t.key.size)
}
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
*(*unsafe.Pointer)(e) = nil
} else if t.elem.ptrdata != 0 {
memclrHasPointers(e, t.elem.size)
} else {
memclrNoHeapPointers(e, t.elem.size)
}
b.tophash[i] = emptyOne
// If the bucket now ends in a bunch of emptyOne states,
// change those to emptyRest states.
// It would be nice to make this a separate function, but
// for loops are not currently inlineable.
// 如果发现当前元素的下一个元素的tophash值不是emptyRest,则直接退出
if i == bucketCnt-1 {
if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
goto notLast
}
} else {
if b.tophash[i+1] != emptyRest {
goto notLast
}
}
// 否则,将当前元素的tophash设置为emptyRest,且向前check并设置emptyRest标识
for {
b.tophash[i] = emptyRest
if i == 0 {
if b == bOrig {
break // beginning of initial bucket, we're done.
}
// Find previous bucket, continue at its last entry.
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
i = bucketCnt - 1
} else {
i--
}
if b.tophash[i] != emptyOne {
break
}
}
notLast:
h.count--
break search
}
}
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
h.flags &^= hashWriting
}
扩容
golang map不会缩容。除非将map中有效内容拷贝到小容量的map,这样才可能释放掉老map占用的大量空间(ps:golang slice同理)
- map的扩容有两种方式,分别是:翻倍扩容和等量扩容
翻倍扩容
负载因子代表的是bmap的使用程度,当map中元素的个数大于8且负载因子大小超过6.5(81.25%),触发翻倍扩容,bucket的数量翻倍。因为每个bmap的容量为8,所以在loadFactor过大时,会导致查找性能急速下降,golang中将loadfactor的阈值设置为13/2=6.5。
const (
loadFactorNum = 13
loadFactorDen = 2
)
// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
至于装载因子为什么选择6.5,以下是go源码中对不同装载因子的测试,其中有四个重要的指标
-
%overflow : hmap中拥有溢出桶的bucket数量
-
bytes/entry:平均每对key/elem使用的内存数量
-
hitprobe:查找一个存在的keys所需要的检查的kv数量
-
missprobe:查找一个不存在的key需要检查的kv数量
可以看到,当负载因子过大时会导致查找性能急速下降,但是负载因子太小时又会导致有大量内存被浪费,所以go team最终选择了6.5做负载因子。
// Picking loadFactor: too large and we have lots of overflow
// buckets, too small and we waste a lot of space. I wrote
// a simple program to check some stats for different loads:
// (64-bit, 8 byte keys and elems)
// 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
//
// %overflow = percentage of buckets which have an overflow bucket
// bytes/entry = overhead bytes used per key/elem pair
// hitprobe = # of entries to check when looking up a present key
// missprobe = # of entries to check when looking up an absent key
等量扩容
根据map的大小会做区分,如果有过多的溢出桶,则触发等量扩容,bucket的数量不变。因为如果溢出桶的数量过多,也会导致map的查找效率下降。所以触发等量扩容但可以减少溢出桶的使用且使bmap的排列更加紧密(github.com/golang/go/c…
等量扩容的触发条件有2个
-
如果hmap.B<=15,一旦溢出桶的个数超过常规桶,则触发等量扩容
-
如果hmap.B>15,只要溢出桶的个数超过pow(2,15),即触发等量扩容
// tooManyOverflowBuckets reports whether noverflow buckets is too many for a map with 1<<B buckets.
// Note that most of these overflow buckets must be in sparse use;
// if use was dense, then we'd have already triggered regular map growth.
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
// If the threshold is too low, we do extraneous work.
// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
// "too many" means (approximately) as many overflow buckets as regular buckets.
// See incrnoverflow for more details.
if B > 15 {
B = 15
}
// The compiler doesn't see here that B < 16; mask B to generate shorter shift code.
return noverflow >= uint16(1)<<(B&15)
}
- 扩容前准备
在数据写入前会进行扩容条件的判断,如果当前hmap的状态符合上述的翻倍扩容或等量扩容,则触发扩容操作,注意golang map的扩容并不是一个原子过程,是渐进式的,会将扩容操作增量到每一次写操作中去,runtime.hashGrow
就是扩容前的准备操作,会将map标记为扩容中状态。
渐进式扩容也是一种分治的思想,意思是golang不会一次完成整个map的迁移,而是会离散到每一次的写入操作,至于golang为什么选择渐进式扩容,好处也是很明显的,渐进式扩容可以降低全量扩容带来的瞬时计算/存储开销,而弊端也会有一些,因为渐进式扩容是要维护中间状态的,所以在使用上也会带来一定的额外成本,这个我们下文会介绍到
func hashGrow(t *maptype, h *hmap) {
// If we've hit the load factor, get bigger. Otherwise, there are too many overflow buckets,
// so keep the same number of buckets and "grow" laterally.
bigger := uint8(1) //优先走翻倍扩容操作,如果未满足翻倍扩容条件,则走等量扩容
if !overLoadFactor(h.count+1, h.B) {
bigger = 0
h.flags |= sameSizeGrow // 设置等量扩容标记位
}
oldbuckets := h.buckets
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
flags := h.flags &^ (iterator | oldIterator) //清楚iterator标记位
//这里指的一提的是 &^操作,这个操作符的意思是按位置0,通常用于标记位的清楚操作
//x=01011001
//y=01010111
//z = x &^ y = 00001000
//如果y对应的位为1,则z对应的位置0,z的其余位与x一致
//这里的意思是如果hmap存在iterator标记位,因为已经触发了扩容,所以需要转移到oldIterator标志位
if h.flags&iterator != 0 {
flags |= oldIterator
}
// commit the grow (atomic wrt gc)
h.B += bigger
h.flags = flags
h.oldbuckets = oldbuckets // 旧桶会存储到oldbuckets字段
h.buckets = newbuckets // buckets赋值为新桶
// 转移溢出桶
h.nevacuate = 0
h.noverflow = 0
if h.extra != nil && h.extra.overflow != nil {
// Promote current overflow buckets to the old generation.
if h.extra.oldoverflow != nil {
throw("oldoverflow is not nil")
}
h.extra.oldoverflow = h.extra.overflow
h.extra.overflow = nil
}
if nextOverflow != nil {
if h.extra == nil {
h.extra = new(mapextra)
}
h.extra.nextOverflow = nextOverflow
}
}
- bucket搬迁
扩容的时候并没有将旧桶数据转移到新桶。数据转移遵循了写时复制(copy on write)的规则。在真正赋值的时候,会选择是否需要进行数据转移。即写时的“if h.growing() {growWork(t, h, bucket)}”代码段,也可以发现,只会转移当前需要操作的旧桶。
先来几个图示
- hash
因为我们的B是2的正整数次幂,所以翻倍扩容对hash值的影响是只需要向前多取一位即可
- biggerSizeGrow(翻倍扩容)
- sameSizeGrow(等量扩容)
再来看一下具体的源码
//执行桶的迁移,每次迁移桶的数量为1或2
func growWork(t *maptype, h *hmap, bucket uintptr) {
// make sure we evacuate the oldbucket corresponding
// to the bucket we're about to use
// 迁移当前正在访问的旧桶
evacuate(t, h, bucket&h.oldbucketmask())
// evacuate one more oldbucket to make progress on growing
// 为了加快搬迁进度,如果依旧处于扩容中,则再迁移一个桶
if h.growing() {
evacuate(t, h, h.nevacuate)
}
}
// evacDst is an evacuation destination.
type evacDst struct {
b *bmap // current destination bucket
i int // key/elem index into b
k unsafe.Pointer // pointer to current key storage
e unsafe.Pointer // pointer to current elem storage
}
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
newbit := h.noldbuckets()//旧桶的个数
if !evacuated(b) {
// TODO: reuse overflow buckets instead of using new ones, if there
// is no iterator using the old buckets. (If !oldIterator.)
//如果是翻倍扩容,一个旧桶会被迁移到两个新桶,分别位于新buckets的前半部分和后半部分。xy[0]指向前半部分,xy[1]指向后半部分
// xy contains the x and y (low and high) evacuation destinations.
var xy [2]evacDst
x := &xy[0]
x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
x.k = add(unsafe.Pointer(x.b), dataOffset)
x.e = add(x.k, bucketCnt*uintptr(t.keysize))
if !h.sameSizeGrow() { // 如果是f是翻倍扩容,也需要用到ypart,计算ypart地址
// y 代表的 bucket 序号增加了 2^oldB
y := &xy[1]
y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
y.k = add(unsafe.Pointer(y.b), dataOffset)
y.e = add(y.k, bucketCnt*uintptr(t.keysize))
}
//以bucket index维度进行迁移,每一次迁移需要迁移对应index的常规桶和全部溢出桶
for ; b != nil; b = b.overflow(t) {
k := add(unsafe.Pointer(b), dataOffset)
e := add(k, bucketCnt*uintptr(t.keysize))
// 遍历 bucket 中的所有 cell
for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) {
top := b.tophash[i]
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
var useY uint8 //如果是等量扩容则迁移到xpart。如果是翻倍扩容,则会根据高1位来决定迁移到xpart还是ypart
if !h.sameSizeGrow() {
hash := t.hasher(k2, uintptr(h.hash0))
if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
//这里的if条件只有key是math.NAN会进入,因为math.NAN!=math.NAN,我们暂时先忽略这里的特殊逻辑处理,后续详细介绍一下math.NAN
useY = top & 1
top = tophash(hash)
} else {
// 取决于新哈希值的 oldB+1 位是 0 还是 1
if hash&newbit != 0 {
useY = 1
}
}
}
if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
throw("bad evacuatedN")
}
b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
dst := &xy[useY] // evacuation destination
// 如果新桶已满,则需要使用溢出桶
if dst.i == bucketCnt {
dst.b = h.newoverflow(t, dst.b) // 新建一个 bucket
dst.i = 0 // xi 从 0 开始计数
dst.k = add(unsafe.Pointer(dst.b), dataOffset)
dst.e = add(dst.k, bucketCnt*uintptr(t.keysize))
}
dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check
if t.indirectkey() { // key 是指针
*(*unsafe.Pointer)(dst.k) = k2 // copy pointer
} else { // 将原 key(是值)复制到新位置
typedmemmove(t.key, dst.k, k) // copy elem
}
if t.indirectelem() { // value 是指针,操作同 key
*(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e)
} else {
typedmemmove(t.elem, dst.e, e)
}
// 使用下一个 cell
dst.i++
dst.k = add(dst.k, uintptr(t.keysize))
dst.e = add(dst.e, uintptr(t.elemsize))
}
}
// Unlink the overflow buckets & clear key/elem to help GC.
if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
// Preserve b.tophash because the evacuation
// state is maintained there.
ptr := add(b, dataOffset)
n := uintptr(t.bucketsize) - dataOffset
memclrHasPointers(ptr, n)
}
}
// bucket迁移并不是按序迁移的,map在写入时会优先迁移当前正在写入的bucket,如果当前迁移的bucket是hmap中记录的待迁移的下一个bmap,则更新迁移进度。
if oldbucket == h.nevacuate {
advanceEvacuationMark(h, t, newbit)
}
}
//记录迁移进度
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
h.nevacuate++
// Experiments suggest that 1024 is overkill by at least an order of magnitude.
// Put it in there as a safeguard anyway, to ensure O(1) behavior.
stop := h.nevacuate + 1024
if stop > newbit {
stop = newbit
}
for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
h.nevacuate++
}
//全部迁移完成,需要将hmap.flags,hmap.oldbuckets,hmap.extra.oldoverflow清除掉
if h.nevacuate == newbit { // newbit == # of oldbuckets
// Growing is all done. Free old main bucket array.
h.oldbuckets = nil
// Can discard old overflow buckets as well.
// If they are still referenced by an iterator,
// then the iterator holds a pointers to the slice.
if h.extra != nil {
h.extra.oldoverflow = nil
}
h.flags &^= sameSizeGrow
}
}
遍历
先说结论:map的遍历过程会随机取开始的bucket和cell,之后依次遍历全部bucket和cell,因为map是渐进式扩容的,所以如果map处于扩容中且当前遍历到的bucket尚未迁移,就会去从oldbucket中遍历将要迁移加入到当前bucket的cell
注意:在遍历过程中去insert/delete一个kv对,结果是未定义的,会导致遍历出的结果集出现异常
对应到用户侧就是使用range去遍历map,range也是面向用户的,我们先来看一下range在运行时会调用哪些函数。
go源代码:
package main
func main() {
//a := map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}
//for k, v := range a {
// fmt.Println(k, v)
//delete(a, 3)
//a[k+10] = v + 10
//a[k+2_000000] = v + 2_000000
//}
}
func Range(mp map[int64]int64) {
var k, v int64
for k, v = range mp {
_, _ = k, v
}
}
使用go tool compile -N -S -l将上述代码编译成汇编代码,如下,其中核心的函数已用绿色标出
...
0x002f 00047 (main.go:15) MOVQ $0, "".k+32(SP)
0x0038 00056 (main.go:15) MOVQ $0, "".v+24(SP)
0x0041 00065 (main.go:16) MOVQ "".mp+160(SP), AX
0x0049 00073 (main.go:16) MOVQ AX, ""..autotmp_3+40(SP)
0x004e 00078 (main.go:16) LEAQ ""..autotmp_4+48(SP), DI
0x0053 00083 (main.go:16) XORPS X0, X0
0x0056 00086 (main.go:16) PCDATA $0, $-2
0x0056 00086 (main.go:16) LEAQ -32(DI), DI
0x005a 00090 (main.go:16) NOP
0x0060 00096 (main.go:16) DUFFZERO $273
0x0073 00115 (main.go:16) PCDATA $0, $-1
0x0073 00115 (main.go:16) MOVQ ""..autotmp_3+40(SP), AX
0x0078 00120 (main.go:16) LEAQ type.map[int64]int64(SB), CX
0x007f 00127 (main.go:16) MOVQ CX, (SP)
0x0083 00131 (main.go:16) MOVQ AX, 8(SP)
0x0088 00136 (main.go:16) LEAQ ""..autotmp_4+48(SP), AX
0x008d 00141 (main.go:16) MOVQ AX, 16(SP)
0x0092 00146 (main.go:16) PCDATA $1, $1
0x0092 00146 (main.go:16) CALL runtime.mapiterinit(SB)
0x0097 00151 (main.go:16) JMP 153
0x0099 00153 (main.go:16) CMPQ ""..autotmp_4+48(SP), $0 //sp+48是我们当前遍历到的key的地址
0x009f 00159 (main.go:16) NOP
0x00a0 00160 (main.go:16) JNE 164
0x00a2 00162 (main.go:16) JMP 212
0x00a4 00164 (main.go:16) MOVQ ""..autotmp_4+48(SP), AX
0x00a9 00169 (main.go:16) TESTB AL, (AX)
0x00ab 00171 (main.go:16) MOVQ (AX), AX
0x00ae 00174 (main.go:16) MOVQ AX, "".k+32(SP)
0x00b3 00179 (main.go:16) MOVQ ""..autotmp_4+56(SP), AX
0x00b8 00184 (main.go:16) TESTB AL, (AX)
0x00ba 00186 (main.go:16) MOVQ (AX), AX
0x00bd 00189 (main.go:16) MOVQ AX, "".v+24(SP)
0x00c2 00194 (main.go:17) JMP 196
0x00c4 00196 (main.go:16) LEAQ ""..autotmp_4+48(SP), AX
0x00c9 00201 (main.go:16) MOVQ AX, (SP)
0x00cd 00205 (main.go:16) CALL runtime.mapiternext(SB)
0x00d2 00210 (main.go:16) JMP 153
0x00d4 00212 (main.go:16) PCDATA $1, $-1
0x00d4 00212 (main.go:16) MOVQ 144(SP), BP
0x00dc 00220 (main.go:16) ADDQ $152, SP
0x00e3 00227 (main.go:16) RET
0x00e4 00228 (main.go:16) NOP
遍历的整体流程图如下:
遍历会使用runtime.hiter结构体作为迭代器,使用runtime.mapiterinit进行迭代器初始化,使用runtime.mapiternext进行map的迭代。
如果扩容的过程是原子的,那么其实遍历可以非常简单,只需按序遍历bucket及cell即可。但是,由于golang中map的扩容是一个渐进式的操作,所以也会存在map处于扩容中的状态,在这种情况下,部分oldbucket数据尚未迁移到bucket,为了保证所有的key都被遍历到,在遍历时就会需要在oldbuckts和buckets间来回跳转。来看一个遍历的图示:
假设当前有一个map的内存布局如下,当前map正处于扩容中,且已完成了一个bucket的迁移,对应到下图中红色oldbuckets[0]已迁移到buckets[0]和buckets[2]。
假设我们的startBucket = 2且offset = 1。
所以bmap和key的遍历顺序是这样的:
bmap | 2 | 3 | 0 | 1 |
---|---|---|---|---|
key | 3->7->11 | 10-> | 1->2-> | 9 |
bmap: 2 ----> 3----->0------>1
key: 3 -> 7 -> 10 -> 1 -> 2 --> 9
下面我们再来分析一下遍历的源码
type hiter struct {
key unsafe.Pointer // 当前遍历到的key
elem unsafe.Pointer // 当前遍历到的value
t *maptype // map 类型,包含如 key size 大小等
h *hmap // 迭代器初始化时设置,指向当前hmap
buckets unsafe.Pointer // 初始化时指向的 buckets
bptr *bmap // 当前遍历到的 bmap
overflow *[]*bmap // keeps overflow buckets of hmap.buckets alive
oldoverflow *[]*bmap // keeps overflow buckets of hmap.oldbuckets alive
startBucket uintptr // 起始遍历的 bucket 编号
offset uint8 // 迭代过程,从每一个桶的开始cell编号
wrapped bool // already wrapped around from end of bucket array to beginning 是否已经遍历到[]bmap结尾,需要从头开始遍历,用于结束条件的判断
B uint8 // hmap.B
i uint8 // 当前 相对offset的cell序号,真正遍历到的cell序号其实是(i+offset) & (bucketCnt - 1)
bucket uintptr // 指向当前的 bucket
checkBucket uintptr // 因为map在迭代时也可能发生扩容,所以在扩容也需要检查bucket是否已经迁移
}
func mapiterinit(t *maptype, h *hmap, it *hiter) {
//忽略race相关的操作
...
if h == nil || h.count == 0 {
return
}
if unsafe.Sizeof(hiter{})/sys.PtrSize != 12 {
throw("hash_iter size incorrect") // see cmd/compile/internal/gc/reflect.go
}
it.t = t
it.h = h
it.B = h.B // grab snapshot of bucket state 保存桶的当前状态
it.buckets = h.buckets
if t.bucket.ptrdata == 0 {
// Allocate the current slice and remember pointers to both current and old.
// This preserves all relevant overflow buckets alive even if
// the table grows and/or overflow buckets are added to the table
// while we are iterating.
h.createOverflow()
it.overflow = h.extra.overflow
it.oldoverflow = h.extra.oldoverflow
}
// 生成随机数 r
r := uintptr(fastrand())
if h.B > 31-bucketCntBits {
r += uintptr(fastrand()) << 31
}
it.startBucket = r & bucketMask(h.B) // 从哪个 bucket 开始遍历
it.offset = uint8(r >> h.B & (bucketCnt - 1)) // 从 bucket 的哪个 cell 开始遍历
// iterator state
it.bucket = it.startBucket
// Remember we have an iterator.
// Can run concurrently with another mapiterinit().
//将map标记为正在迭代中
if old := h.flags; old&(iterator|oldIterator) != iterator|oldIterator {
atomic.Or8(&h.flags, iterator|oldIterator)
}
mapiternext(it)
}
func mapiternext(it *hiter) {
// 检测当前是不是有 groutine 在进行写入,有的话直接抛出错误
if h.flags&hashWriting != 0 {
throw("concurrent map iteration and map write")
}
h := it.h
t := it.t
bucket := it.bucket
b := it.bptr
i := it.i
checkBucket := it.checkBucket
next:
// 当前遍历到的bmap为 nil,需要继续遍历下一个bmap
if b == nil {
// 当前遍历到的bmap是开始的bmap 并且已经遍历过尾部了,则标识key为nil,
// 在汇编中会用key==nil作为遍历结束条件
if bucket == it.startBucket && it.wrapped {
it.key = nil
it.elem = nil
return
}
//开始迭代时,hmap已经在扩容中,并且扩容尚未结束
if h.growing() && it.B == h.B {
// Iterator was started in the middle of a grow, and the grow isn't done yet.
// If the bucket we're looking at hasn't been filled in yet (i.e. the old
// bucket hasn't been evacuated) then we need to iterate through the old
// bucket and only return the ones that will be migrated to this bucket.
oldbucket := bucket & it.h.oldbucketmask() //当前bucket对应的old bucket
b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) //对应的old bmap
if !evacuated(b) { // 如果old bmap尚未完成迁移,则需要去遍历oldbucket
checkBucket = bucket //标记,当前bucket需要检查,因为当前bucket对应的oldbucket尚未完成迁移
} else { //old bucket已经完成了迁移,则正常遍历当前bucket即可
b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
checkBucket = noCheck
}
} else {
b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
checkBucket = noCheck
}
bucket++ // 下一个桶
if bucket == bucketShift(it.B) { // 已经遍历到尾部了,需要继续从头部开始
bucket = 0
it.wrapped = true //标识已经遍历过尾部了,在退出条件时会用到
}
i = 0
}
// 遍历当前 bucket 的 cell
for ; i < bucketCnt; i++ {
offi := (i + it.offset) & (bucketCnt - 1)
if isEmpty(b.tophash[offi]) || b.tophash[offi] == evacuatedEmpty {
// TODO: emptyRest is hard to use here, as we start iterating
// in the middle of a bucket. It's feasible, just tricky.
continue
}
// key/value 地址
k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+uintptr(offi)*uintptr(t.elemsize))
//如果map处于翻倍扩容的场景,
if checkBucket != noCheck && !h.sameSizeGrow() {
// Special case: iterator was started during a grow to a larger size
// and the grow is not done yet. We're working on a bucket whose
// oldbucket has not been evacuated yet. Or at least, it wasn't
// evacuated when we started the bucket. So we're iterating
// through the oldbucket, skipping any keys that will go
// to the other new bucket (each oldbucket expands to two
// buckets during a grow).
if t.reflexivekey() || t.key.equal(k, k) {
// If the item in the oldbucket is not destined for
// the current new bucket in the iteration, skip it.
// 因为迭代时在迭代新桶,但是旧桶中的数据可以迁移到2个新桶,
// 如果检查旧桶中的数据不是迁移到当前这个新桶,则跳过,会在另一个新桶遍历到
hash := t.hasher(k, uintptr(h.hash0))
if hash&bucketMask(it.B) != checkBucket {
continue
}
} else {
// math.NAN的特殊逻辑,暂时忽略
if checkBucket>>(it.B-1) != uintptr(b.tophash[offi]&1) {
continue
}
}
}
if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) ||
!(t.reflexivekey() || t.key.equal(k, k)) {
// This is the golden data, we can return it.
// OR
// key!=key, so the entry can't be deleted or updated, so we can just return it.
// That's lucky for us because when key!=key we can't look it up successfully.
it.key = k
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
it.elem = e
} else {
// The hash table has grown since the iterator was started.
// The golden data for this key is now somewhere else.
// Check the current hash table for the data.
// This code handles the case where the key
// has been deleted, updated, or deleted and reinserted.
// NOTE: we need to regrab the key as it has potentially been
// updated to an equal() but not identical key (e.g. +0.0 vs -0.0).
rk, re := mapaccessK(t, h, k)
if rk == nil {
continue // key has been deleted
}
it.key = rk
it.elem = re
}
it.bucket = bucket
if it.bptr != b { // avoid unnecessary write barrier; see issue 14921
it.bptr = b
}
it.i = i + 1
it.checkBucket = checkBucket
return
}
b = b.overflow(t)
i = 0
goto next
}
常见的坑点
1、为了防止开发人员依赖map的顺序,对于每一次map遍历(range),得到结果的顺序都是不同的,因为在初始化迭代器(runtime.mapiterinit)时都对startBucket和startCell进行了random操作。
func mapiterinit(t *maptype, h *hmap, it *hiter) {
//some code ...
// decide where to start
r := uintptr(fastrand()) //fastrand 是一个快速生成随机数的函数
if h.B > 31-bucketCntBits {
r += uintptr(fastrand()) << 31
}
it.startBucket = r & bucketMask(h.B)
//bucketMask返回bucket的个数-1,也就是全1,将r和bucketMask做与操作,获得开始的桶编号
it.offset = uint8(r >> h.B & (bucketCnt - 1))
//bucketCnt是常量8代表一个桶内的cell数,将r右移B位后与7做与运算,可以获得开始的cell编号
//some code ...
}
2、无法比较的类型无法作为key
关于Go语言中的可比较性,参考官方文档golang.org/ref/spec#Co…
- 布尔值是可比较的
- 整数值可比较的
- 浮点值是可比较的(其中比较特殊的是math.NAN,math.NAN != math.NAN)
- 复数值是可比较的
- 字符串值是可比较
- 指针值是可比较的。如果两个指针值指向相同的变量,或者两个指针的值均为nil,则它们相等。
- channel是可比较的。如果两个channel值是由相同的make调用创建的,或者两个值都为nil,则它们相等。
- 接口值是可比较的。如果两个接口值具有相同的动态类型和相等的动态值,或者两个接口值都为nil,则它们相等。
- 如果结构的所有字段都是可比较的,则它们的值是可比较的。
- 如果array元素类型的值可比较,则array值可比较。如果两个数组的对应元素相等,则它们相等。
- slice、function value、map是不可比较的(但是这三种类型都可以和nil进行对比)
3、map的value是无法寻址的,也就是说无法获取map value的地址。
常见的case为:当value是struct类型,其值无法修改(最简单的解决方法就是value存成*T类型即可);对map[key]取地址时报错。
package main
type T struct {
Id int64
}
func main() {
var a = map[int64]T{}
a[10].Id = 20
var m map[int]int{1:1}
b := &m[1]
}
./main.go:9:11: cannot assign to struct field a[10].Id in map
./main.go:11:11: cannot take the address of
设计成不可寻址是因为map内部的存储位置未必是固定的。当插入时,若数据空间不够,就需要进行扩容,扩容要重新哈希,此时key被rehash到另外的桶里。
4、map不支持并发读写,只能并发读
package main
func main() {
mp := make(map[int]int)
go func() {
for i := 0; i < 10_0000; i++ {
_ = mp[i]
}
}()
go func() {
for i := 0; i < 10_0000; i++ {
mp[i] = i
}
}()
select {}
}
网上都说对并发读写map会抛出panic,其实是不对的,并不是抛出panic,只是会打印错误及对应的信息并直接调用runtime.exit(2)
函数退出进行。执行上述的代码会有
throw:fatal error: concurrent map read and map write。
值得一提的是对map的并发读写导致的throw是unrecoverable,无法通过内置的recover函数进行捕获,进程会直接crash掉,退出码是2。
可以在运行时加上race检测,可以检测出并发竞争操作,但race开销较大,不要在生产环境中开启。
go run -race main.go
其本质原因也是因为key/value的内存地址是可变的。如果在写时改变了地址,并发线程对旧地址进行读写都会导致内存崩溃/不安全。
具体代码的做法就是设置并check write标示位:
// 写时:
h.flags ^= hashWriting
// 在读/写map时会check标记位,如果发现了并发读写,会抛出错误信息
if h.flags & hashWriting != 0 {
throw("concurrent map read and map write")
}
//输出错误信息
//go:nosplit
func throw(s string) {
// Everything throw does should be recursively nosplit so it
// can be called even when it's unsafe to grow the stack.
systemstack(func() {
print("fatal error: ", s, "\n")
})
gp := getg()
if gp.m.throwing == 0 {
gp.m.throwing = 1
}
fatalthrow()
*(*int)(nil) = 0 // not reached
}
// fatalthrow implements an unrecoverable runtime throw. It freezes the
// system, prints stack traces starting from its caller, and terminates the
// process.
//(输出函数堆栈信息,并退出进程)
//go:nosplit
func fatalthrow() {
pc := getcallerpc()
sp := getcallersp()
gp := getg()
// Switch to the system stack to avoid any stack growth, which
// may make things worse if the runtime is in a bad state.
systemstack(func() {
startpanic_m()
if dopanic_m(gp, pc, sp) {
// crash uses a decent amount of nosplit stack and we're already
// low on stack in throw, so crash on the system stack (unlike
// fatalpanic).
crash()
}
exit(2)
})
*(*int)(nil) = 0 // not reached
}
对于需要并发读写map的场景,常见的解决方案如下:
- map + sync.RWMutex
- 采用 sync.map,实现是小粒度的锁+读写分离+原子操作
5、len(map)返回的是map中元素的个数,不是map的容量
package main
import "fmt"
func main() {
mp := make(map[int64]int64,10)
fmt.Println(len(mp))
}
//上面这段程序会输出0
6、new出来的map是无法使用的,因为new只是malloc了8字节大小的内存(new返回指针)。
相对的,make 是个语法糖,最终被编译器翻译成runtime.makemap 函数调用,会进行内存开辟和对象初始化操作。
7、通过fmt打印map时,空map和nil map结果是一样的,都为map[]。所以,这个时候别断定map是空还是nil,而应该通过map == nil来判断。
8、超出容量时会自动扩容,但尽量提供一个合理的初始值
9、map作为函数的出参时不需要以指针形式进行传递,因为map本身就是指针,上文中map初始化一节也可以看到runtime.makemap返回的是*runtime.hmap。
10、delete是不会真正地把map的内存释放的,要回收map还是需要设为nil
var intMap map[int]int
func main() {
printMemStats("初始化")
// 添加1w个map值
intMap = make(map[int]int, 10000)
for i := 0; i < 10000; i++ {
intMap[i] = i
}
// 手动进行gc操作
runtime.GC()
// 再次查看数据
printMemStats("增加map数据后")
log.Println("删除前数组长度:", len(intMap))
for i := 0; i < 10000; i++ {
delete(intMap, i)
}
log.Println("删除后数组长度:", len(intMap))
// 再次进行手动GC回收
runtime.GC()
printMemStats("删除map数据后")
// 设置为nil进行回收
intMap = nil
runtime.GC()
printMemStats("设置为nil后")
}
func printMemStats(mag string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
log.Printf("%v:分配的内存 = %vKB, GC的次数 = %v\n", mag, m.Alloc/1024, m.NumGC)
}
总结
本文我们首先介绍了map的基本概念,了解到golang的map是基于hash表实现的且冲突解决方式采用链地址法,并简单介绍了map的基本操作。
接下来我们深入源码,分别研究了map的内存模型、哈希函数、初始化、读取、写入、删除、扩容和遍历过程。map中非常核心的概念是桶,桶作为map数据查找和存储的基本单位,每个桶中有8个cell,key的hash值的低位决定了落入哪个bucket,高位则决定了落入哪个cell中,对于超过8个元素的桶,会在后边链上溢出桶做额外存储,而如果有太多的溢出桶或map的空闲容量较小的情况下,则会触发map的扩容,为了防止一次扩容带来的内存抖动和时延开销,map的扩容是渐进式的,会将扩容过程离散到每一次的数据写入操作中。
最终结合网上的一些参考资料和我们平常中的实践,介绍了map在日常使用中常见的一些坑点。