Golang原理剖析(map)

文章目录

map

map是什么

map就是一个key/value键值对的集合,可以根据key在O(1)的时间复杂度内取到value,有点类似我们常用的数组或者切片结构,可以把数组看作是一种特殊的map,数组的key为数组的下标,而map的key可以为任意的可比较结构。在map中key不允许重复且要能够比较。

在go语言中,map的底层采用hash表,用变种拉链法来解决hash冲突问题

哈希冲突

哈希表的原理是将多个k-v键值对散列的存储在buckets中,buckets可以理解为一个连续的数组,所以给定一个key/value键值对,我们要将其存储到合适的位置需要经过两步:

  1. 计算hash值: hash = hashFunc(key)
  2. 计算索引位置: index = hash % len(buckets)

第一步是根据hash函数将key转化为一个hash值

第二步用hash值对桶的数量取模得到一个索引值,这样就得到了我们要插入的键值对的位置。

但是这里会出现一个问题,比如我们有两个键值对 key1/value1 和 key2/value2,经过哈希函数 hashFunc的计算得到的哈希值 hash1 和 hash2 相同,那么这两个hash值的索引也必然相同,那将会存放到同一个位置,那这样怎么处理呢?丢弃后来的键值对?或者是覆盖之前的键值对?

但是这都是不可以的,因为key1和key2是不同的。他们就是两个不同的键值对,理论上都应该被存储,那应该怎么存储呢?这就是我们所说的哈希碰撞问题。

解决哈希碰撞一般有两种方式:拉链法和开放寻址法

拉链法

拉链法是一种最常见的解决哈希冲突的方法,很多语言都是用拉链法哈希冲突。拉链法的主要实现是底层不直接使用连续数组来直接存储数据元素,而是使用通过数组和链表组合连使用,数组里存储的其实是一个指针,指向一个链表。当出现两个 key 比如 key1 和 key2 的哈希值相同的情况,就将数据链接到链表上,如果没有发现有冲突的 key,显然链表上就只有一个元素。拉链法处理冲突简单,可以动态的申请内存,删除增加节点都很方便,当冲突严重,链表长度过长的时候也支持更多的优化策略,比如用红黑树代替链表

拉链法结构如下图:

左边为一个连续数组,数组每个元素存储一个指针,指向一个链表,链表里每个节点存储的是发生hash冲突的数据

开放地址法

开放地址法是另外一种非常常用的解决哈希冲突的策略,与拉链法不同,开放地址法是将具体的数据元素存储在数组中,在要插入新元素时,先根据哈希函数算出hash值,根据hash值计算索引i,如果发现冲突了,计算出的数组索引位置已经有数据了,就继续向后探测,直到找到未使用的数据槽为止。哈希函数可以简单地理解为:

hash(key)=(hash1(key)+i)%len(buckets)

开放地址法结构如下图

在存储键值对 <b,101> 的时候,经过hash计算,发现原本应该存放在数组下标为2的位置已经有值了,存放了 <a,100>,就继续向后探测,发现数组下标为3的位置是空槽,未被使用,就将 <b,101> 存放在这个位置。

go语言map的底层结构

go语言中的map其实就是一个指向 hmap 的指针,占用8个字节。所以map底层结构就是 hmap,hmap 包含多个结构为 bmap 的 bucket 数组当发生冲突的时候,会到正常桶里面的overflow指针所指向的溢出桶里面去找 ,Go语言中溢出桶也是一个动态数组形式,它是根据需要动态创建的。Go语言中处理冲突其实是采用了优化的拉链法,链表中每个节点存储的不是一个键值对,而是8个键值对。其整体的结构如下图:

看一下hmap的结构体定义:

go 复制代码
// 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)
	clearSeq   uint64

	extra *mapextra // optional fields
}

字段含义:


mapextra 结构定义如下:

go 复制代码
// 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
}


hmap 中真正用于存储数据的是 buckets 指向的这个 bmap (桶)数组,每一个 bmap 都能存储 8 个键值对,当 map 中的数据过多,bmap 数组存不下的时候就会存储到extra指向的溢出bucket(桶)里面

下面看一下bmap的结构定义

go 复制代码
type bmap struct {
    tophash  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    overflow uintptr
}

上图就是 bucket 的内存模型,HOB Hash 指的就是 top hash。 注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式。源码里说明这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间。

例如,有这样一个类型的 map:

go 复制代码
map[int64]int8

如果按照 key/value/key/value/... 这样的模式存储,那在每一个 key/value 对之后都要额外 padding 7 个字节;为什么是7字节padding

key 是 int64:占 8 字节,而且通常要求起始地址是 8 的倍数(8-byte aligned)

value 是 int8:占 1 字节,对齐要求是 1 字节

而将所有的 key,value 分别绑定到一起,这种形式 key/key/.../value/value/...,则只需要在最后添加 padding。

每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 overflow 指针连接起来。


bmap 里的 overflow 本质上就是"拉链法(separate chaining)" 的一种实现形式------但它是 Go map 特有的"桶数组 + 溢出桶链"的混合设计,不是教科书里那种"每个槽一个链表节点"的纯拉链。

bmap 就是我们常说的"桶",桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是"一类"的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。

来一个整体的图:

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

tophash

是一个长度为8的数组,它不仅仅用来存放key的哈希高8位,在不同场景下它还可以标记迁移状态,bucket是否为空等。弄懂tophash对我们深入了解map实现非常重要

如上图:每一个tophash唯一对应一个K/V对

当tophash对应的K/V被使用时,存的是key的哈希值的高8位;当tophash对应的K/V未被使用时,存的是K/V对应位置的状态。

当tophash[i] < 5时,表示存的是状态;

当tophash[i] >= 5时,表示存的是哈希值;

go语言的 map 会根据每一个key计算出一个hash值,有意思的是,对这个hash值的使用,go语言并不是一次性使用的,而是分开使用的,在使用中,把求得的这个hash值按照用途一分为二:高位和低位

tophash = 桶内 8 个槽位的 1 字节哈希指纹 + 状态标记
目的:加速查找/插入,减少昂贵的 key 比较次数,并支持快速判断空槽/终止条件。

这里的高8位是二进制的高8位,不是转成10进制之后的

假设我们对一个 key 做 hash 计算得到了一个hash值如图所示,蓝色就是这个hash值的高8位,红色就是这个hash值的低8位。而每个bmap中其实存储的就是8个这个蓝色的数字。

通过上图map的底层结构图我们可以看到,bmap的结构,bmap显示存储了8个 tohash 值,然后存储了8个键值对 ,注意,这8个键值对并不是按照 key/value 这样 key 和 value 放在一起存储的,而是先连续存储完8个 key,之后再连续存储8个 value 这样,当键值对不够8个时,对应位置就留空。这样存储的好处是可以消除字节对齐带来的空间浪费。

上图中,假定 B = 5,所以 bucket 总数就是 2^5 = 32。首先计算出待查找 key 的哈希,使用低 5 位 00110,找到对应的 6 号 bucket,使用高 8 位 10010111,对应十进制 151,在 6 号 bucket 中寻找 tophash 值(HOB hash)为 151 的 key,找到了 2 号槽位,这样整个查找过程就结束了。

如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。

Go 语言 map 的内存结构与 GC(垃圾回收)优化机制

key/value 都无指针 ⇒ 桶里除了 overflow 指针以外基本没有 GC 关心的东西

为了让桶能按 noscan 快速处理,runtime 把 overflow 桶地址集中放到 mapextra 的切片里

GC 扫描切片就能保活 overflow 桶 ⇒ 不用扫描每个 bmap 的 overflow 指针链

因此 GC 可以"更粗粒度"地直接标记桶为存活(黑色),减少扫描成本

让 bmap 变成 noscan,从而 GC 不必逐桶扫描;overflow 的可达性改由 hmap.extra 里的指针切片来保证

overflow *[]*bmap,一个"可选的清单"的指针,清单里装的是:所有 overflow 桶(*bmap)的地址。[]*bmap:很多个桶地址组成的列表(切片)

*[]*bmap:这个列表本身也放在某个地方;overflow 保存的是"指向这个列表的地址"







map的访问原理

对map的访问有两种方式:

go 复制代码
v := map[key]      // 当map中没有对应的key时,会返回value对应类型的零值
y, ok := map[key]  // 当map中没有对应的key时,除了会返回value对应类型的零值,还会返回一个值存不存在的布尔值

虽然这两种方法在返回值上很接近,后者只是多出了一个key存不存在的布尔值,但是在运行时调用的方法却不一样。

大致原理如下图所示:

这里梳理一下步骤:

  1. 判断map是否为空或者无数据,若为空或者无数据返回对应的空值

  2. map写检测,如果正处于写状态,表示此时不能进行操作,报fatal error

  3. 计算出hash值和掩码

  4. 判断当前map是否处于扩容状态,如果在扩容执行下面步骤:

    • 根据状态位算判断当前桶是否被迁移
    • 如果迁移,在新桶中查找
    • 未被迁移,在旧桶中查找
    • 根据掩码找到的位置
  5. 依次遍历桶以及溢出桶来查找key

    • 遍历桶内的8个槽位
    • 比较该槽位的tophash和当前key的tophash是否相等
      • 相同,继续比较key是否相同,相同则直接返回对应value
      • 不相同,查看这个槽位的状态位是否为"后继空状态"
        • 是,key在以后的槽中也没有,这个key不存在,直接返回零值
        • 否,遍历下一个槽位
  6. 当前桶没有找到,则遍历溢出桶,用同样的方式查找

先比 tophash/topbit 是用 1 字节的哈希指纹做廉价过滤,避免对 8 个槽位都做昂贵的 key 读取与比较,并减少 cache miss;只有极少数槽位指纹相同才需要真正比 key。

go 复制代码
func evacuated(b *bmap) bool {
	h := b.tophash[0]
	return h > emptyOne && h < minTopHash
}

Go runtime 的 map 扩容(grow)过程中判断:某个旧桶 b(old bucket)是不是已经"迁移/搬空(evacuate)"过了。

map的赋值原理

map的赋值操作很简单

go 复制代码
map[key] = value

原map中存在 key,则更新对应的值为 value,若 map 中不存在 key 时,则插入键值对 key/value。

但是有两点需要注意:

1. 在对map进行赋值操作的时候,map一定要先进行初始化,否则会panic

go 复制代码
var m map[int]int
m[1] = 1

m只是做了声明为一个map,并未初始化,所以程序会panic

2. map是非线程安全的,不支持并发读写操作。当有其他线程正在读写map时,执行map的赋值会报为并发读写错误

go 复制代码
package main

import (
	"fmt"
)

func main() {
	m := make(map[int]int)
	go func() {
		for {
			m[1] = 1
		}
	}()

	go func() {
		for {
			v := m[1]
			fmt.Printf("v=%d\n", v)
		}
	}()
	select {}
}

运行结果:

go 复制代码
root@GoLang:~/proj/goforjob# go run main.go 
v=0
v=0
v=0
v=0
v=0
v=0
fatal error: concurrent map read and map write

goroutine 8 [running]:
internal/runtime/maps.fatal({0x4c36fe?, 0xc0001021a0?})
        /usr/local/go/src/runtime/panic.go:1046 +0x18
main.main.func2()
        /root/proj/goforjob/main.go:17 +0x28
created by main.main in goroutine 1
        /root/proj/goforjob/main.go:15 +0x76

goroutine 1 [select (no cases)]:
main.main()
        /root/proj/goforjob/main.go:21 +0x7b

goroutine 7 [runnable]:
main.main.func1()
        /root/proj/goforjob/main.go:11 +0x28
created by main.main in goroutine 1
        /root/proj/goforjob/main.go:9 +0x49
exit status 2
root@GoLang:~/proj/goforjob# 

大致流程:

  1. map写检测,如果正处于写状态,表示此时不能进行读取,报fatal error

  2. 计算出hash值,将map置为写状态

  3. 判断桶数组是否为空,若为空,初始化桶数组

  4. 目标桶查找

    a. 根据hash值找到桶的位置

    b. 判断该当前是否处于扩容:

    • i. 若正在扩容:迁移这个桶,并且还另外帮忙多迁移一个桶以及它的溢出桶

    c. 获取目标桶的指针,计算tophash,开始后面的key查找过程

  5. key查找

    a. 遍历桶和它的溢出桶的每个槽位,按下述方式查找

    b. 判断槽位的tophash和目标tophash

    • i. 不相等

        1. 槽位tophash为空,标记这个位置为候选位置(为什么不直接插入?因为后续未遍历到的位置可能已经存在这个key,如果这个key存在则会更新key对应的value,只有当这个key不存在才插入)
        1. 槽位tophash的标志位为"后继空状态"(主要就是emptyOne和emptyREST 前者代表当前位置没有放入元素 后者是包括当前以及后面都没有元素),说明这个key之前没有被插入过,插入key/value
        1. tophash标志位不为空,说明存储着其他key,说明当前槽位tophash不符合,继续遍历下一个槽
    • ii. 相等

        1. 判断当前槽位的key与目标key是否相等
        • a. 不相等,继续遍历下一个槽位
        • b. 相等,找到了目标key的位置,原来已有键值对,则修改key对应的value,然后执行收尾程序
  6. key插入

    a. 若map中既没有找到key,且根据这个key找到的桶及其这个桶的溢出桶中没有空的槽位了,要申请一个新的溢出桶,在新申请的桶里插入

    b. 否则在找到的位置插入

  7. 收尾程序

    a. 再次判断map的写状态

    b. 清除map的写状态

这里需要注意一点:申请一个新的溢出桶的时候并不会一开始就创建一个溢出桶,因为map在初始化的时候会提前创建好一些溢出桶存储在extra*mapextra字段,当然当溢出桶出现时,这些下溢出桶会优先被使用,只有预分配的溢出桶使用完了,才会新建溢出桶。

扩容的目的不是"为了扩容而扩容",而是:通过增加桶数量,把原本在少数桶上形成的 overflow 链重新分散到更多主桶槽位中,减少指针追踪和槽位扫描,从而提升 map 的查找/插入性能并降低尾延迟

扩容的目的就是为了让溢出桶链表 都收缩到一个新的bucket数组中 ​ 溢出桶链表越多越长,查找效率越容易被影响

map的扩容

在上面介绍map的写入操作的时候,其实忽略了一个点,那就是随着不断地往map里写入元素,会导致map的数据量变得很大,hash性能会逐渐变差,而且溢出桶会越来越多,导致查找的性能变得很差。所以,需要更多的桶和更大的内存保证哈希的读写性能,这时map会自动触发扩容,在 runtime.mapassign (/usr/local/go/src/internal/runtime/maps/runtime_swiss.go)可以看到这条语句:

go 复制代码
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    ...
    if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        hashGrow(t, h)
        goto again
    }
    ...
}

可以看到map会在两种情况下触发扩容:

  • map的负载因子已经超过 6.5
  • 溢出桶的数量过多

在扩容的时候还有一个条件:!h.growing(),这是因为map的扩容并不是一个原子操作不是一次性完成的,所以需要判断一下,当前map是否正处于扩容状态,避免二次扩容造成混乱。

而这两种情况下,扩容策略是不同的

  • 负载因子已经超过6.5:双倍扩容
  • 溢出桶的数量过多:等量扩容(一般认为溢出桶数量接近正常桶数量时)

什么是负载因子?

bash 复制代码
负载因子 = 哈希表中的元素数量 / 桶的数量

哈希表中的元素数量:hmap里的count

桶的数量:bucket数组大小

为什么负载因子是6.5?

源码里对负载因子的定义是6.5,是经过测试后取出的一个比较合理的值,每个 bucket 有 8 个空位,假设map里所有的数组桶都装满元素,没有一个数组有溢出桶,那么这时的负载因子刚好是8。而负载因子是6.5的时候,说明数组桶快要用完了,存在溢出的情况下,查找一个key很可能要去遍历溢出桶,会造成查找性能下降,所以有必要扩容了

溢出桶的数量过多?

可以想象一下这种情况,先往一个map插入很多元素,然后再删除很多元素?再插入很多元素。会造成什么问题?

由于插入了很多元素,在不是完全理想的情况下,肯定会创建一些溢出桶,但是,又由于没有达到负载因子的临界值,所以不会触发扩容,在删除很多元素,这个时候负载因子又会减小,再插入很多元素,会继续创建更多的溢出桶,导致查找元素的时候要去遍历很多的溢出桶链表,性能下降,所以在这种情况下要进行扩容,新建一个桶数组,把原来的数据拷贝到里面,这样数据排列更紧密,查找性能更快。

反复插入/删除导致产生大量 overflow 桶,而删除不会自动回收这些 overflow 结构,久而久之出现"overflow 桶很多但很稀疏" 的情况;当 overflow 桶数达到阈值后触发等量扩容,通过重建把每条桶链重新紧凑化,从而减少 overflow 桶、避免长期占用多余内存。

扩容过程

扩容过程中大概需要用到两个函数,hashGrow() 和 growWork()。

扩容函数:

go 复制代码
func hashGrow(t *maptype, h *hmap) {
	// If we've hit the load factor, get bigger.
	// Otherwise, there are too many overflow buckets,
	// so keep the same number of buckets and "grow" laterally.
	bigger := uint8(1)
	if !overLoadFactor(h.count+1, h.B) {
		bigger = 0
		h.flags |= sameSizeGrow
	}
	oldbuckets := h.buckets
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

	flags := h.flags &^ (iterator | oldIterator)
	if h.flags&iterator != 0 {
		flags |= oldIterator
	}
	// commit the grow (atomic wrt gc)
	h.B += bigger
	h.flags = flags
	h.oldbuckets = oldbuckets
	h.buckets = newbuckets
	h.nevacuate = 0
	h.noverflow = 0

	if h.extra != nil && h.extra.overflow != nil {
		// Promote current overflow buckets to the old generation.
		if h.extra.oldoverflow != nil {
			throw("oldoverflow is not nil")
		}
		h.extra.oldoverflow = h.extra.overflow
		h.extra.overflow = nil
	}
	if nextOverflow != nil {
		if h.extra == nil {
			h.extra = new(mapextra)
		}
		h.extra.nextOverflow = nextOverflow
	}

	// the actual copying of the hash table data is done incrementally
	// by growWork() and evacuate().
}

go语言在对map进行过扩容的时候,并不是一次性将map的所有数据从旧的桶搬到新的桶,如果map的数据量很大,会非常影响性能,而是采用一种"渐进式"的数据转移技术,遵循写时复制(copy on write)的规则,每次只对使用到的数据做迁移。




简单分析一下扩容过程:

通过代码分析,hashGrow() 函数是在 mapassign 函数中被调用,所以,扩容过程会发生在map的赋值操作,在满足上述两个扩容条件时触发。

扩容过程中大概需要用到两个函数,hashGrow() 和 growWork()。其中 hashGrow() 函数只是分配新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上,并未参与真正的数据迁移,而数据迁移的功能是由 growWork() 函数完成的。



  • inserti 是"如果最终决定插入就用哪里"的备选位置,不代表马上插入,也不代表不用再继续扫描。
  • 创建 overflow 的前提是:整条 bucket 链都满了,找不到任何空位,所以 inserti 一直是 nil。
  • "删除后前面空了但还要遍历 overflow"是必须的:为了防止 key 已经存在于 overflow。

迁移时机

growWork() 函数会在 mapassign 和 mapdelete 函数中被调用,所以数据的迁移过程一般发生在插入或修改、删除 key 的时候 。在扩容完毕后(预分配内存),不会马上就进行迁移。而是采用写时复制的方式,当有访问到具体 bucket 时,才会逐渐的将 oldbucket 迁移到新bucket中

growWork() 函数定义如下:

go 复制代码
func growWork(t *maptype, h *hmap, bucket uintptr) {
    // 首先把需要操作的bucket迁移
    evacuate(t, h, bucket&h.oldbucketmask())
    // 再顺带迁移一个bucket
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

下面分析一下 evacuate 函数,大致迁移过程如下:

a. 先要判断当前bucket是不是已经迁移,没迁移就做迁移操作

go 复制代码
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
newbit := h.noldbuckets()
 // 判断旧桶是否已经被迁移了
if !evacuated(b) {
    do...  // 做迁移操作
}
  1. evacuated 函数直接通过 tophash 中第一个hash值判断当前bucket是否被转移
go 复制代码
func evacuated(b *bmap) bool {
    h := b.tophash[0]
    return h > emptyOne && h < minTopHash
}


中间三个值都表示bucket迁移了

  1. 数据迁移时,根据扩容规则,可能是迁移到大小相同的 buckets 上,也可能迁移到 2 倍大的 buckets 上。

如果迁移到等量数组上,则迁移完的目标桶位置还是在原先的位置上的,如果是双倍扩容迁移到 2 倍桶数组上,迁移完的目标桶位置有可能在原位置,也有可能在原位置+偏移量。(偏移量大小为原桶数组的长度)。xy 标记目标迁移位置,x 标识的是迁移到相同的位置,y 标识的是迁移到2倍数组上的位置。

Go map 扩容=把桶数翻倍;翻倍以后,每个旧桶会"分裂成两个新桶"。元素到底去哪个新桶,只取决于 hash 的某一位是 0 还是 1。






解释一下:

go 复制代码
var xy [2]evacDst
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))

if !h.sameSizeGrow() {
    // Only calculate y pointers if we're growing bigger.
    // Otherwise GC can see bad pointers.
    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))
}

evacDst 结构如下:

go 复制代码
type evacDst struct {
    b *bmap          // 迁移桶
    i int            // 迁移桶槽下标
    k unsafe.Pointer // 迁移桶key指针
    e unsafe.Pointer // 迁移桶val指针
}
  1. 迁移完 bucket 之后,就会按照 bucket 内的槽位逐条迁移 key/value 键值对。
  2. 迁移完一个桶后,迁移标记位 nevacuate+1,当 nevacuate 等于旧桶数组大小时,迁移完成,释放旧的桶数组和旧的溢出桶数组



扩容过程大概如下图所示:

等量扩容​

等量扩容,目标桶再扩容后还在原位置处

双倍扩容​

双倍扩容,目标桶扩容后的位置可能在原位置也可能在原位置+偏移量处。

map的删除原理

map的delete原理很简单,其核心代码位于 runtime.mapdelete 函数中,这里就不贴完整函数了,删除动作前整体逻辑和前面map的访问差不多,也是map的写检测,以及寻找bucket和key的过程。

清空key/value的核心代码如下:

go 复制代码
for {
    b.tophash[i] = emptyRest
    if i == 0 {
        if b == bOrig {
            break // beginning of initial bucket, we're done.
        }
        // Find previous bucket, continue at its last entry.
        c := b
        for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
        }
        i = bucketCnt - 1
    } else {
        i--
    }
    if b.tophash[i] != emptyOne {
        break
    }
}

如果在找到了目标key,则把当前桶该槽位对应的key和value删除,将该槽位的tophash置为emptyOne,如果发现当前槽位后面没有元素,则将tophash设置为emptyRest,并循环向前检查前一个元素,若前一个元素也为空,槽位状态为emptyOne,则将前一个元素的tophash也设置为emptyRest。这样做的目的是将emptyRest状态尽可能地向前面的槽推进,这样做是为了增加效率,因为在查找的时候发现了emptyRest状态就不用继续往后找了,因为后面没有元素了。

举个例子:

假设当前map的状态如下图所示,溢出桶2后面没有再接溢出桶,或者是溢出桶2后面接的溢出桶中没有数据,溢出桶2中有三个空槽,即第2,3,6处为emptyOne,

在删除了溢出桶1的key2和key4,以及溢出桶2的key7之后,对应map状态如下:

从delete map单个key/value的原理可以看出,当我们删除一个键值对的时候,这个键值对在桶中的内存并不会被释放

m = make(map[K]V) 让变量 m 指向一个全新的 map 对象。旧 map 如果此时再也没有任何引用,就会变成"不可达对象",之后会被 GC 回收,于是它占的 bucket 数组、overflow 桶、以及间接分配的结构内存都会释放

map的遍历

go语言中map的遍历尤其要引起注意,因为每次遍历的数据顺序都是不同的。这是因为go在每次开始遍历前,都会随机选择一个桶下标,一个桶内遍历的起点槽下标,遍历的时候从这个桶开始,在遍历每个桶的时候,都从这个槽下标开始

go语言为什么要用这种随机开始的位置开始遍历呢?

一方面:因为go的扩容不是一个原子操作,是渐进式的,所以在遍历map的时候,可能发生扩容,一旦发生扩容,key的位置就发生了重大的变化,下次遍历map的时候结果就不可能按原来的顺序了

另一方面:hash 表中数据每次插入的位置是变化的,同一个 map 变量内,数据删除再添加的位置也有可能变化,因为在同一个桶及溢出链表中数据的位置不分先后

所以理论上,map的遍历结果就是不同的,所以Go防止用户错误的依赖于每次迭代的顺序,索性每次遍历时,都是随机选取的一个遍历开始位置。

遍历过程

迭代器

运行时,map的遍历是依靠一个迭代器来完成的,迭代器的代码定义如下:

go 复制代码
// A hash iteration structure.
// If you modify hiter, also change cmd/compile/internal/reflectdata/reflect.go
// and reflect/value.go to match the layout of this structure.
type hiter struct {
	key         unsafe.Pointer // Must be in first position.  Write nil to indicate iteration end (see cmd/compile/internal/walk/range.go).
	elem        unsafe.Pointer // Must be in second position (see cmd/compile/internal/walk/range.go).
	t           *maptype
	h           *hmap
	buckets     unsafe.Pointer // bucket ptr at hash_iter initialization time
	bptr        *bmap          // current bucket
	overflow    *[]*bmap       // keeps overflow buckets of hmap.buckets alive
	oldoverflow *[]*bmap       // keeps overflow buckets of hmap.oldbuckets alive
	startBucket uintptr        // bucket iteration started at
	offset      uint8          // 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
	i           uint8
	bucket      uintptr
	checkBucket uintptr
	clearSeq    uint64
}

整个遍历的过程大致可以分为两步

  1. 初始化迭代器
  2. 开始一轮遍历

初始化迭代器的工作主要在函数 mapiterinit(t maptype, h hmap, it *hiter) 中完成,源代码如下:

go 复制代码
// mapiterinit initializes the hiter struct used for ranging over maps.
// The hiter struct pointed to by 'it' is allocated on the stack
// by the compilers order pass or on the heap by reflect_mapiterinit.
// Both need to have zeroed hiter since the struct contains pointers.
//
// mapiterinit should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
//   - github.com/bytedance/sonic
//   - github.com/goccy/go-json
//   - github.com/RomiChan/protobuf
//   - github.com/segmentio/encoding
//   - github.com/ugorji/go/codec
//   - github.com/wI2L/jettison
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname mapiterinit
func mapiterinit(t *maptype, h *hmap, it *hiter) {
	if raceenabled && h != nil {
		callerpc := sys.GetCallerPC()
		racereadpc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapiterinit))
	}

	it.t = t
	if h == nil || h.count == 0 {
		return
	}

	if unsafe.Sizeof(hiter{}) != 8+12*goarch.PtrSize {
		throw("hash_iter size incorrect") // see cmd/compile/internal/reflectdata/reflect.go
	}
	it.h = h
	it.clearSeq = h.clearSeq

	// grab snapshot of bucket state
	it.B = h.B
	it.buckets = h.buckets
	if !t.Bucket.Pointers() {
		// Allocate the current slice and remember pointers to both current and old.
		// This preserves all relevant overflow buckets alive even if
		// the table grows and/or overflow buckets are added to the table
		// while we are iterating.
		h.createOverflow()
		it.overflow = h.extra.overflow
		it.oldoverflow = h.extra.oldoverflow
	}

	// decide where to start
	r := uintptr(rand())
	it.startBucket = r & bucketMask(h.B)
	it.offset = uint8(r >> h.B & (abi.OldMapBucketCount - 1))

	// iterator state
	it.bucket = it.startBucket

	// Remember we have an iterator.
	// Can run concurrently with another mapiterinit().
	if old := h.flags; old&(iterator|oldIterator) != iterator|oldIterator {
		atomic.Or8(&h.flags, iterator|oldIterator)
	}

	mapiternext(it)	// 进行单次遍历
}

主要工作分为以下几步:

  1. 判断map是否为空
  2. 随机一个开始遍历的起始桶下标
  3. 随机一个槽位下标,后续每个桶内的遍历都从这个槽位开始
  4. 把map置为遍历状态
  5. 开始执行一次遍历过程

单次遍历的工作主要在 mapiternext(it *hiter) 函数中完成,源代码如下:

go 复制代码
func mapiternext(it *hiter) {
	h := it.h
	if raceenabled {
		callerpc := sys.GetCallerPC()
		racereadpc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapiternext))
	}
	if h.flags&hashWriting != 0 {	// map的并发写判断
		fatal("concurrent map iteration and map write")
	}
	t := it.t	// 获取上次迭代进度
	bucket := it.bucket
	b := it.bptr
	i := it.i
	checkBucket := it.checkBucket

next:	// 开始一次迭代
	if b == nil {	// 遍历未开始或者当前正常桶(桶数组中的桶)的溢出桶(同数组的溢出表链表中的桶)已经遍历完了,开始遍历下一个正常桶
		if bucket == it.startBucket && it.wrapped {	// 遍历位置是起始桶,并且wrapped为true,说明遍历完了,直接返回
			// end of iteration
			it.key = nil
			it.elem = nil
			return
		}
		// 如果map正在扩容,判断当前遍历的桶数据是否已经迁移完,迁移完了则使用新桶,否则使用旧桶
		if h.growing() && it.B == h.B {
			// Iterator was started in the middle of a grow, and the grow isn't done yet.
			// If the bucket we're looking at hasn't been filled in yet (i.e. the old
			// bucket hasn't been evacuated) then we need to iterate through the old
			// bucket and only return the ones that will be migrated to this bucket.
			oldbucket := bucket & it.h.oldbucketmask()
			b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.BucketSize)))
			if !evacuated(b) {
				checkBucket = bucket
			} else {
				b = (*bmap)(add(it.buckets, bucket*uintptr(t.BucketSize)))
				checkBucket = noCheck
			}
		} else {
			b = (*bmap)(add(it.buckets, bucket*uintptr(t.BucketSize)))
			checkBucket = noCheck
		}
		bucket++
		if bucket == bucketShift(it.B) {	// 遍历到了数组最后,从头开始继续遍历
			bucket = 0
			it.wrapped = true
		}
		i = 0
	}
	for ; i < abi.OldMapBucketCount; i++ {	// 遍历当前桶和当前桶的溢出桶里的数据
		offi := (i + it.offset) & (abi.OldMapBucketCount - 1)	// 通过初始化的槽位下标确定将要遍历的槽位的tophash
		if isEmpty(b.tophash[offi]) || b.tophash[offi] == evacuatedEmpty {
			// TODO: emptyRest is hard to use here, as we start iterating
			// in the middle of a bucket. It's feasible, just tricky.
			continue
		}
		// 根据偏移量i确定key和value的地址
		k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.KeySize))
		if t.IndirectKey() {
			k = *((*unsafe.Pointer)(k))
		}
		e := add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr(t.KeySize)+uintptr(offi)*uintptr(t.ValueSize))
		if checkBucket != noCheck && !h.sameSizeGrow() {
			// Special case: iterator was started during a grow to a larger size
			// and the grow is not done yet. We're working on a bucket whose
			// oldbucket has not been evacuated yet. Or at least, it wasn't
			// evacuated when we started the bucket. So we're iterating
			// through the oldbucket, skipping any keys that will go
			// to the other new bucket (each oldbucket expands to two
			// buckets during a grow).
			// 这里处于增量扩容,需要进一步判断
            // 如果数据还没有从旧桶迁移到新桶,需要计算这个key重新hash计算后是否与oldbucket的索引一致,不一致则跳过
			if t.ReflexiveKey() || t.Key.Equal(k, k) {
				// If the item in the oldbucket is not destined for
				// the current new bucket in the iteration, skip it.
				hash := t.Hasher(k, uintptr(h.hash0))
				if hash&bucketMask(it.B) != checkBucket {
					continue
				}
			} else {
				// Hash isn't repeatable if k != k (NaNs).  We need a
				// repeatable and randomish choice of which direction
				// to send NaNs during evacuation. We'll use the low
				// bit of tophash to decide which way NaNs go.
				// NOTE: this case is why we need two evacuate tophash
				// values, evacuatedX and evacuatedY, that differ in
				// their low bit.
				if checkBucket>>(it.B-1) != uintptr(b.tophash[offi]&1) {
					continue
				}
			}
		}
		if it.clearSeq == h.clearSeq &&
			((b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) ||
				!(t.ReflexiveKey() || t.Key.Equal(k, k))) {	// 这里的数据没有处在扩容中,直接使用
			// This is the golden data, we can return it.
			// OR
			// key!=key, so the entry can't be deleted or updated, so we can just return it.
			// That's lucky for us because when key!=key we can't look it up successfully.
			it.key = k
			if t.IndirectElem() {
				e = *((*unsafe.Pointer)(e))
			}
			it.elem = e
		} else {
			// The hash table has grown since the iterator was started.
			// The golden data for this key is now somewhere else.
			// Check the current hash table for the data.
			// This code handles the case where the key
			// has been deleted, updated, or deleted and reinserted.
			// NOTE: we need to regrab the key as it has potentially been
			// updated to an equal() but not identical key (e.g. +0.0 vs -0.0).
			rk, re := mapaccessK(t, h, k)	// 走到这里,表明这条数据已经被迁移或者删除,使用mapaccessK去找回这部分的数据
			if rk == nil {
				continue // key has been deleted	数据没找到,说明已经删除
			}
			it.key = rk
			it.elem = re
		}
		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
	}
	b = b.overflow(t)	// 遍历溢出桶链表:继续遍历下一个溢出桶
	i = 0
	goto next
}

遍历的流程大致是以下几个步骤:

  1. map的并发写检测,在 mapiternext 中通过 h.flags&hashWriting 检测是否有写操作与遍历并发发生;若发现则触发运行时致命错误 concurrent map iteration and map write(不是 panic)【fata error 是不可以 recover 的 error ,而 panic 是可以 recover 的 error】
  2. 判断是否已经遍历完了,遍历完了直接退出
  3. 开始遍历
  4. 首先确定一个随机开始遍历的起始桶下标作为startBucket,然后确定一个随机的槽位下标作为offset
  5. 根据startBucket和offset开始遍历当前桶和当前桶的溢出桶,如果当前桶正在扩容,则进行步骤6,否则进行步骤7
  6. 在遍历处于扩容状态的bucket的时候,因为当前bucket正在扩容,我们并不会遍历这个桶,而是会找到这个桶的旧桶old_bucket,遍历旧桶中的一部分key,这些key重新hash计算后能够散列到bucket中,对那些key经过重新hash计算不散列到bucket中的key,则跳过(这个大概就是如果bucket正在迁移 哪怕有key在旧桶里面未迁移 我们遍历的时候会严格按照新桶的顺序来遍历)
  7. 根据遍历初始化的时候选定的随机槽位开始遍历桶内的各个key/value
  8. 继续遍历bucket溢出指针指向的溢出链表中的溢出桶
  9. 假如遍历到了起始桶startBucket,则说明遍历完了,结束遍历

遍历禁止写 ≠ 进入遍历时 map 一定不在 growing 状态 ,扩容不是"瞬间完成"的,而是"先开始,后慢慢搬"

之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!

相关推荐
wen__xvn2 小时前
代码随想录算法训练营DAY15第六章 二叉树part03
数据结构·算法·leetcode
Sagittarius_A*2 小时前
图像滤波:手撕五大经典滤波(均值 / 高斯 / 中值 / 双边 / 导向)【计算机视觉】
图像处理·python·opencv·算法·计算机视觉·均值算法
seeksky2 小时前
Transformer 注意力机制与序列建模基础
算法
冰暮流星2 小时前
c语言如何实现字符串复制替换
c语言·c++·算法
Swift社区2 小时前
LeetCode 374 猜数字大小 - Swift 题解
算法·leetcode·swift
Coovally AI模型快速验证2 小时前
2026 CES 如何用“视觉”改变生活?机器的“视觉大脑”被点亮
人工智能·深度学习·算法·yolo·生活·无人机
Wpa.wk2 小时前
性能测试 - JMeter练习-JMeter录制Web端压测脚本操作步骤
java·前端·经验分享·jmeter·自动化
小镇cxy2 小时前
Ragas 大模型评测框架深度调研指南
后端
有一个好名字2 小时前
力扣-链表最大孪生和
算法·leetcode·链表