map的实现原理

map的底层是一个hmap结构体,这个结构体组成是这样的:

type hmap struct {
    count int // 当调用len用来返回map的长度时,就会返回它。
    flags uint8 // 标志着hmap处于什么状态,读还是写?
    B 	  uint8 // 这里不了解为什么要大写,B是hmap中buckets的对数, 2^B = len(buckets)
    noverflow uint16 // 溢出桶的大概数量。
    hash0 uint32 // 后文计算hash值时会传入它作为参数,生成的hash具有随机性(hash种子)
    buckets unsafe.Pointer // 指针指向bmap结构体数组
    oldbuckets unsafe.Pointer // 指针指向旧的bmap结构体数组
    nevacuate uintptr // 英文有疏散的意思,在这里表示hamp的迁移进度
    extra *mapextra // 据名思意,也就是额外的map。为了优化GC而设计的。
}

这里的B就能够表示整个map的buckets的数量。其实也就是bmap结构体数组的大小,那么我们来介绍一下bmap结构体

type bmap struct {
    tophash [bucketCnt]uint8
}

这里表示的仅仅是表面上bmap结构体的样子。实际上,在编译器他会发生一些动态的扩充。

type bmap struct {
    tophash  [8]uint8 // tophash中放置的是key的 “类型”
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

android map数据结构 map结构实现_map

当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来。

type mapextra struct {
	// overflow contains overflow buckets for hmap.buckets.
	// oldoverflow contains overflow buckets for hmap.oldbuckets.
    overflow    *[]*bmap
	oldoverflow *[]*bmap

	// nextOverflow 包含空闲的 overflow bucket,这是预分配的 bucket
	nextOverflow *bmap
}

通过下图可以更好的理解bmap中存放key-value的情况。

android map数据结构 map结构实现_golang_02

bmap中最多放置8个key-value键值对。并且这些键值对是通过数组的形式分别存储的。好处是什么呢(个人理解)?

map[int64]uint8

先假设一个key,一个value的连续存储。而key和value的类型又不相同(如上图所示),所占据的内存大小也不相同。因此在内存分配的时候就会一段大,一段小。可能会浪费内存空间。

而采用连续的key和连续的value进行分别组合的话,内存就会比较均匀完整。

每个bmap只能存放8个键值对,因此如果有第9个键值对时,就会通过链表法将overflow指向下一个bmap。

Key的定位过程

在64bit机器上,随机生成的key就是64位的。而最后B位就决定了这个key要被存储在哪个桶中去。因为前边已经说过了,一共有2^B个桶,而最后B位恰好就能决定放在哪个桶中。举个例子:

10010111 | 000011110110110010001111001010100010010110010101010 │ 01010

假设上述是生成的一个key。而01010表示二进制数。将二进制转化为十进制的数就是这个key要存储的桶子下标,也就是10,那么他就会被放在下标为10的桶子中去。

那么如何确定key的下标呢?这时候就要用到高8位了。在源码中会将key向右移动56个位置来找到key的地址。一样是通过进制转换将key地址确定为:151。因此这个tophash值就是151。

更新理解:这里的高八位用来计算出tophash值,根据这个tophash值与bucket中的8个tophash依次进行比较。如果能够找到相等的tophash,则该key大概率会出现在这个bucket中,如果找不到相等的key,则一定不会在该bucket中。

再找到tophash之后,并不是通过下标来找到key的位置。因为存储key-value的是数组,而数组又是一段连续的内存。因此可以根据tophash直接推算出key的地址,通过key和value的地址来取出具体的值。

两个不同的key落入到同一个位置上时,就会发生hash冲突,这时候就会使用链表法,通过overflow指向下一个bmap中去。

下面给出一张非常详细的图:

android map数据结构 map结构实现_golang_03

map底层是如何进行遍历的?

map在扩容的时候每次只会扩容两个bmap。扩容进度可以通过tophash[0]中的值可以看到

// 这个值有两层意思:一是表示该tophash对应的K/V位置是可用的;二是表示该位置后面的K/V位置都是可用的。
emptyRest = 0 // 该cell为空,并且任何一个cell都为空。完全初始化状态。
// 仅表示该tophash对应的K/V位置是可用的,其后面的是否可用不知道。
emptyOne = 1 // 该cell为空。
evacuatedX = 2 // 旧的key val已经被搬迁,但新的bucket中key val并没有完全分离
evacuatedY = 3 // 旧的key val已经被搬迁,但新的bucket中key val并没有完全分离
evacuatedEmpty = 4 // 空的搬迁状态,说明已经完成搬迁
minTopHash = 5 // tophash 的最小正常值

当map进行遍历时,会先进行迭代器初始化,然后再循环迭代器。

map遍历是无序的,这是因为底层会生成一个随机数,使用这个随机数会随机确定从哪个bucket开始进行遍历。然后再确定从这个bucket的哪个cell开始遍历。

假设我们有下图**(图片来自参考资料链接,侵权删)**所示的一个 map,起始时 B = 1,有两个 bucket,后来触发了扩容(这里不要深究扩容条件,只是一个设定),B 变成 2。并且, 1 号 bucket 中的内容搬迁到了新的 bucket,1 号裂变成 1 号3 号0 号 bucket 暂未搬迁。老的 bucket 挂在在 *oldbuckets 指针上面,新的 bucket 则挂在 *buckets 指针上面。

android map数据结构 map结构实现_golang_04

这时,我们对此 map 进行遍历。假设经过初始化后,startBucket = 3,offset = 2。于是,遍历的起点将是 3 号 bucket 的 2 号 cell,下面这张图就是开始遍历时的状态:

android map数据结构 map结构实现_面试题_05

标红的表示起始位置,bucket 遍历顺序为:3 -> 0 -> 1 -> 2。

如果遍历到一个已经迁移过的bucket,那就按照随机形式进行遍历,直到结束。

如果遍历到一个没有迁移的bucket。这里需要先知道一个bucket(1号)会扩容为两个bucket,(新1号和新2号)。因此在遍历未迁移的bucket时,会去遍历bucket的新1号。因为分裂之后的旧bucket中的kv都会放在新1号中。剩余的扩展内容放在新2号中。

什么时候map会触发扩容机制?

只有在两种情况下才会触发map的扩容机制:

  1. map的装载因子大于一个阀值,这个阀值在源码中表示为6.5。
  2. 当overflow中的bucket过多:
  1. 当B<15,也就是说bucket总数2^B < 2^15。overflow中的bucket > 2^B。
  2. 当B>15,也就是说bucket总数2^B >= 2^15。overflow中的bucket > 2^15。

这里的装载因子计算公式为:

loadFactor := count / (2^B)

那么为什么要设置第二个限制条件呢?原因在于hash冲突

map是线程安全的吗?

map不是线程安全的,在对map进行插入、删除、循环遍历的时候只要触发写的操作,就会出现painc异常。但sync.Map是线程安全的。

【引申1】slice 和 map 分别作为函数参数时有什么区别?

makeslice时返回的是一个原类型的slice,而makemap时返回的是一个指针类型的map。因此slice作为参数时,拷贝的是slice的一个副本。map作为参数时,拷贝的时map的地址。当函数内部对map进行修改时也会影响到函数外map的内部值,即使触发了扩容机制也一样。

【引申2】map是无序的还是有序的,为什么?如何有序输出map?

map是无序的。因为map在遍历的时候会先随机生成一个数值。根据这个数值来决定从哪个bucket开始遍历,从bucket的哪个cell开始遍历。因此map不会有序。

将map中的key取出来进行排序,然后按照key的顺序进行取值即可有序的输出map。

疑问与解答

count的类型是uint8,范围只有0-255,如果map大于这个范围该如何进行表示呢?

答:因为看错类型了,count的类型为int,在64位机下,int的范围是2^64,根本用不完!

查找一个值时,前8位确定了tophash的位置。那么如何确定key的位置?key的位置是不是以下标进行存储的?

答案已经更新过,就在key定位处可以看到。

tophash[0]的位置存储迁移情况,那么它到底是存储值,还是记录更新状态?

这里我的理解是:因为map的迁移是两个两个进行的。因此在tophash[0] > mintophash时,说明他是存值状态,如果 < mintophash,说明他是处于记录状态,并且该bucket已经在更新过程中。

overflow以及hash冲突是如何体现的?

答:在map中key是唯一的,但是tophash不一定唯一。当相同的tophash在同一个bucket中出现时,就会发生hash冲突,继而创建新的bmap并通过overflow指向它。