不得不知道的map

212 阅读11分钟

我正在参加「掘金·启航计划」

作为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)

0A11027A.jpg

这里调用了两个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() 生成一个 seedhmap.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
}

hmap.jpg

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的结构体如下:

channel.jpg

由此,我们可以从上面的代码可以知道之间的关系,还是通过一个结构图来表示map数据结构: map关系.jpg

查看 makemap_small 函数的注释后,我们就可以知道make(map[k]v, hint)hintbucketCnt(即: 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,使用溢出桶从概率上讲很小,因此不需要考虑该情况。在这里我们还是用一个图来直观说明一下:

buckets.jpg

通过源码阅读,基本桶和溢出桶在内存上是一片连续的空间,其大小为 bucket.size * (buckets数量 + overflowBuckets数量)。这里源码不贴出了,感兴趣的小伙伴可以自行阅读,后面我们基本上也是按照这种能直接绘图说明的就不会用代码展示,争取让各位看官老爷能理解设计者的心路历程。

map查询

map查询key.jpg

在源码中**mapaccess2()、mapaccessK()** 只是返回值参数不同,其逻辑与mapaccess1() 一致。

map写入

map赋值.jpg

map删除

map_delete.jpg

map遍历为何输出无序结果?

func main() {
	var m = map[int]string{
		1: "golang",
		2: "java",
		3: "c/c++",
	}

	for k := range m {
		fmt.Println(k, m[k])
	}
}

通过对上述代码进行汇编分析后,我们可以清晰看出在遍历过程中分别调用了 mapiterinitmapiternext 这两个函数。在初始化迭代器中分别对起始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扩容过程和数据迁移区间操作,还有相关性能优化以后会分章节介绍。哈哈哈,开始给自己埋坑了,感谢小伙伴的观看啦!!!