Go进阶理解map实现原理并高效使用

0 阅读11分钟

Go中map的内部实现.png

1.什么是map

map是Go语言提供的一种抽象数据类型.它表示一组无序的键值对.map对value的类型没有限制.但是对key有严格的要求.key的类型应该严格定义了作为"=="和"!="两个操作符的操作数时的行为,因此函数 map 切片不能作为map的key类型.map类型不支持"零值可用".未显示赋初值的map类型变量的零值为nil.对处于零值状态的map变量进行操作将会导致panic.

func main() {
    //m为nil.
    var m map[int]string
    m[1] = "hello"
}

必须对map类型变量进行显示初始化后才能使用它,和切片一样.创建map类型变量有两种方式.一种是使用符合字面值.另一种是使用make这个预声明的内置函数.

使用复合字面值创建map类型变量:

func main() {
    //m为nil.
    var m = map[int]string{
       1: "hello",
       2: "go",
    }
    m[3] = "world"
    fmt.Println(m)
}

使用make创建map类型变量:

func main() {
    m := make(map[int]string)
    m[1] = "hello"
    m[2] = "world"
    m[3] = "go"
    fmt.Println(m)
}

和切片一样.map也是引用类型.将map类型变量作为函数参数传入不会有很大性能消耗.并且在函数内部对map变量的修改在函数外部也是可见的.

func foo(m map[string]int) {
    m["key1"] = 11
    m["key2"] = 22
}

func main() {
    m := map[string]int{
       "key1": 1,
       "key2": 2,
    }
    fmt.Println(m)
    foo(m)
    fmt.Println(m)
}

2.map的基本操作:

1).插入数据:

面对一个非nil的map类型变量.可以向其插入符合map类型定义的任意键值对.Go运行时会负责map内部的内存管理.除非是系统内存耗尽.所以不用担心map中插入数据的数量.

m := make(map[K]V)

m[k1] = v1

m[k2] = v2

m[k3] = v3

如果key已经存在于map中.则该插入操作会用新值覆盖旧值.

m := map[string]int {

"key1" : 1,

"key2" : 2,

}

//11会覆盖掉旧值1.

m["key1"] = 11

m["key23"] = 3

2).获取数据个数:

和切片一样.map也可以通过内置函数len获取当前已存储的数据个数.

m := map[string]int{

"key1" : 1,

"key2" : 2,

}

//2.

fmt.Println(len(m))

m["key3"] = 3

//3.

fmt.Println(len(m))

3).查找和数据读取:

map类型更多用在查找和数据读取场合.所谓查找就是判断某个key是否存在于某个map中.可以使用"comma ok"惯用法进行查找.

_,ok := m["key"]

if !ok {

//"key"不在map中.

}

如果并不关心某个key对应的value.仅仅关心某个key是否在map中.使用空标识符(blank identifier)忽略了可能返回的数据值.仅关心ok的值是否为true(表示在map中).

如果要读取key对应的value值.

m := map[string]int

m["key1"] = 1

m["key2"] = 2

v  := m["key1"]

//1.

fmt.Println(v)

v  := m["key3"]

//0.

fmt.Println(v)

对于map中不存在的值还是返回了一个默认值.在这样的情况下无法判断这个值是存在不存在.因此还是要借助"comma ok"惯用法.只有当ok=true时.所得的value值才是需要的.

4).删除数据:

借助内置函数delete从map中删除数据.

m := map[string]int {

"key1" : 1,

"key2" : 2,

}

fmt.Println(m)

delete(m,"key2")

fmt.Println(m)

注意:即使要删除的数据在map中不存在.delete也会导致panic.

5).遍历数据:

可以像对待切片那样通过for range语句对map中的数据进行遍历:

func main(){

m := map[int]int{

1 : 11,

2 : 12,

3 : 13,

}

fmt.Printf("{")

for k,v := range m {

fmt.Printf("[%d,%d]",k,v)

}

fmt.Printf("}\n")

}

注意:Go在运行时在初始化map迭代器时对起始位置做了随机处理,千万不要依赖map所得到的元素次序.

3.map的内部实现:

和切片相比.map类型的内部实现要复杂的多.Go运行时用一张哈希表来实现抽象的map类型.运行时实现了map操作的所有功能.包括查找 插入 删除和遍历等.在编译阶段.Go编译器会将语法层面的map操作重写成运行时对应的函数调用.

m := make(map[keyType]valType,capacityhint) -> m := runtime.makemap(maptype,capacityhint,m)

v := m["key"] -> v := runtime.mapaccess1(maptype,m,"key")

v , ok := m["key"]  -> v , ok := runtime.mapassign(maptype, m, "key")  //v是用于后续存储value的空间的地址. 

delete(m,"key")  -> runtime.mapdelete(maptype,m,"key")

1).初始状态:

count:当前map的元素个数.对map类型变量运用len内置函数时.len返回的就是count值.

flags:当前map所处的状态标志.

B:B的值是bucket数量的以2为底的对数.

noverflow:overflow bucket的大约数量.

buckets:哈希函数的种子值.

oldbuckets:在map扩容阶段指向前一个bucket数组的指针.

nevacuate:在map扩容阶段充当扩容进度计数器.所有下标号小于nevacuate的bucket都已完成了数据排空和迁移操作.

extra:可选字段.如果有overflow bucket存在.且key value都因不包含指针而被内联的情况下.该字段将存储所有指向overflow bucket指针.保证overflowbucket始终可用.(不被垃圾回收掉).

真正用于存储键值对数据的是bucket(桶).每个bucket中存储的是Hash值低于bit位数值相同的元素.默认的元素个数为BUCKETSIZE(值为8).当某个bucket的8个空槽(slot)都已填满且map尚未达到扩容条件时.运行时会建立overflow bucket.并将该overflow bucket挂在上面bucket末尾的overflow指针上.这样两个bucket形成了一个链表结构.该结构的存在将持续到下一次map扩容.

每个bucket由三部分组成.tophash区域 key存储区域和value存储区域.


tophash区域:

当向map插入一条数据或从map按key查询数据的时候.运行时会使用哈希函数对key做哈希运算并获得一个哈希值的hashcode.这个hashcode很关键.运行时将hashcode一分为二看待.低位区的值用于选定bucket.高位区用于某个bucket中确定key的位置.因此,每个bucket的tophash区域是用于快速定位key位置的.这样避免了逐个key进行比较这种代价的操作.尤其是key是size较大的字符串类型.是一种空间换时间的思路.

key存储区域:

tophash区域下面是一块连续的内存区域.存储的是bucket承载的所有key数据.运行时在分配bucket时需要知道key的大小.Go运行时会为该变量对应的特定map类型生成一个maptype实例(如存在.则复用):

type mapType struct {
    abi.OldMapType
}

type OldMapType struct {
	Type
	Key    *Type
	Elem   *Type
	Bucket *Type // internal type representing a hash bucket
	// function for hashing keys (ptr to key, seed) -> hash
	Hasher     func(unsafe.Pointer, uintptr) uintptr
	KeySize    uint8  // size of key slot
	ValueSize  uint8  // size of elem slot
	BucketSize uint16 // size of bucket
	Flags      uint32
}

上面结构体包含了map类型的所有元信息.前面提到过编译器会将语法层面的map操作重写成运行时所对应的函数调用..这些运行时函数有一个共同特定.第一个参数都是maptype指针类型的参数.Go运行时就是利用maptype参数中的信息确定key的类型和大小的.map所用的hash函数也存放在maptype.key.alg.hash(key,hmap.hash())中.同时maptype的存在也让Go中所有的map类型共享一套运行时的map操作函数.减少了对最终二进制文件空间的占用.

value存储区域:

key存储区域下方是一块连续的内存区域.该区域存储的是key对应的value.和key一样.该区域的创建也得到了了maptype信息的帮助.Go运行时采用了将key和value分开存储而不是采用一个kv接着一个kv紧邻方式存储.带来了算法上的复杂性.减少了内存对齐带来的内存浪费.

如果key或value的数据长度大于一定的数值.那么运行时不会在bucket中直接存储数据.而是会存储key或value数据的指针.

4.map的扩容:

map会对底层使用的内存进行自动管理.在使用过程中.在插入元素超出一定数值后.map势必存在自动扩容的问题(扩充bucket数量).并重新在bucket间均衡分配数据.

Go在运行时的map实现中引入了一个LoadFactor(负载因子).当count>LoadFactor*2^B或overflow bucket过多时.运行时会对map进行扩容.目前LoadFactor设置为6.5.

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    if h == nil {
       panic(plainError("assignment to entry in nil map"))
    }
    if raceenabled {
       callerpc := sys.GetCallerPC()
       pc := abi.FuncPCABIInternal(mapassign)
       racewritepc(unsafe.Pointer(h), callerpc, pc)
       raceReadObjectPC(t.Key, key, callerpc, pc)
    }
    if msanenabled {
       msanread(key, t.Key.Size_)
    }
    if asanenabled {
       asanread(key, t.Key.Size_)
    }
    if h.flags&hashWriting != 0 {
       fatal("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

    if h.buckets == nil {
       h.buckets = newobject(t.Bucket) // newarray(t.Bucket, 1)
    }

again:
    bucket := hash & bucketMask(h.B)
    if h.growing() {
       growWork(t, h, bucket)
    }
    b := (*bmap)(add(h.buckets, bucket*uintptr(t.BucketSize)))
    top := tophash(hash)

    var inserti *uint8
    var insertk unsafe.Pointer
    var elem unsafe.Pointer
bucketloop:
    for {
       for i := uintptr(0); i < abi.OldMapBucketCount; i++ {
          if b.tophash[i] != top {
             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+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
             }
             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+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
          goto done
       }
       ovf := b.overflow(t)
       if ovf == nil {
          break
       }
       b = ovf
    }

    // Did not find mapping for key. Allocate new cell & add entry.

    // 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
    }

    if inserti == nil {
       // The current bucket and all the overflow buckets connected to it are full, allocate a new one.
       newb := h.newoverflow(t, b)
       inserti = &newb.tophash[0]
       insertk = add(unsafe.Pointer(newb), dataOffset)
       elem = add(insertk, abi.OldMapBucketCount*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)
    *inserti = top
    h.count++

done:
    if h.flags&hashWriting == 0 {
       fatal("concurrent map writes")
    }
    h.flags &^= hashWriting
    if t.IndirectElem() {
       elem = *((*unsafe.Pointer)(elem))
    }
    return elem
}

流程:

开始

安全检查(nil map/并发写/竞赛检测)

计算 key 哈希值

标记:正在写入(防止并发)

如果桶未初始化 → 创建第一个桶


循环:

  1. 计算目标桶编号
  2. 如果正在扩容 → 先迁移数据
  3. 遍历当前桶 + 溢出桶
    ↳ 找到相同 key → 更新,直接结束
    ↳ 找到空位 → 记录插入位置
    ↳ 桶满 → 找下一个溢出桶

    没找到 key:
    ↳ 达到负载因子 → 扩容 → 重新从头执行
    ↳ 没有空位 → 新建溢出桶
    ↳ 写入新 key/value
    ↳ count++

  ↓
取消写入标记

返回 value 指针
结束

如果是因为overflow bucket过多导致的"扩容".实际上运行时会新建一个和现有规模一样的bucket数组.然后在进行assign和delete操作的时候进行排空和迁移.如果因为当前数据量超出LoadFactor指定的水位情况.那么运行时会建立一个两倍于现有规模的bucket数组.但真正排空和迁移工作也是进行assign和delete操作逐步进行的.原bucket数组会挂在hmap的oldbuckets指针下面.知道原bucket数组中的所有数据都迁移到新数组.原bucket数组才会被释放.

5.map与并发:

从上面实现原理来看.充当map描述符角色的hmap实例自身是有状态的(hmap.flags)且对状态的读写是没有并发保护的.因此map实例不是并发安全的.不支持并发读写.如果对map实例进行并发读写会发生panic.

func main() {
    m := map[int]int{
       1: 11,
       2: 12,
       3: 13,
    }

    go func() {
       for i := 0; i < 1000; i++ {
          doIteration(m)
       }
    }()

    go func() {
       for i := 0; i < 1000; i++ {
          doWrite(m)
       }
    }()
    time.Sleep(5 * time.Second)
}

func doIteration(m map[int]int) {
    for k, v := range m {
       _ = fmt.Sprintf("[%d:%d]", k, v)
    }
}

func doWrite(m map[int]int) {
    for k, v := range m {
       m[k] = v + 1
    }
}

6.尽量使用cap参数创建map:

从上面自动扩容原理了解到.如果初始创建map没有创建足够多可以应付map使用场景的bucket.那么随着插入map元素数量增多.map会频繁扩容.而这一过程将降低map的访问性能.如果可能的话.最好对map使用规模做出粗略的估算.并使用cap参数对map实例进行初始化.

func BenchmarkMapInitWithoutCap(b *testing.B) {
    for n := 0; n < b.N; n++ {
       m := make(map[int]int)
       for i := 0; i < 10000; i++ {
          m[i] = i
       }
    }
}

func BenchmarkMapInitWithCap(b *testing.B) {
    for n := 0; n < b.N; n++ {
       m := make(map[int]int,10000)
       for i := 0; i < 10000; i++ {
          m[i] = i
       }
    }
}

谢家庭院残更立.燕宿雕梁.月度银墙.不辩花丛那辩香.

此情已自成追忆.零落鸳鸯.雨歇微凉.十一年前梦一场. 纳兰.

语雀地址www.yuque.com/itbosunmian…?

《Go.》 密码:xbkk 欢迎大家访问.提意见.

如果大家喜欢我的分享的话.可以关注我的微信公众号

念何架构之路