Golang Map实现

1、设计原理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func fastrand() uint32 {
	mp := getg().m
	// Implement xorshift64+: 2 32-bit xorshift sequences added together.
	// Shift triplet [17,7,16] was calculated as indicated in Marsaglia's
	// Xorshift paper: https://www.jstatsoft.org/article/view/v008i14/xorshift.pdf
	// This generator passes the SmallCrush suite, part of TestU01 framework:
	// http://simul.iro.umontreal.ca/testu01/tu01.html
	s1, s0 := mp.fastrand[0], mp.fastrand[1]
	s1 ^= s1 << 17
	s1 = s1 ^ s0 ^ s1>>7 ^ s0>>16
	mp.fastrand[0], mp.fastrand[1] = s0, s1
	return s0 + s1
}

2、数据结构

map仅仅是一个hash表,数据被存放到一个桶数组中,每个桶包含8个键值对。低位的hash用于选择桶。每个桶包含每个hash的几个高阶位,以区分单个桶中的条目。

如果一个桶超过了8条,我们链接到extra的桶。

2.1 hmap

哈希表 runtime.hmap 的桶是 runtime.bmap每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。

Go 语言运行时同时使用了多个数据结构组合表示哈希表,其中runtime.hmap是最核心的结构体,我们先来了解一下该结构体的内部字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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)
	flags     uint8
	B         uint8  // buckets数量为2^B 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种子 hash seed

	buckets    unsafe.Pointer //长度为2^B的buckets数组 array of 2^B Buckets. may be nil if count==0.
	oldbuckets unsafe.Pointer //前一个桶数组,大小是现在的一半,只有在扩容过程中才不为nil  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 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 //下一个未使用的空桶
}

	// flags
	iterator     = 1 // there may be an iterator using buckets
	oldIterator  = 2 // there may be an iterator using oldbuckets
	hashWriting  = 4 // a goroutine is writing to the map
	sameSizeGrow = 8 // the current map growth is to a new map of the same size
  1. count 长度
  2. flags 由四个bit位组成,1.有迭代器在使用buckets,2. 有迭代器在使用oldbuckets,4.hashWriting 正在向map写入,8、sameSizeGrow 等量扩容
  3. B buckets数量,2^B次方,如果B>4,则申请bucket数组的过程中,多申请2^(B-4)个作为overflow bucket,并将nextOverflow指向2^B处的bucket
  4. noverflow 溢出桶的数量,主要用于触发等量扩容条件当noverflow>uint16(1)«(B&15),其中B最大为15
  5. hash0 hash随机种子
  6. buckets 桶,长度为2^B的buckets数组
  7. oldbuckets 旧桶,为了渐进式扩容,先将旧桶保存到这里,扩容完成后即消除。
  8. nevacuate 当前扩容的进度,指向下一个需要迁移的桶编号
  9. extra 扩展字段,主要拥有溢出的场景如下:
    • overflow 已经使用的溢出桶
    • oldoverflow 扩容阶段旧桶用到的那些溢出桶
    • **nextOverflow **下一个可用的溢出桶

2.2 bmap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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.
}
type bmap struct {
    tophash  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}
	emptyRest      = 0 // this cell is empty, and there are no more non-empty cells at higher indexes or overflows.
	emptyOne       = 1 // this cell is empty
	evacuatedX     = 2 // key/elem is valid.  Entry has been evacuated to first half of larger table.
	evacuatedY     = 3 // same as above, but evacuated to second half of larger table.
	evacuatedEmpty = 4 // cell is empty, bucket is evacuated.
	minTopHash     = 5 // minimum tophash for a normal filled cell.

哈希表 runtime.hmapruntime.bmap。每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。

在运行期间,runtime.bmap 结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对,而且 Go 语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导。runtime.bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段,不过我们能根据编译期间的 cmd/compile/internal/gc.bmap 函数重建它的结构:

  1. tophash 存储高位的hash值,用于快速定位,tophash=hash>>(PtrSize*8 - 8)64位系统下将hash右移56位,刚好采用高8位,如果小于5,则加5,因为5以下都有特殊的定义。
  2. keys 键存放的地方,键与键存放一起主要是为了方便计算偏移量和遍历。
  3. values 存放值数据的地方
  4. pad ,内存对齐?我也不知
  5. overflow 指向溢出桶的指针,形成链表

2.3 hiter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// A hash iteration structure.
// If you modify hiter, also change cmd/compile/internal/gc/reflect.go to indicate
// the layout of this structure.
type hiter struct {
	key         unsafe.Pointer // 每次迭代结果的key Must be in first position.  Write nil to indicate iteration end (see cmd/compile/internal/gc/range.go).
	elem        unsafe.Pointer // 每次迭代结果的value Must be in second position (see cmd/compile/internal/gc/range.go).
	t           *maptype // map的类型
	h           *hmap   // map指针
	buckets     unsafe.Pointer // 初始化是指向的桶指针,bucket ptr at hash_iter initialization time
	bptr        *bmap          // 遍历当前桶,current bucket
	overflow    *[]*bmap       // 当前map溢出桶, keeps overflow buckets of hmap.buckets alive
	oldoverflow *[]*bmap       // 扩容之前map溢出桶, keeps overflow buckets of hmap.oldbuckets alive
	startBucket uintptr        // 开始桶偏移量,从这个桶开始遍历的, bucket iteration started at
	offset      uint8          // 桶内偏移量,表示第几个(key,value),intra-bucket offset to start from during iteration (should be big enough to hold bucketCnt-1)
	wrapped     bool           // 尾部已经遍历完成,开始折返从头开始遍历,already wrapped around from end of bucket array to beginning
	B           uint8          // h.B
	i           uint8          // 当前桶已经遍历数目,i=8时,bptr指向下一个
	bucket      uintptr        // 当前hash桶偏移量
	checkBucket uintptr        // 不为 noCheck(1<<(8*sys.PtrSize) - 1)的话,表示当前桶还没有搬迁到新 map,需要对旧桶检查跳过迁移到新扩容的hash桶的元素
}

2、初始化

1
2
3
4
5
hash := map[string]int{
	"1": 2,
	"3": 4,
	"5": 6,
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func maplit(n *Node, m *Node, init *Nodes) {
	a := nod(OMAKE, nil, nil)
	a.Esc = n.Esc
	a.List.Set2(typenod(n.Type), nodintconst(int64(n.List.Len())))
	litas(m, a, init)

	entries := n.List.Slice()
	if len(entries) > 25 {
	//	...
		return
	}
	// Build list of var[c] = expr.
	// Use temporaries so that mapassign1 can have addressable key, elem.
	// ...
}

3、 读写操作

3、扩容

4.1、等量扩容

4.2、增量扩容

5、访问

5.1 遍历

mapiterinit()函数主要是决定我们从哪个位置开始迭代,为什么是从哪个位置,而不是直接从 hash 数组头部开始呢?hash 表中数据每次插入的位置是变化的,这是因为实现的原因,一方面 hash 种子是随机的,这导致相同的数据在不同的 map 变量内的 hash 值不同;另一方面即使同一个 map 变量内,数据删除再添加的位置也有可能变化,因为在同一个桶及溢出链表中数据的位置不分先后,所以为了防止用户错误的依赖于每次迭代的顺序,map 作者干脆让相同的 map 每次迭代的顺序也是随机的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
for ; i < bucketCnt; i++ {
		offi := (i + it.offset) & (bucketCnt - 1) //offset开始遍历
		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
	}

map 的遍历由函数mapiternext()完成,过程如下: (1)从 hash 数组中第 it.startBucket 个桶开始,先遍历 hash 桶,然后是这个桶的溢出链表; (2)之后 hash 数组偏移量+1,继续前一步动作; (3)遍历每一个桶,无论是正常桶还是溢出桶,都从 it.offset 偏移量开始; (4)当迭代器经过一轮循环回到 it.startBucket 的位置,结束遍历。

注意: (1)map 如果在遍历开始时发现处于写入状态,那么报并发读写异常,终止程序。 (2)迭代还需要关注扩容的情况:如果是在迭代开始后才 growing,那么迭代初始状态如 it.buckets 和 it.B 等将被改变,迭代有可能出现异常。如果是先 growing,再开始迭代。这种情况下,不会出现异常,会先到旧 hash 表中检查 key 对应的桶有没有被迁移,未迁移则遍历旧桶,已迁移则遍历新 hash 表里对应的桶。

6、删除

渐进式扩容:

image-20210906073044322

桶的数据结构:

tophash

key与key放一起

value与value放一起

一个桶放不下之后,会新建一个新的桶存放。

如果hash表要分配的桶的数量大于2^4就认为桶的溢出概率很大,就会预分配2^(B-4)个溢出桶备用,溢出桶和常规桶内存上面是连续的,常规桶在前2^B次方,后面的用着溢出桶,举个例子:B=14,前面4k个用着存储常规桶,后面1k个桶用着存储溢出桶。

image-20210906074525883

image-20210906074926231

2、扩容

2.1、增量扩容

从赋值函数mapassign()可以看出,触发扩容有两个条件: (1)当前不处在 growing 状态; (2.1)元素个数 count 大于 hash 桶数量(2^B)*6.5。注意这里的 hash 桶指的是 hash 数组中的桶,不包括溢出的桶; (2.2)或溢出的桶数量 noverflow>=32768(1«15) 或者 noverflow>=hash 数组中桶数量。

go map 的扩容预处理由函数 hashGrow() 来完成,主要完成两个操作: (1)判断扩容类型; (2)申请新的 hash 桶。 新申请的 hash 桶数组指针由 h.buckets 保存,h.oldbuckets 则指向旧 hash 桶数组。map 是否处于扩容状态是根据 h.oldbuckets 是否为空来判断的。

Go map 有两种扩容类型: (1)一种是真扩容,扩到 hash 桶数量为原来的两倍,针对元素数量过多的情况; (2)一种是假扩容,hash 桶数量不变,只是把元素搬迁到新的 map,针对溢出桶过多的情况。如果是假扩容,那么 hmap.flags 会被打上 sameSizeGrow 标识。

2.2、等量扩容

目的是合并已经删除很多keys的map。

image-20210906221650549

image-20210906221815289

image-20210906222507581

image-20210906222626723

image-20210906222816777

image-20210906225608305

image-20210906230047426

使用 Hugo 构建
主题 StackJimmy 设计