文章目录
Map
Map通常称哈希表(Hash Table)、散列表等,是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做桶。哈希表是计算机科学中的最重要数据结构之一,这不仅因为它 𝑂(1) 的读写性能非常优秀,还因为它提供了键值之间的映射。想要实现一个性能优异的哈希表,需要注意两个关键点 —— 哈希函数和冲突解决方法。哈希表的实现主要需要解决两个问题,哈希函数和冲突解决。
在很多编程语言中都有哈希表的相关库的提供,比如C++,Java等,均在语言内部标准库中实现了哈希表相关数据结构的使用方式,而在Go语言中,哈希表则是一个内嵌类型。
哈希表设计及哈希碰撞解决方式
哈希函数
在哈希表实现过程中,哈希函数是一个很重要的部分,哈希函数选择的好坏很大程度的影响哈希表整体性能的好坏,实际上,哈希表是一个表结构,其在读写时,可以通过使用哈希函数对传入的Key进行哈希计算得出其哈希值,能够将不同Key映射到不同的索引上,一般来说,存储表结构的数据结构通常使用数组来做存储某些Key的哈希值对应的Value,通常存储这些Value的数组的每个位置,我们通常称其为桶。
较为理想的哈希函数实现的哈希表,对其任意元素的查找速度始终为𝑂(1) 。因为其每个Key所映射到的桶都是唯一的,即每个Key都有不同的桶的位置,但是这种情况非常难以实现因为,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。
那么当两个不同的Key经过哈希函数计算后所得到的桶的位置是相同的时候,就产生了哈希碰撞,要注意的是,两个不同的Key产生哈希碰撞不代表两个Key的哈希值完全相同,有可能只是部分相同,比如高X位或者低X位。
哈希碰撞
当产生哈希碰撞时,该如何解决呢?目前比较常见的就是开放地址法和链表法,接下来我们简要的了解一下这两种哈希冲突的解决办法。
开放地址法
开放地址法的基本思想是:当发生哈希碰撞时,按照某种方法继续探测哈希表中的其他存储单元,直到找到空位置为止。这个过程可用以下过程描述:
首先注明,哈希函数计算哈希索引的公式假若为:
i n d e x = H a s h F u n c t i o n ( K e y ) % L e n ( B u c k e t ) index=HashFunction(Key) \% Len(Bucket) index=HashFunction(Key)%Len(Bucket)
其中HashFunction(Key)为求出当前Key的哈希值,然后对当前桶的总数求余
- 发现发生哈希碰撞,此时计算出的index=1(只是举例)
- 此时发现index=1处有Value存储,则探测index+1处也就是index=2处
- 如果index=2处仍然有Value存储则继续往后探测,直到找到某个桶中不存在Value(写)或者找到目标元素(读)时或到桶的长度时结束
有上述过程可以看出,当哈希表越来越满时聚集越来越严重,这导致产生非常长的探测长度,后续的数据插入将会非常费时。
开放地址法对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 𝑂(𝑛)的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。
链表法
链表法的基本思想是:当发生哈希碰撞时,将该Key与其hash值相同的Key链接在同一个单链表中,因为一般来说这个链表的长度并不会太长(比如Go中这个链表长度取8),所以查询仍然可以按照𝑂(1)时间计算,链表法有一个很重要的变量,即装载因子 这个装载因子的大小会影响链表的性能,因此,当装载因子达到一定程度的时候就需要进行哈希表的扩容,装载因子的求出公式为:
装
载
因
子
=
哈
希
表
总
元
素
数
量
➗
哈
希
表
桶
的
数
量
装载因子=哈希表总元素数量 ➗ 哈希表桶的数量
装载因子=哈希表总元素数量➗哈希表桶的数量
Go中的Map
概述
Map是一种方便而强大的内置数据结构,它将一种类型的Key(键)与另一种类型的Value(元素或值)关联起来。Key可以是任何可以进行比较的类型,如整数、浮点和复数、字符串、指针、interface{}(但是前提是interface中的数据类型也必须是可以比较的)、结构和数组。和切片一样,Map 也持有对底层数据结构的引用。如果你把一个Map传给一个函数,该函数改变了Map的内容,那么这些改变将在调用者中可见。
数据结构
Map在Go中是按照一种内置数据结构的形式出现,在使用的时候只需要像使用切片那样即可:
func main() {
a := make(map[string]string,10)
a["test"]="test"
}
同时跟切片一样Map在Go语言底层也是一种结构体的实现,现在来看一下Map在Go中最底层的结构体样式以及相关字段功能:
type hmap struct {
// 当前map中元素数量 即len(map)返回的值。
count int
// 当前map所处状态标记,比如正在写入正在迭代等。
flags uint8
// 桶的数量的值,最终map创建的桶的数量为 2^B
// 另外在使用Key的哈希值选桶的时候
// 取的是该哈希值的低B位作为选桶的值
B uint8
// 当前溢出桶的数量
noverflow uint16
// hash种子,在创建该结构体的时候动态生成
hash0 uint32
// 指向第一个桶的指针 是一个连续的地址 因为是个数组
// 这里面存的是 *bmap
buckets unsafe.Pointer
// 旧桶第一个桶的指针,用于在扩容搬迁的时候未完成搬迁时保存之前的旧桶
oldbuckets unsafe.Pointer
// 搬迁桶的进度 就是处于扩容搬迁时,目前搬到哪了
nevacuate uintptr
// 溢出桶 当bmap中存储的数据过多
// 单个bmap已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。
// 溢出桶不一定会使用,因为他是个可选字段
extra *mapextra
}
type mapextra struct {
// 如果key和value都不包含指针,而且是内联的,那么我们就将bucket类型标记为不包含指针。
// 这样就避免了对这类地图的扫描。
// 然而,bmap.overflow 是一个指针。为了防止溢出桶被gc处理
// 我们在hmap.extra.overflow和hmap.extra.oldoverflow中存储所有溢出桶的指针。
// overflow和oldoverflow只在key和value不包含指针的情况下使用
overflow *[]*bmap
oldoverflow *[]*bmap
// 存储一个已经创建好了但是暂未使用的空bmap溢出桶
nextOverflow *bmap
}
// 实际存储k,v数据的桶
// 该结构体还有一些字段会在编译期添加
// 比如 下一个溢出桶的地址
// key,value所处的地址等
type bmap struct {
// 这里存储可 Key的哈希值的高8位 例子 11111111 00000000
// 用于在查找时快速的去判断当前Key是否存在这个桶里
// 因此得出,每个桶只能存放8个Key-Value映射
tophash [bucketCnt]uint8
}
关于Map的结构体,就是上述代码中所描述的一些信息,由此可以得出几个信息:
- Map的最大桶的数量为1<<255个,因为hmap.B为uint8类型,最大值为255.
- 桶的数量时1<<B个
- 每个bmap只能存放8对Key-Value,当这个bmap装满的时候就会使用hmap.extra.nextOverflow中已经创建好的空bmap作为溢出桶,如果不存在则创建一个溢出桶。
构建Map
看完了Map的数据结构,那么接下来看一下Map的构建方式以及构建过程之中到底发生了什么,首先Map的几种构建方式如下:
func main() {
a := map[int]int{
1: 2,
2: 3,
}
// let [a] alloc from heap
fmt.Println(a)
// let this map alloc alloc from stack
_ = map[int]int{
1: 2,
2: 3,
}
b := make(map[int]int, 10)
b[1] = 2
b[2] = 3
var c map[int]int
c[1]=2 // wrong! map is not init,it will panic
}
上面4种方式都是构建一个Map的方式,但是第4种方式只是声明了一个Map并未初始化,所以当对其赋值的时候会出现panic,那么这两种构建的方式区别在哪呢?遇事不决先看汇编。
构建方式1–小型Map堆分配
首先看第一个方式的汇编代码:
0x002f 00047 CALL runtime.makemap_small(SB)
0x0034 00052 MOVQ (SP), AX
0x0038 00056 MOVQ AX, "".a+72(SP)
0x003d 00061 MOVQ $1, ""..autotmp_4+56(SP)
首先不看别的只看第一行,00047 部分,此处调用了函数构建了一个新的map,此函数的实现代码如下:
func makemap_small() *hmap {
h := new(hmap)
h.hash0 = fastrand()
return h
}
短短的3行代码,很简单,runtime.makemap_small() 实现了在编译时已知元素数量,即Key-Value个数<=8或者make时明确len<=8,且Map需要在堆上分配时(因为我这个代码下面使用了fmt.Println()输出了a,所以在堆上分配了Map),为make(map[k]v)和make(map[k]v, hint)创建Map。
构建方式2–小型Map栈分配
接下来看一下第二种Map的字面量构建方式,这种方式是在栈上构建的,因为避免输出汇编时出现未使用变量错误所以忽略了变量名,接下来看一下汇编代码:
0x01e2 00482 CALL runtime.fastrand(SB)
0x01e7 00487 MOVQ ""..autotmp_14+312(SP), AX
还是只看第一行,其调用了runtime.fastrand(),这是为什么呢?因为在编译期间,当创建的Map被分配到栈上并且其Key-Value个数<=8或者make时明确len<=8时,Go 语言在编译阶段会使用runtime.fastrand()快速初始化哈希,这也是编译器对小容量的哈希做的优化,也就是说会生成类似下面的代码:
var h *hmap
var hv hmap
var bv bmap
h := &hv
b := &bv
h.buckets = b
h.hash0 = fashtrand0()
常规构建方式–Make的len>8或Key-Value个数>8
第三种方式是经常使用的一种初始化方式,即使用make函数进行初始化,这与切片的初始化方式相同,但是不同的是,使用make初始化Map只可以传递两个参数一个是类型一个是长度,而不能像切片一样传递一个容量。
接下来看一下第三种方式所产生的汇编代码:
0x04b8 01208 CALL runtime.makemap(SB)
从上述汇编可以看出,创建Map调用了runtime.makemap()这个函数不光只有在使用make方式时会调用,也会在不满足上面2种方式的时候进行调用,也就是说runtime.makemap()是一个构建Map最后调用的都是runtime.makemap(),接下来来详细讲解这个函数的逻辑:
// 传入的三个参数分别为
// 1.map的类型即 key和value的类型信息等其他数据
// 2.长度 即 make 传入的len
// 3.hmap结构体 可以为nil
// 返回值为经过处理的 *hmap
func makemap(t *maptype, hint int, h *hmap) *hmap {
// 校验一下需求的长度和类型占用字节数的乘积
// 是否超过内存限制
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
// 创建一个新的 hmap结构体
if h == nil {
h = new(hmap)
}
// 获取一个随机的哈希种子
h.hash0 = fastrand()
B := uint8(0)
// 通过输入的长度 算出一个合适的B值
for overLoadFactor(hint, B) {
B++
}
h.B = B
// 如果B不为0
if h.B != 0 {
var nextOverflow *bmap
// 调用makeBucketArray 返回一个溢出bmap和开辟完内存的桶的首地址指针
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
// 如果有溢出bmap
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
// 求出需要桶的个数 即返回 1<<B的值
base := bucketShift(b)
nbuckets := base
// 如果需要的桶的个数小于4个 那么就不需要创建溢出bmap
// 为的是防止有可能不需要bmap的时候却创建了bmap
// 使其降低资源开销
if b >= 4 {
// 创建的溢出bmap的数量是1<<(B-4)个
// 获得所需的桶与溢出bmap的数量
nbuckets += bucketShift(b - 4)
// 计算类型 占用字节数 * 所需的桶与溢出bmap的数量的乘积
sz := t.bucket.size * nbuckets
// 获取对应长度的内存块的大小
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}
// 如果之前没分配过
if dirtyalloc == nil {
// 分配一个nbuckets 长度的bmap数组 返回首指针
// 正常情况下桶和溢出bmap是连续的
// 但是当溢出bmap过多的时候每次通过runtime.newobject申请的bmap
// 与最初创建的不一定是连续的了
buckets = newarray(t.bucket, int(nbuckets))
} else {
// 这块是用来清空已经分配的桶的内存的逻辑
buckets = dirtyalloc
size := t.bucket.size * nbuckets
if t.bucket.ptrdata != 0 {
memclrHasPointers(buckets, size)
} else {
memclrNoHeapPointers(buckets, size)
}
}
// 如果创建了溢出bmap
if base != nbuckets {
// 如果有溢出桶
// 就把溢出bmap设置为申请的bmap数组的 nbuckets-base处的那个当作空闲溢出bmap
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
// 然后把剩下那个 即最后那个bmap(应该是)做一下标记
// 这个地方有疑,官方的意思是
// 我们预先分配了一些溢出桶。为了将跟踪这些溢出桶的开销降到最低
// 我们使用的惯例是,如果一个预分配溢出桶的溢出 指针为nil
// 那么就可以通过bumping指针来获得更多的可用指针。
// 个人理解 就是给申请好了的溢出bmap 但是还不是nextOverflow的溢出bmap
// 加个类似标记的东西,方便下次需要nextOverflow时但 nextOverflow ==nil 时
// 直接通过某种方式快速查找使用
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow
}
总结
至此讲完了关于构建Map的方式,接下来做个总结:
- 无论是字面量初始化还是make初始化,当所需Map分配到堆上且所需长度<=8时,使用runtime.makemap_small()初始化。
- 无论是字面量初始化还是make初始化,当所需Map分配到不需要分配到堆上且所需长度<=8时,通过快速哈希方式创建。
- 其余情况会调用runtime.makemap(),该函数的执行过程如下:
- 校验是否内存溢出
- 获得随机哈希种子
- 计算传入长度所需的B值,这个值是最小值,即最小需要1<<B个bmap
- 如果B<4则不创建溢出bmap ,为的是节省资源,否则创建1<<(B-4)个溢出bmap
- 然后创建一个所需长度的连续的bmap数组,并且返回头指针给hmap.buckets。
- 设置溢出bmap(如果有)的一些信息。
- 返回*hmap,也就是说 make(map)返回的是一个hmap的指针。
Map的读写
Map的写入/删除
在构建完Map之后,可以向Map中进行读写和删除数据了,因为删除算是写的一种,所以本章会将Map的删除和写入放到一起来讲,常见的Map的写入删除方式如下:
func main() {
m := make(map[int]int, 10)
m[1] = 1
m[2] = 2
delete(m,1)// normal delete Key == 1
delete(m,3) // not found Key == 3
}
类似m[1]=1这种就是对Map的写入,即将Key设置为1并且对应的Value也是1,在写入时,如果这个Key存在于当前Map时,其目前的值就会被覆盖掉,如果不存在则会直接写入
后面的delete(m,1)是一个内建函数,用于删除Map中的某个Key及其对应的值,第一个参数传递的是目标Map,第二个是Key的值,这个函数不会返回任何参数,并且当要删除的Key不存在时,也不会出现任何问题。
Map的写入解析
现在来解析一下关于Map的写入的解析,首先还是根据上面的代码去输出汇编代码看看写入的时候到底发生了什么?
0x00af 00175 CALL runtime.mapassign_fast64(SB)
上述汇编代码看出,再向Map中写入数据的时候调用了runtime.mapassign_fast64()函数,但是作者并没有在runtime.map.go文件中找到相对应的函数,根据网上其他一些文章得知,最终的写入时的函数调用都会调用到runtime.mapassign()中,那么,接下来我们就开始讲一下关于runtime.mapassign()函数的一些逻辑的代码解析:
// 该函数传入的参数分别为
// 1.map的类型即 key和value的类型信息等其他数据
// 2.那个map
// 3.key所在内存的地址
// 返回的参数为
// 1.找到的可用的可存储value的内存地址
// 返回后具体的赋值操作由汇编完成
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 不能往nil map中进行写入
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
// 校验标记为是否存在并发写状态
// 如果是则panic
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
// 求出这个key的哈希值
hash := t.hasher(key, uintptr(h.hash0))
// 设置写标记,避免并发写
h.flags ^= hashWriting
// 如果当前么得可用的桶
// 那就创建一个bmap用于装数据
// 调用 makemap_small 创建map的时候可能会出现此问题
if h.buckets == nil {
h.buckets = newobject(t.bucket)
}
again:
// 算出这个key应该落到哪个bmap里
bucket := hash & bucketMask(h.B)
// 判断是否处于搬迁桶的状态
// 如果处于该状态则进行旧桶 -> 新桶的搬迁
if h.growing() {
growWork(t, h, bucket)
}
// 通过位移操作获得 bucket := hash & bucketMask(h.B) 这个桶的对象
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
// 计算出key的哈希值之后的高8位哈希值
top := tophash(hash)
// 元素在bmap中tophash数组的地址
var inserti *uint8
// key的地址
var insertk unsafe.Pointer
// value 的地址
var elem unsafe.Pointer
bucketloop:
for {
// 遍历当前选中的bmap
for i := uintptr(0); i < bucketCnt; i++ {
// 对比高8位,如果不等于
if b.tophash[i] != top {
// 如果这个tophash为nil且当前索引=nil
// 那么就直接用这个位置
// 这个操作是插入且不存在扩容时的操作
if isEmpty(b.tophash[i]) && inserti == nil {
// 设置元素在bmap中tophash数组的地址
inserti = &b.tophash[i]
// 通过位移找到对应的可用的key的地址位置
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
// 通过位移找到对应的可用的value的地址位置
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
}
// 如果 b.tophash[i] == emptyRest
// 代表后续的b.tophash没有 溢出bmap或者可遍历的b.tophash了
if b.tophash[i] == emptyRest {
// 跳出循环
break bucketloop
}
continue
}
// 偏移到到对应的key的位置
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
// 传入的key的值和当前k处存在的key的值比较一下是否相等
// 如果不相等代表没找到
if !t.key.equal(key, k) {
continue
}
// 如果相等那么就代表是更新操作
// 先把传入的key的赋值到k处
if t.needkeyupdate() {
typedmemmove(t.key, k, key)
}
// 计算出k对应value的地址
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
// 直接跳转到结束
goto done
}
// 获取溢出bmap
// 代表在当前bmap里面么得找到可用的地址
ovf := b.overflow(t)
// 没有溢出bmap就跳出
if ovf == nil {
break
}
// 继续循环
b = ovf
}
// 如果没找到可用的插槽,那么就创建一个
// 判断是否需要扩容
// 扩容条件为:
// 1.当装载因子>=6.5
// 2.溢出bmap过多
// 第二种情况只会出现在先大量的写然后再大量删除的情况
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
// 扩容
hashGrow(t, h)
// 重新找
goto again
}
// 如果在当前的bmap及其溢出的bmap都没找到合适的地方
// 即 所有的bmap都满了
// 那就创建一个新的溢出bmap
if inserti == nil {
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
// 将新key/elem存储在插入位置
if t.indirectkey() {
kmem := newobject(t.key)
*(*unsafe.Pointer)(insertk) = kmem
insertk = kmem
}
if t.indirectelem() {
vmem := newobject(t.elem)
*(*unsafe.Pointer)(elem) = vmem
}
// 把插入的key的值写入到对应的key的地址里
typedmemmove(t.key, insertk, key)
// 把插入的key的高8值写入到对应的tophash的地址里
*inserti = top
// 当前元素个数+1
h.count++
done:
// 再次校验并发写
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
// 取消写标记
h.flags &^= hashWriting
if t.indirectelem() {
elem = *((*unsafe.Pointer)(elem))
}
// 返回可用的value 存放地址
// 随后的赋值操作会在汇编中进行
return elem
}
Map写入操作过程的总结
- 首先会计算出传入Key的哈希,其低B位用于选择对应的bmap,其哈希值的高8位用于在对应的bmap中快速的查找是否有此key的存在,并且对该map打上写标记,防止并发读写。
- 遍历找到的bmap及其溢出bmap,并按照下面的条件执行
- 如果比较到当前的tophash不等于传入key的tophash,且未找到可用tophash地址时,如果当前tophash无已存放的key,那么记录下这个tophash的地址和其对应的k,v的地址,此条件为插入操作。
- 如果当前tophash是emptyRest,则代表后续没有溢出bmap或者可遍历的tophash了
- 如果当前的tophash==传入key的tophash,则会对比传入的Key以及tophash对应的Key的地址的值是否相等,如果相等则代表已经存在了当前传入的Key,则此操作为更新操作,此时计算出对应value的存放地址
- 如果上述条件都不满足则遍历溢出bmap(如果有)
- 如果在bmap及其溢出bmap中都没有找到可用的存放地址,则创建一个新的溢出bmap,因为没找到说明没有可用的tophash位置了,即所有的bmap都满了。
- 如果符合下列扩容条件就会产生扩容,扩容完毕后重新开始 序号2 的过程。
- 当装载因子>=6.5
- 溢出bmap过多,该情况只会出现在先大量的写然后再大量删除但装载因子并没有>=6.5的情况
- 如果不需要扩容则创建一个新的bmap,给传入Key的哈希,其低B位用于选择对应的bmap的溢出bmap链表尾部,然后获得可用的tophash,key,value地址。然后取消写标记,返回可用的value 存放地址 , 随后的赋值操作会在汇编中进行
Map的扩容
在刚才的上文说了,写入的时候如果在bmap及其溢出bmap中都没有找到可用的存放地址,则创建一个新的溢出bmap,因为没找到说明没有可用的tophash位置了,即所有的bmap都满了。那么我们接下来看一下这个关于扩容函数hashGrow()的源代码解析
func hashGrow(t *maptype, h *hmap) {
// 需要扩容多少bmap
// 默认为翻倍扩容
// 此情况为装载因子>=6.5
bigger := uint8(1)
if !overLoadFactor(h.count+1, h.B) {
// 如果是因为溢出bmap过多导致扩容
// 则不会增加新的bmap
// 此情况为等量扩容(整理)
bigger = 0
h.flags |= sameSizeGrow
}
// 把原来的bmap列表放到旧bmap字段里
oldbuckets := h.buckets
// 申请新的翻倍长度的bmap 因为bmap的个数为1<<B个所以当B+1时就会增加一倍
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
// 判断当前状态
flags := h.flags &^ (iterator | oldIterator)
// 如果当前状态不是迭代状态
if h.flags&iterator != 0 {
// 让迭代的时候去迭代旧桶
// 因为还没有搬迁完毕
flags |= oldIterator
}
// 对hmap做赋值操作
h.B += bigger
h.flags = flags
h.oldbuckets = oldbuckets
h.buckets = newbuckets
h.nevacuate = 0
h.noverflow = 0
// 如果之前有溢出bmap的话,把原来所持有的溢出bmap也变成旧的溢出bmap
if h.extra != nil && h.extra.overflow != nil {
if h.extra.oldoverflow != nil {
throw("oldoverflow is not nil")
}
h.extra.oldoverflow = h.extra.overflow
h.extra.overflow = nil
}
// 如果扩容的新bmap列表存在可用溢出bmap
// 那就设置一下
if nextOverflow != nil {
if h.extra == nil {
h.extra = new(mapextra)
}
h.extra.nextOverflow = nextOverflow
}
// 此处没有发现扩容时的数据搬迁(从旧bmap中拷贝原数据到新的bmap中)工作
// 因为哈希表数据的实际复制是由growWork()和evacuate()逐步完成的。
// 这两个函数的触发时机是在写/删除操作时触发
}
func growWork(t *maptype, h *hmap, bucket uintptr) {
// 计算一下当前的Key的hash出来的桶的索引在旧桶的哪个位置
evacuate(t, h, bucket&h.oldbucketmask())
// 如果搬完了一个之后发现还没搬迁完
// 就再搬一个
if h.growing() {
evacuate(t, h, h.nevacuate)
}
}
// 用于保存分配上下文的结构体
type evacDst struct {
b *bmap // 当前目标存储桶
i int // 键/元素索引到b
k unsafe.Pointer // 指向当前key的指针
e unsafe.Pointer // 指向当前value的指针
}
// 实际的旧bmap 搬迁到新bmap的逻辑
// 第三个参数是搬迁的那个桶
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
// 找到要搬迁的桶
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
// 算一下之前有多少个桶
newbit := h.noldbuckets()
// 判断是不是没有东西的bmap
if !evacuated(b) {
// 生成两个用于保存新桶上下文的结构体
var xy [2]evacDst
// 先保存一个 新bmap的上下文信息
x := &xy[0]
x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
x.k = add(unsafe.Pointer(x.b), dataOffset)
x.e = add(x.k, bucketCnt*uintptr(t.keysize))
// 如果当前不是等量扩容(整理)
// 那么就搬两个桶
// 再获取一个新的bmap的上下文信息
if !h.sameSizeGrow() {
y := &xy[1]
y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
y.k = add(unsafe.Pointer(y.b), dataOffset)
y.e = add(y.k, bucketCnt*uintptr(t.keysize))
}
// 循环当前需要搬迁的bmap及其溢出bmap
for ; b != nil; b = b.overflow(t) {
// 找到第一个key和对应的value的地址
k := add(unsafe.Pointer(b), dataOffset)
e := add(k, bucketCnt*uintptr(t.keysize))
// 遍历整个当前bmap的key和value
for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) {
// 获取当前key value对应的tophash
top := b.tophash[i]
// 如果是空
if isEmpty(top) {
// 做个标记 代表这个tophash已经搬走了
b.tophash[i] = evacuatedEmpty
continue
}
// 有问题
if top < minTopHash {
throw("bad map state")
}
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
var useY uint8
// 如果是翻倍扩容
if !h.sameSizeGrow() {
// 那么就需要重新计算当前这个key应该落到xy[0]还是xy[1]这两个新桶的哪个中去
// 因为等量扩容是整理,桶的数量并没有增加,所以原来在哪还是在哪
// 但是翻倍扩容则是增加了新的桶,就需要重新计算hash
// 也就是说翻倍扩容可能导致当前key的哈希值和之后的不是同一个
hash := t.hasher(k2, uintptr(h.hash0))
// 这个是计算到底该把当前的key-value 放到xy[0]还是xy[1]
if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
useY = top & 1
top = tophash(hash)
} else {
if hash&newbit != 0 {
useY = 1
}
}
}
if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
throw("bad evacuatedN")
}
// 开始
b.tophash[i] = evacuatedX + useY
dst := &xy[useY]
if dst.i == bucketCnt {
dst.b = h.newoverflow(t, dst.b)
dst.i = 0
dst.k = add(unsafe.Pointer(dst.b), dataOffset)
dst.e = add(dst.k, bucketCnt*uintptr(t.keysize))
}
dst.b.tophash[dst.i&(bucketCnt-1)] = top
if t.indirectkey() {
*(*unsafe.Pointer)(dst.k) = k2
} else {
typedmemmove(t.key, dst.k, k)
}
if t.indirectelem() {
*(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e)
} else {
typedmemmove(t.elem, dst.e, e)
}
dst.i++
dst.k = add(dst.k, uintptr(t.keysize))
dst.e = add(dst.e, uintptr(t.elemsize))
// 结束
// 从开始到结束这一块的代码大概意思就是找到搬到的目标是xy[0]还是xy[1]
// 如果是等量扩容则默认放到xy[0]桶里
// 然后如果不是等量扩容,可能会重新计算该key的tophash的值
// 然后把key value,和tophash 复制到xy[0]或xy[1]的对应位置
// 然后继续循环直到整个bmap及其溢出bmap被搬迁完
}
}
// 把搬完了的旧桶做一些标记以便等待gc清除
if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
ptr := add(b, dataOffset)
n := uintptr(t.bucketsize) - dataOffset
memclrHasPointers(ptr, n)
}
}
// 如果此次搬迁是按照 h.nevacuate的搬迁则
// 做个标记,代表下一次要搬迁的桶是哪个
if oldbucket == h.nevacuate {
advanceEvacuationMark(h, t, newbit)
}
}
Map扩容过程的总结
- 当装载因子>=6.5或者溢出bmap过多时,会产生扩容
- 如果是装载因子>=6.5的扩容则为翻倍扩容,创建一个比当前桶个数*2的桶列表,如果溢出桶过多但装载因子<6.5时为等量扩容,即把当前稀疏的桶内元素进行整理,使其没有那么多的溢出桶。
- 然后会把当前的桶放到hmap.oldbuckets字段作为旧桶,然后把当前的溢出桶(如果有)也放到hmap.extra.oldoverflow作为旧的溢出桶。
- 在扩容时不会触发搬迁操作,搬迁操作只会在写入/删除操作时被触发。
- 搬迁操作过程:
- 翻倍扩容搬迁过程:
- 找到当前key的hash对应的旧桶然后算出对应的新桶保存其上下文xy[0],再找到一个根据当前新桶+旧桶数量偏移的哪个桶xy[1],也保存其上下文。比如当前key的哈希对应的旧桶是3号,那么xy[0]也是新桶的3号桶,然后xy[1]的桶号就为3+旧桶的个数、比如7号。
- 由于翻倍扩容时当前旧桶对应两个新桶,所以需要针对当前旧桶中的key做重新hash,选择一个目标桶出来,即当前key处于3号桶,经过重新哈希后可能放到了7号桶,找到目标桶后,将对应的key,value,tophash复制过去,完成一个key-value的搬迁。然后循环此过程。
- 等量扩容搬迁过程:
- 与翻倍扩容相同,但只保存一个新桶的上下文,因为等量扩容没有创建更多的桶,扩容是对应的关系,即当前的key处于3号桶,搬迁后也属于3号桶。
- 剩余过程与翻倍扩容相同。
- 搬迁完毕后,将旧桶做一些标记以便可以gc清除,然后如果此次搬迁是按照顺序搬迁,即 h.nevacuate记录的桶搬迁的,则更新 h.nevacuate到下次应该搬迁的桶上。
- 翻倍扩容搬迁过程:
Map的删除解析
接下来来解析一下关于Map的删除,首先还是根据上面的代码去输出汇编代码看看删除的时候到底发生了什么?
0x0108 00264 CALL runtime.mapdelete_fast64(SB)
上述汇编代码看出,再向Map中删除数据的时候调用了runtime.mapdelete_fast64()函数,但是作者并没有在runtime.map.go文件中找到相对应的函数,根据网上其他一些文章得知,最终的写入时的函数调用都会调用到runtime.mapdelete()中,那么,接下来我们就开始讲一下关于runtime.mapdelete()函数的一些逻辑的代码解析:
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
// 如果map是nil或者没有元素则直接返回
if h == nil || h.count == 0 {
// 好像是针对类型的错误校验
// 具体可以看issue 23734
if t.hashMightPanic() {
t.hasher(key, 0)
}
return
}
// 不能并发写!
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
// 算出key的对应的hash
hash := t.hasher(key, uintptr(h.hash0))
// 做写标记
h.flags ^= hashWriting
// 选 桶
bucket := hash & bucketMask(h.B)
// 如果正在进行扩容
// 那就进行搬迁
if h.growing() {
growWork(t, h, bucket)
}
// 找到那个bmap
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
bOrig := b
// 算出tophash
top := tophash(hash)
search:
// 循环遍历选中的那个桶及其溢出桶
for ; b != nil; b = b.overflow(t) {
// 遍历每个溢出桶的tophash进行快速比对
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
// 如果 b.tophash[i] == emptyRest
// 代表后续的b.tophash没有 溢出bmap或者可遍历的b.tophash了
if b.tophash[i] == emptyRest {
break search
}
continue
}
// 有相等的tophash
// 就算出对应k的位置
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
// 比对key和要删除的key是否相等
if !t.key.equal(key, k2) {
continue
}
// 如果key是个指针 进行清空操作
if t.indirectkey() {
*(*unsafe.Pointer)(k) = nil
} else if t.key.ptrdata != 0 {
memclrHasPointers(k, t.key.size)
}
// 算出key对应的value的地址
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
// 跟key一样如果是指针就清空它
if t.indirectelem() {
*(*unsafe.Pointer)(e) = nil
} else if t.elem.ptrdata != 0 {
memclrHasPointers(e, t.elem.size)
} else {
memclrNoHeapPointers(e, t.elem.size)
}
// 标记这个tophash 是一个空的
b.tophash[i] = emptyOne
// 开始
if i == bucketCnt-1 {
if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
goto notLast
}
} else {
if b.tophash[i+1] != emptyRest {
goto notLast
}
}
for {
b.tophash[i] = emptyRest
if i == 0 {
if b == bOrig {
break
}
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
i = bucketCnt - 1
} else {
i--
}
if b.tophash[i] != emptyOne {
break
}
}
// 结束
// 从开始到结束的意义是
// 如果当前桶现在以一堆 emptyOne 状态结束,就把这些状态改为 emptyRest 状态。
notLast:
// 当前元素数量-1
h.count--
break search
}
}
// 再次校验是否并发写
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
// 取消写标记
h.flags &^= hashWriting
}
Map删除过程的总结
总的来说删除过程和写入过程没啥区别,只是触发哈希的删除需要使用关键字不同,如果在删除期间遇到了哈希表的扩容,就会分流桶中的元素,分流结束之后会找到桶中的目标元素完成键值对的删除工作。
Map的读取
说完了写入和删除操作,接下来该说一下Map的读取操作了,读取操作再Go中分为三种形式,接下来看一下代码示例:
func main() {
m := map[int]int{
1: 1,
2: 2,
3: 3,
}
value := m[1]
value, ok := m[2]
for key, value := range m {
fmt.Println(key, value)
}
}
首先初始化了一个哈希表m,然后对其进行读取值,我们先来看看前两种,因为其实际的代码都差不多,首先第二个是返回了两个值,第二个值(即ok)代表当没有找到这个key的时候返回false,否则返回true。
接下来看一下这两种形式在汇编上有什么区别:
0x01e0 00480 CALL runtime.mapaccess1_fast64(SB)
0x0213 00531 CALL runtime.mapaccess2_fast64(SB)
上面的汇编依旧不同,但是区别也很小,一个是runtime.mapaccess1另一个是runtime.mapaccess2,有啥区别呢?在源代码里发现,这两个函数的代码实现没有区别,区别在于runtime.mapaccess2多返回了一个bool值用于标记是否查询到了数据,那么接下来我们就对runtime.mapaccess2就行代码解析:
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
// 如果map是nil或者没有元素则
// 返回对应类型的零值和false
if h == nil || h.count == 0 {
// 同写入
if t.hashMightPanic() {
t.hasher(key, 0)
}
return unsafe.Pointer(&zeroVal[0]), false
}
// 检查是否有正在写入
// 不能并发写
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
// 算出哈希
hash := t.hasher(key, uintptr(h.hash0))
m := bucketMask(h.B)
// 找到哈希的低B位的对应的桶
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))
// 如果此时处于扩容状态
if c := h.oldbuckets; c != nil {
// 那么就去旧桶找
// 如果此时是翻倍扩容
if !h.sameSizeGrow() {
// 因为旧桶是当前桶的一半所以需要把当前的桶/2
m >>= 1
}
// 找到在旧桶里面对应的那个桶
oldb := (*bmap)(unsafe.Pointer(uintptr(c) + (hash&m)*uintptr(t.bucketsize)))
// 如果这桶没被搬走
// 那就遍历这个旧桶
if !evacuated(oldb) {
b = oldb
}
}
// 算出高八位
top := tophash(hash)
bucketloop:
// 遍历桶和其溢出桶
for ; b != nil; b = b.overflow(t) {
// 遍历tophash
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
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) {
// 算出key对应的value
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
// 返回!
return e, true
}
}
}
//到这了就说明没找到
// 返回对应类型的零值和false
return unsafe.Pointer(&zeroVal[0]), false
}
Map读取的总结
其实map的读取没什么可说的大概就是以下几步:
- nil的Map或者len==0的Map直接返回对应类型零值
- 如果当前处于翻倍扩容状态且当前key的hash对应的桶还没搬迁完,那么就在旧桶里找对应的那个桶去找对应的key-value
- 跟写入一样 遍历桶及其溢出桶
- 找到了返回值,没找到返回返回对应类型零值
总结
经过上面的一堆长篇大论,Map的相关东西都已经大概齐的讲完了,还差Map的遍历和清空,但是这两个我打算在未来某天写Range的时候去讲(挖坑,啥时候填不一定),反正大概齐就是这么回事,目前Map主要的坑还是在并发读写的问题上,源代码中也能看得出,对并发读写都做了panic处理,那么如何避免并发读写或者使得Map可以并发的使用,请看我的这篇文章 《「Golang」并发场景下的Map使用方式及避坑指南》
由于我这人不喜欢审稿,写完了就写完了,上面的一些语句或者词汇可能并不是很通顺或者有语病,请各位指出,在此感谢各位,同时也感谢各位能够阅读本篇文章,如果能带给你一些收获是我的荣幸,同时希望能指出文章中的一些问题,或者各位看官的一些见解,让我们一同学习,再次感谢各位!
我已开通自己的公众号【Echo的技术笔记】
日后的文章发布会主要在公众号上发布
希望各位关注一下
谢谢大家啦