我正在参加「掘金·启航计划」
作为golang三大特殊类型slice、map、channel之一,由于map极其简洁的语法特性,对于新增、删除、修改数据这些基本操作,大家都可以信手拈来。但对于map的实现原理、如何实现扩容以及项目中常常出现的错误写法却是作为一个go开发者必须知道并掌握的技能。下面我们由浅入深地了解map的真实面纱。
不得不说的 crud
创建一个崭新的map
// 第一种 不指定容量
var m = make(map[string]string)
// 第二种 将空间容量在创建时就分配好, 推荐写法
var cm = make(map[string]string, 10)
// 第三种 定义好包含一定kv键值对的map
var dm = map[string] string {"key1": "value1", "key2": "value2"}
Q:key类型可以有哪些,value又可以有哪些?
在stackoverflow上有人已经做了答复,直接上链接:stackoverflow.com/questions/7… 。同时在这里将例子一并抄录下来
type mytype struct{}
type ss []string
_ = make(map[interface{}]interface{}) // this works...
_ = make(map[any]any) // ... semantically the same
_ = make(map[mytype]any) // even a struct
_ = make(map[ss]any) // ✘ 编译错误: 无效的map类型ss
key类型必须是可以进行 == 和 != 操作的类型,因此key类型不能是function, map, slice。同时还有这样一句话:
If the key type is an interface type, these comparison operators must be defined for the dynamic key values; failure will cause a run-time panic.
当key类型是一个接口类型, 这些动态且为无法做比较的key值在做等值比较运算时,将造成运行时panic。这里我们还是用事实说话,代码如下:
package main
import (
"fmt"
)
type S struct {
f func()
}
func (s S) Impl() {}
var _ I = S{}
type I interface {
Impl()
}
func main() {
m := make(map[I]bool)
m[S{}] = true
fmt.Println(m)
}
如何存储多个不同类型的键和值?
直接上代码:
// interface
var im = make(map[interface{}]interface{})
im["version"] = 1.19
im["name"] = "golang"
im[100000000] = "gopher's number"
fmt.Println(im)
// any
var am = make(map[any]any)
am["name"] = "golang"
am[1.19] = true
fmt.Println(am)
如何在一个key中存储多个值?
var sliceMap = make(map[string][]interface{}, 2)
sliceMap["name"] = []interface{}{"jack", "john", "jackson", "janni"}
sliceMap["age"] = []interface{}{21, 23, 28, 18}
fmt.Println(sliceMap)
如何添加k-v键值对?
m["country music"] = "Taylor Swift"
m["pop music"] = "Jay Zhou"
m["Rock Stone"] = "Michael JackSon"
m["Blue Jazz"] = "Mars"
fmt.Printf("%v\n", m)
删除键值对
delete(m, "pop music")
fmt.Printf("%v\n", m)
修改键值对
// 判断map中key对应的值是否存在
value, ok := m["pop music"]
if ok {
map["pop music"] = "Gloria"
}
注意:当 value, ok :=m[key] map中的key查找不到时,value返回空值,ok返回false; 空值的value在不同类型下返回的值也是不同,例如 int 为0,string 为 "", slice 为 空
查询键值对
for key := range m {
fmt.Println("key:", key, "value:", m[key])
}
for key, val := range m {
fmt.Printf("%s -----> %s\n", key, val)
}
注意循环遍历的map输出的都是无序的,那要想遍历出有序的map如何实现呢?
var m = make(map[string]string, 3)
m["pop music"] = "jay zhou"
m["classical music"] = "J.S.Bach"
m["jazz music"] = "Bruce"
fmt.Println("original:")
for key, val := range m {
fmt.Printf("%s:%s\n", key, val)
}
keys := make([]string, len(m))
index := 0
for k, _ := range m {
keys[index] = k
index++
}
sort.Strings(keys)
fmt.Println("sorted:")
for _, key := range keys {
fmt.Printf("%s:%s\n", key, m[key])
}
"组合怪" map
map + slice
var sm = make([]map[string]string, 2)
sm[0] = make(map[string]string, 2)
sm[0]["name"] = "zhangsan"
sm[0]["age"] = "12"
sm[1] = make(map[string]string, 2)
sm[1]["name"] = "wanger"
sm[1]["age"] = "21"
fmt.Println(sm)
注意区分:下面的例子是value类型为slice
var sm = make(map[string][]string, 2)
sm["name"] = []string{"zhangsan","wanger"}
sm["brand"] = []string{"google", "apple", "bwm"}
map + map
var mm = make(map[string]map[string]string, 2)
mm["zhangsan"] = make(map[string]string)
mm["zhangsan"]["age"] = "18"
map实现原理分析
先看看go官网是如何定义map:
Go provides a built-in map type that implements a hash table.
map实现了一个哈希表。下面我们用代码证明map的数据结构。 terminal执行命令:go build -gcflags="-l -S" initMap.go > initMap.s 2>&1 ,这里我只选取部分代码。
CALL runtime.makemap_small(SB)
MOVQ AX, main.m+40(SP)
MOVQ AX, BX
LEAQ go.string."pop music"(SB), CX
MOVL $9, DI
LEAQ type.map[string]string(SB), AX
PCDATA $1, $1
CALL runtime.mapassign_faststr(SB)
这里调用了两个runtime方法,分别为:makemap_small, mapassign_faststr . 在 runtime/map.go 下找到这两个方法。这里我就只贴 makemap_small 代码:
// makemap_small implements Go map creation for make(map[k]v) and
// make(map[k]v, hint) when hint is known to be at most bucketCnt
// at compile time and the map needs to be allocated on the heap.
func makemap_small() *hmap {
h := new(hmap)
h.hash0 = fastrand()
return h
}
这个函数的主要作用是创建hmap结构体,并且调用fastrand() 生成一个 seed 给 hmap.hash0 赋值。那这个hmap是一个什么样子的,作用是什么?如何实现map的赋值,hash以及map的操作?带着这些疑问我们接着往里看。
不得不说的map数据结构
map的头部对象:
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/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)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
mapextra 结构体(可选字段):
// 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.
// 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
oldoverflow *[]*bmap
// nextOverflow holds a pointer to a free overflow bucket.
nextOverflow *bmap
}
bmap结构体(即:bucket的内存模型)
// 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.
}
bucketCnt常量代码如下:
const (
// Maximum number of key/elem pairs a bucket can hold.
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits // 1 << 3 => 2^3 = 8
}
从编译器编译后的bmap可以知道,实际上调用 cmd/compile/internal/reflectdata/reflect.go
func MapBucketType(t *types.Type) *types.Type {
...
field := make([]*types.Field, 0, 5)
// The first field is: uint8 topbits[BUCKETSIZE].
arr := types.NewArray(types.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)
...
overflow := makefield("overflow", otyp)
field = append(field, overflow)
// link up fields
bucket := types.NewStruct(types.NoPkg, field[:])
bucket.SetNoalg(true)
types.CalcSize(bucket)
...
t.MapType().Bucket = bucket
bucket.StructType().Map = t
return bucket
}
因此,bmap的结构体如下:
由此,我们可以从上面的代码可以知道之间的关系,还是通过一个结构图来表示map数据结构:
查看 makemap_small 函数的注释后,我们就可以知道make(map[k]v, hint) 中 hint 为 bucketCnt(即: 8) 的值时,都会调用该函数,并把map分配在堆上。那 hint> 8 呢?马上试试呗!
var m = make(map[string]string, 10)
再次生成对应的汇编代码,即可知道当 hint> 8时,编译器则调用 makemap 函数。
makemap函数
func makemap(t *maptype, hint int, h *hmap) *hmap {
// step1. 当计算出hint * t.bucket.size 大于最大分配内存时,hint = 0
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
// step2. 初始化hmap,并通过fastrand得到hash值
if h == nil {
h = new(hmap)
}
h.hash0 = fastrand()
// step3. 根据输入的元素hint,得到可装下元素的B
// For hint < 0 overLoadFactor returns false since hint < bucketCnt.
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
// 分配初始化的哈希表
// 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
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
这里可以先看看这个计算B值的函数
overLoadFactor函数:
// bucketCnt: 单个桶数(初始值:8)
// loadFactorNum: 13
// bucketShift:1<<b
// loadFactorDen:2
//
// overLoadFactor报告放置在1<<B个桶中的计数项是否超过loadFactor。
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
当overLoadFactor返回true时,表示需要扩容,则B+1;直到递增B值满足overLoadFactor函数返回false为止。
接下来就是根据B申请桶空间,其中申请桶空间主要就是makeBuketArray,其主要逻辑就是当h.B>=4时,即桶数量大于等于16个时,会额外创建2^(b-4)个溢出桶;当h.B < 4 时,不再有任何操作,主要原因是桶数量小于16,使用溢出桶从概率上讲很小,因此不需要考虑该情况。在这里我们还是用一个图来直观说明一下:
通过源码阅读,基本桶和溢出桶在内存上是一片连续的空间,其大小为 bucket.size * (buckets数量 + overflowBuckets数量)。这里源码不贴出了,感兴趣的小伙伴可以自行阅读,后面我们基本上也是按照这种能直接绘图说明的就不会用代码展示,争取让各位看官老爷能理解设计者的心路历程。
map查询
map写入
map删除
map遍历为何输出无序结果?
func main() {
var m = map[int]string{
1: "golang",
2: "java",
3: "c/c++",
}
for k := range m {
fmt.Println(k, m[k])
}
}
通过对上述代码进行汇编分析后,我们可以清晰看出在遍历过程中分别调用了 mapiterinit 和 mapiternext 这两个函数。在初始化迭代器中分别对起始bucket和起始cell均做了随机数的操作。下面抄录部分关键源码如下:
func mapiterinit(t *maptype, h *hmap, it *hiter) {
...
var r uintptr
// 快速生成随机数
if h.B > 31-bucketCntBits {
r = uintptr(fastrand64())
} else {
r = uintptr(fastrand())
}
// r 与 bucketMask返回值做与运算,获取初始桶的编号。
it.startBucket = r & bucketMask(h.B)
// r 右移 h.B 位 与7做与运算,获取开始的cell的位置。
it.offset = uint8(r >> h.B & (bucketCnt - 1))
// iterator state
it.bucket = it.startBucket
...
}
map初始迭代器 hiter 中起始的bucket位置和每一个bmap的偏移量都是随机的,所以map遍历出来的顺序也是随机的。
map扩容机制
为什么需要map扩容?首先我们寻找这个问题的线索。从上面的源码分析,我们可以很容易知道golang map采用了哈希表的方式实现。当向map中添加大量key时,bucket中的cell也会被迅速填满,那么buckets数组的bmap链表就会随着key的插入变长,这时候map的时间复杂度由O(1) 变成 O(N),为防止这种情况的发生,map就需要扩容来增大buckets数组,用空间换时间的方式维持其效率。那扩容还需要什么呢?什么时候触发扩容效率最高呢?让我们带着这些问题一起去寻找这些问题的线索。
增量扩容
// 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 溢出桶占全部百分比
> // bytes/entry 平均每对key-value占用开销字节数
> // hitprobe 查找一个存在的key所需的entry数量
> // missprobe 查找一个不存在的key所需的entry数量
在go源码注释中,详细罗列了不同装载因子对map的性能测试,其中分别以四个性能指标来展示不同负载因子对map效率的影响。在go map源码中函数overLoadFactor():
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
可以清晰得出其loadfactor = 6.5 ;其实从测试性能数据上看出,当负载因子过大会导致大量的溢出桶创建,查询效率降低;负载因子太小时,就会浪费大量内存空间;因此取6.5作为负载因子。
如果在map插入key已经达到 key个数 >= bucket总数量 *(13/2)时,这时候大量的基本bucket上元素装满时,新的key极大概率需要放在溢出桶中。 这个时候就需要再创建bucket数组,将这个新的数组扩大为原有数组的两倍,把旧有的数组数据搬迁到新的上面,将这种方案称之为增量扩容。
等量扩容
还有一种情景就是,当创建过多的溢出桶后,会导致产生大量空置的桶,造成桶利用率不高,查询等操作效率变低,但其未达到上面的临界条件,那就不能用增量扩容的方式了。
那是不是可以不用扩大容量,只需要把原有的散列开来的数据重新排列一下。这样就提高桶的使用效率,保证map高效的效率了,我们将这种方案称为等量扩容。
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
}
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
// B
if B > 15 {
B = 15
}
return noverflow >= uint16(1)<<(B&15)
}
注意:map无论是触发增量扩容还是等量扩容,均需要创建新的bucket数组,并不会在原有的bucket数组上进行操作,在之后进行迁移数据工作。 对于迁移的数据,是不会一次性完整地将key的数据搬迁到新数组上,这样会造成严重的性能下降,而是申请好新的bucket数组空间,将老的bucket数组放在hmap的oldbuckets上。真正迁移数据是放在map存入key的调用函数mapassign()和删除key 函数mapdelete()上的。
map能支持并发操作吗?
在这里我们通过一个例子来说明这个问题。
func main() {
var m = make(map[int]int, 10)
for i := 0; i <= 10; i++ {
go func() {
for j := 0; j <= 10; j++ {
m[j] = j
}
}()
}
time.Sleep(time.Second * 3)
fmt.Println(m)
}
执行以上命令,控制台输出 fatal error: concurrent map writes 。很明显看出,map不能支持并发写入操作。其实在map写入函数mapassign中清晰展现了这个逻辑。现在将相关源码贴出如下:
...
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
hash := t.hasher(key, uintptr(h.hash0))
// 写入保护
h.flags ^= hashWriting
...
done:
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
h.flags &^= hashWriting
if t.indirectelem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem
小小的总结:
在介绍map的过程发现还是只讲了一点大概的东西,还是有很多小的细节没说到。比如在扩容过程中对于搬迁区间的内容并没有一一列出,这也是map写入理解的难点之处。在这里我的主要路线针对于map的基本数据结构,map查询、写入、删除的主要流程,以及map使用的坑点这些知识点,对于map扩容过程和数据迁移区间操作,还有相关性能优化以后会分章节介绍。哈哈哈,开始给自己埋坑了,感谢小伙伴的观看啦!!!