
























这一期我们聊聊Go语言中的Map和sync.Map,通过这一篇文章,深入理解底层的原理是怎么实现的,这里要非常感谢Go社区某大佬提供的参考: https://learnku.com/articles/89660
map 是由键值对 key-value 组成的,key 只会出现一次,实现方式有下面两种:
1、哈希查找表(Hash table)
2、搜索树(Search tree)
源码位置:$GOROOT/src/runtime/map.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 // 元素的个数, 也就是len() 的值
flags uint8 //map 的状态标记,用于标识 map 的各种状态,例如:1、有遍历器使用;2、有遍历器使用旧桶;3、有协程正在写入;4、等量扩容
B uint8 //bucket个数为:2^B;可以保存的元素个数:填充因子(默认6.5) * 2^B items)
noverflow uint16 /// 溢出桶数量
hash0 uint32 // 哈希因子
buckets unsafe.Pointer // Buckets数组,大小为 2^B
oldbuckets unsafe.Pointer // 发生扩容前的Buckets,在增长时非nil
nevacuate uintptr // 迁移状态,进度
extra *mapextra // 用于存储一些不是所有哈希表都需要的字段
}
type mapextra struct {
//指向bmap类型切片的指针,bmap类型是哈希表中存储数据的基本单元。
//overflow字段用于存储所有溢出桶的指针,溢出桶是当哈希表中某个位置发生冲突时,用于存放多余数据的额外桶。
//overflow字段只有在哈希表的键和值都不包含指针并且可以内联时才使用,这样可以避免扫描这些哈希表。
overflow *[]*bmap
//这也是一个指向bmap类型切片的指针,它用于存储旧哈希表中的溢出桶的指针,
//旧哈希表是当哈希表需要扩容时,原来的哈希表称为旧哈希表,新分配的哈希表称为新哈希表。
//oldoverflow字段也只有在键和值都不包含指针并且可以内联时才使用。
oldoverflow *[]*bmap
//指向bmap类型的指针,它用于存储一个空闲的溢出桶,当需要分配一个新的溢出桶时,就从nextOverflow中取出一个,
//如果nextOverflow为空,则从堆上分配一个新的溢出桶。
nextOverflow *bmap
}
//定义了hmap.buckets中每个bucket的结构
type bmap struct {
//tophash不仅仅用来存放key的哈希高8位,在不同场景下它还可以标记迁移状态,bucket是否为空等.当tophash对应的K/V被使用时,存的是key的哈希值的高8位;当tophash对应的K/V未被使用时,存的是K/V对应位置的状态
//bucketCnt 是常量=8,一个bucket最多存储8个key/value对
tophash [bucketCnt]uint8
}根据上面的源码结构我们可以知道 map 的键值对是存储到 bucket 里面的,每个 bucket 最多可以存储 8 个键值对。

从源码可知 buckets 中是 [] bmap 切片,我们来看看 bmap 究竟是什么样的
type bmap struct {
tophash [bucketCnt]uint8
}上面的结构只是表面 (src/runtime/hashmap.go) 的结构,编译期间会被拓展,动态地创建一个新的结构:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}这就和上面图的 bmap 结构相对应,bmap 就是我们常说的 “桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是 “一类” 的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有 8 个位置)。

上图就是 bucket 的内存模型,HOB Hash 指的就是 top hash。 注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式。源码里说明这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间。当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。
但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来。
type mapextra struct {
// overflow[0] contains overflow buckets for hmap.buckets.
// overflow[1] contains overflow buckets for hmap.oldbuckets.
overflow [2]*[]*bmap
// nextOverflow 包含空闲的 overflow bucket,这是预分配的 bucket
nextOverflow *bmap
}map 的一个关键点在于,哈希函数的选择。在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。
hash 函数,有加密型和非加密型。 加密型的一般用于加密数据、数字摘要等,典型代表就是 md5、sha1、sha256、aes256 这种; 非加密型的一般就是查找。在 map 的应用场景中,用的是查找。 选择 hash 函数主要考察的是两点:性能、碰撞概率。
key 经过哈希计算后得到哈希值,共 64 个 bit 位(以 64 位机器为例),计算它到底要落在哪个桶时,只会用到最后 B 个 bit 位。还记得前面提到过的 B 吗?
示例:
如果 B = 5,那么桶的数量,也就是 buckets 数组的长度是 2^5 = 32。
例如,现在有一个 key 经过哈希函数计算后,得到的哈希结果是:
1001011100001111011011001000111100101010001001011001010101001010切分前8位和后5位得到:
10010111 | 000011110110110010001111001010100010010110010101010 | 01010用后 5 个 bit 位,也就是 01010,值为 10 来确定哪一个桶,这里也就是 10 号桶。这个操作实际上就是取余操作,但是取余开销太大,所以代码实现上用的位操作代替。
再用哈希值的高 8 位,也就是 10010111,其实就是 bmap 中的 tophash 字段,找到此 key 在 bucket 中的位置,这是在寻找已有的 key。最开始桶内还没有 key,新加入的 key 会找到第一个空位,放入。buckets 编号就是桶编号,当两个不同的 key 落在同一个桶中,也就是发生了哈希冲突。冲突的解决手段是用链表法:在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key,如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。
流程示意图:

1、计算 hash,确定 key 的位置
根据 hash 低位从 buckets 数组中确认存入 buckets 的位置;根据 hash 高 8 位确认 bucket 内的位置 确认 key 是否存在,若存在则获取数据存入地址,否则获取 overflow buckets,继续第一步;
2、如果需要扩容,则进行扩容后,继续第一步;
3、如果 buckets 已满会存入 overflow buckets,overflow buckets 也满了,就会新建 overflow bucket,获取 key 和 elem 的地址并存入数据
随着新的键值的写入,map 会进行扩容,也会随着 key 的删除可能触发缩容。和切片不同的是 map 可以根据新增的 key-value 动态的伸缩,因此 map 没有固定的长度限制,在使用 make 初始化的时候要我们可以指定其初始长度。我们来看看 map 具体是怎么进行扩容的?
1、当 map 元素达到某个阈值
当 map 元素过多可能会增加 hash 冲突的概率,导致 map 读取效率下降因此当 map 的元素过多,超过一定的阈值后,就应该对 map 的数据元素进行重整,平衡数据的读取速度。这个阈值是由下面的扩容因子决定的:
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
// Maximum average load of a bucket that triggers growth is 6.5.
// Represent as loadFactorNum/loadFactorDen, to allow integer math.
loadFactorNum = 13
loadFactorDen = 2
// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}也就是: count > LoadFactor * 2^B (LoadFactor =loadFactorNum/loadFactorDen=6.5)
计算示例:
// 扩容条件分解
count > LoadFactor * 2^B
其中:
- count: map中的元素个数
- LoadFactor: 负载因子 6.5
- 2^B: 桶的数量
- B: 桶数量的幂次
// 例如:
B = 4 时:
- 桶数量 = 2^4 = 16
- 扩容阈值 = 6.5 * 16 = 104
- 当元素个数 > 104 时需要扩容其中 count 指的是当前 map 里的元素个数,2^B 是指当前 map 里 buckets 数组长度,从这可以看出元素越来越多,即 count 越来越大,则越可能触发扩容。
2、overflow 数量过多,但元素很少这种情况的产生,是因为出现了大量数据的插入和删除,导致 overflow 不能回收,所以也要进行数据重整。
我们重点来看看第一种情况的扩容过程。首先,hmap 会先增加 B 的值,也就是 buckets 数组的数量。然后,重新计算 key 所需要迁移的桶的位置,以便让数据能均衡的分布在新老 buckets 桶里。当然,Go 并不会一下子就把所有的数据都迁移完毕,而是一种懒迁移的机制。它会等到用到某个 key 时,检查此时 key 的迁移状态,再作出迁移动作。从上面的扩容过程我们也可以看出为什么 map 是无序的了,因为原本在一个 bucket 上的数据有可能被迁移到其他的 bucket 上了,会被打乱顺序。
具体的扩容过程如下:
1、在扩容时,调用 hashGrow 函数,如果负载因子超载,则会进行双倍重建。当溢出桶的数量过多时,会进行等量重建。2、新桶会存储到 buckets 字段,旧桶会存储到 oldbuckets 字段。map 中 extra 字段的溢出桶也进行同样的转移。
3、数据转移遵循写时复制(copy on write)的规则,只有在真正赋值时,才会选择是否需要进行数据转移。也就说这个过程是渐进的,并不是一次性将所有键值对都搬移到新的哈希表中,而是逐步迁移的。这样可以避免一次性的大量内存分配和复制操作,减少了扩容时的性能开销;
4、在迁移的过程中,新的哈希表会被标记为正在扩容状态,这样在查找键值对时,会同时在新旧两个哈希表中进行查找,直到所有的键值对都已经迁移到新的哈希表中。
5、迁移完成后,旧的哈希表会被丢弃,释放其占用的内存空间。
6、需要注意的是,由于哈希表的大小是 2 的幂次方,因此扩容后的大小总是 2 的幂次方,这样可以保证哈希函数计算得到的哈希值能够在新的哈希表中找到对应的桶,避免了重新计算哈希值的开销。
总的来说,map 的扩容过程是一种动态的调整大小的机制,它允许 map 在存储越来越多的键值对时保持高效的查找性能。
对于第二种情况的数据重整,就比较简单了。只要在当前 bucket 里收缩各个 overflow 到空位上即可。
当我们向 map 中插入键值对时,map 会首先计算键的哈希值,并根据哈希值找到对应的桶(bucket)。每个桶中存储着一个或多个键值对,最多 8 个。
1、由于哈希函数的随机性和哈希冲突的存在,不同的键可能会被映射到哈希表中的不同桶。因此,插入键值对的顺序并不能决定键在哈希表中的位置,导致 map 中的键是无序的;或者说 hash 算法本身就无法确保 key 的有序性。
2、当我们对 map 进行遍历时,每次遍历的顺序可能是不同的。前面我们讲过,map 会进行懒迁移,当 map 的 key 被访问时会检查它的迁移状态从而可能会将这个 key 迁移到新的 bucket。这也导致了 map 的无序性以及每次迭代时元素顺序的不确定性;
3、从 Go 1.12 版本开始,Go 语言在 runtime 中引入了一种伪随机的哈希算法,以减少哈希冲突带来的风险。这种伪随机哈希算法使得同样的 key 在不同运行时或不同机器上可能会被映射到不同的桶,从而进一步增加了 map 中键的无序性。
在实际开发过程中我们经常需要按照指定的顺序处理 map 的数据,比如搜索业务中,我们得到的搜索结果是一个切片,但是在对这些数据的处理过程中我们需要将其转换成 map,处理完数据后再将新的数据以切片的形式输出。这个过程中,如果我们不刻意对 map 进行排序,就可能导致最终输出的结果顺序会发生变化。
如果需要对 map 中的键进行排序,可以将键拷贝到一个切片中,并对切片进行排序,然后循环切片获取到的元素就是 map 的 key 值,再从map里取对应key的value。这样可以得到有序的键序列,但是需要额外的内存和时间开销。
package main
import (
"fmt"
"sort"
)
func main() {
m := make(map[int]string)
keys := make([]int, 0, 3)
m[1] = "v1"
m[2] = "v2"
m[3] = "v3"
for k, v := range m {
keys = append(keys, k)
fmt.Println("k:", k, "v:", v)
}
//对key进行排序
sort.Ints(keys)
//根据key的值对map进行遍历
for _, key := range keys {
fmt.Println("sorted key :", key, "value:", m[key])
}
}输出:
k: 2 v: v2
k: 3 v: v3
k: 1 v: v1
sorted key : 1 value: v1
sorted key : 2 value: v2
sorted key : 3 value: v3前面我们讲到 map 的底层数据结构是一个哈希表。当我们向 map 中添加键值对时,Go 语言会自动进行扩容和重新哈希等操作,以保证 map 的性能和空间效率。而这些操作可能会导致 map 中的键值对在内存中重新分布。如果我们允许对 map 的元素取地址,并通过指针访问 map 中的键值对,那么当 map 进行扩容或重新哈希时,指向旧键值对的指针将会变得无效,这是 map 元素不能取址的本质原因。
具体而言,为了保证元素索引的效率,map 底层的哈希表只会为它所有的键值维护一段连续的内存段。当 map 中键值对增加到一定程度时,就需要开辟新的内存段来扩容。扩容的过程是将原来内存段上的值全部拷贝到新开辟的内存段上。即使 map 的内存段没有触发扩容,某些哈希表的实现也可能在当前内存段中移动其中的元素。总之,map 中元素的地址会因为各种原因改变。如果 Go 语言的 map 去维护这些指针值,会增加 Go 编译器和运行时的复杂度,最主要的是会影响程序的运行效率,所以 Go 不支持取 map 元素的地址。
当一个 map 变量被声明但未初始化时,它的值为 nil。这就是所谓的”nil map”,也就是未分配内存的 map。一个”nil map” 不能直接用于存储键值对,否则会导致运行时错误(panic)。如果试图向”nil map” 存储数据,会引发 runtime panic,这一点跟切片是有区别的。例如:
var m map[string]int // 这是一个nil map
fmt.Println(m) // 输出: map[]
fmt.Println(m["key"]) //0
delete(m, "key")
m["key"] = 1 // 运行时错误: panic: assignment to entry in nil map“空 map” 是指已经初始化的 map,但其中没有任何键值对。
空 map 是合法的,可以直接用于存储和读取键值对,而不会引发运行时错误。
m := make(map[string]int) // 这是一个空 map
fmt.Println(m) // 输出: map[]
m["key"] = 1 // 添加键值对
fmt.Println(m) // 输出: map[key:1]所以 nil map 和空 map 的本质区别体现在是否分配内存,也就是是否对 map 类型变量进行了初始化。
需要注意的是,切片和 map 在赋值的表现上是有差异的
package main
import "log"
func init() {
log.SetFlags(log.Lshortfile)
}
func main() {
m := make(map[string]string)
m1 := m
m1["age"] = "18"
m["name"] = "iceymoss"
log.Printf("m:%v;ptr:%p", m, m) //输出: m:map[age:18 name:iceymoss];ptr:0x1400007e0f0
log.Printf("m1:%v;ptr:%p", m1, m1) //输出: m1:map[age:18 name:iceymoss];ptr:0x1400007e0f0
s := make([]string, 0, 10)
s1 := s
log.Printf("s:%v;ptr:%p", s, s) //输出: s:[];ptr:0x14000116000
log.Printf("s1:%v;ptr:%p", s1, s1) //输出: s1:[];ptr:0x14000116000
s1 = append(s1, "v1")
log.Printf("s:%v;ptr:%p", s, s) //输出: s:[];ptr:0x14000116000
log.Printf("s1:%v;ptr:%p", s1, s1) //输出: s1:[v1];ptr:0x14000116000
}从上面示例中可以看到:map 重新赋值后 m1 和 m 是共享同一块底层空间的,所以当我们向赋值后的 map(m1)中增加元素时,赋值前的 map(m)中也会有这个元素;
先说结论:删除 map 的 key 是无法达到回收 map 内存空间的目的的。即 map 不会因为删除了一个键值对而自动缩小哈希表的容量,因为 map 的扩容机制,go 语言中的 map 使用了一种称为渐进式扩容法的动态调整方法,它只会在插入新元素时根据装填因子来判断是否需要扩容,并且扩容过程是分批进行的,也就是我们前面讲到的懒迁移的策略。所以 map 中即使删除了 key 也不会立马回收空间。可以验证一下:
package main
import (
"fmt"
"log"
"runtime"
)
const Cnt = 10000000
var m = make(map[int]int, Cnt)
func init() {
log.SetFlags(log.Lshortfile)
}
func main() {
for i := 0; i < Cnt; i++ {
m[i] = i
}
runtime.GC()
log.Println(getMemStats())
for k, _ := range m {
delete(m, k)
}
runtime.GC()
log.Println(getMemStats())
m = nil
runtime.GC()
log.Println(getMemStats())
}
func getMemStats() string {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return fmt.Sprintf("分配的内存 = %vKB, GC的次数 = %v\n", m.Alloc/1024, m.NumGC)
}输出:
main.go:22: 分配的内存 = 314382KB, GC的次数 = 2
main.go:27: 分配的内存 = 314384KB, GC的次数 = 3
main.go:30: 分配的内存 = 104KB, GC的次数 = 4删除 map 的 key 之后,其元素是没法被 GC 的,也就说 map 的容量只能增加不能减少,当频繁删除 map 中的元素时,它们所占用的桶并不会主动被释放,从而可能出现内存泄露。
那我们如果希望手动去触发 map 的空间回收,可以怎么实现呢?
如果想要减少 map 所占用的内存大小,需要重新创建一个新的 map,并将旧 map 赋值为 nil 或者让它超出作用域范围,以便于垃圾回收器回收旧 map。然后再将新的 map 赋值给旧的 map。这样相当于强制对 map 进行了一次重整,就可以释放掉旧 map 中的 key 删除但而保留的内存空间。
正确使用方式:
func mapMemoryRelease() {
// 原始map
oldMap := make(map[int]string)
// 添加数据
for i := 0; i < 1000000; i++ {
oldMap[i] = fmt.Sprintf("value_%d", i)
}
// 注意不能直接newMap = oldMap,这样的话属于浅拷贝,他们还是够用底层的hmap的
// 深拷贝
// 删除数据后,创建新map
newMap := make(map[int]string, len(oldMap))
for k, v := range oldMap {
if k >= 900000 { // 只保留需要的数据
newMap[k] = v
}
}
// 释放旧map
oldMap = nil
// 重新赋值
oldMap = newMap
}1、map 不是线程安全的,不支持并发写,map 在进行读取 key 的时候会检查当前的 hmap.flags 字段,如果发现该值是一个写状态值的话,则会直接 panic;
2、不要依赖 map 的元素遍历顺序,因为 map 的元素是无序的;
3、由于 map 元素会在访问后可能进行懒迁移,所以 map 的元素是不可寻址的,在编写代码过程中不要依赖 map 中 value 的地址;
4、尽量使用 cap 参数创建 map,以提升 map 平均访问性能,减少频繁扩容带来的不必要损耗。
5、当使用 float 作为 map 的 key 时,由于 float 精度问题,会出现获取不到 float key 对应 value 的情况,所以应避免使用 float 作为 map 的 key;
源码核心数据结构如下:
type Map struct {
//加锁,用于保护dirty字段
mu Mutex
// 存储只读数据,原子操作,并发安全
read atomic.Pointer[readOnly]
// 存储最新写入的数据,通过加锁确保并发安全
dirty map[any]*entry
//计数器,从read中读失败则+1,达到一定值时将dirty的数据提升为read
misses int
}
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
m map[any]*entry // 只读map,用于快速读取
amended bool // 标记dirty map是否包含read map中没有的key
}sync.Map 的底层数据结构本质上还是使用的 map,只不过在 map 的基础上做了优化。从上面的结构体字段我们可以看出其在 map 上的一些优化;实现并发安全的两个思路分别是 原子操作 和 加锁, 原子操作由于是直接面向硬件的一组不可分割的指令,所以效率要比加锁高很多,因此 Map 的基本思路就是尽可能多的使用原子操作,直到迫不得已才去使用锁机制,sync map 使用了两个原生的 map 作为存储介质,分别是 read map 和 dirty map(只读字典和脏字典),具体实现如下:
1、read 和 dirty 两个字段将读写分离。读取时会先查询 read 字段,不存在再查询 dirty,写入时则只写入 dirty。读取 read 不需要加锁,而读或写 dirty 都需要加锁。由于只需要在 dirty 字段上加锁。这种读写分离的设计提高了 sync.Map 的读效率
2、通过 misses 字段来统计 read 被穿透的次数(被穿透是指在 read 上找不到需要读 dirty 的情况),超过一定次数则将 dirty 数据同步到 read 上
3、删除数据通过标记来延迟删除;删除的过程同样也是也从 read 中查找,如果在 read 中,则调用 delete () 函数将 key 所指向的条目的指针设置为 nil。否则就需要到 dirty 中查找再执行 delete () 方法。延时删除体现在当 read 和 dirty 一致的时,触发重塑的过程。重塑的过程,会将 nil 状态的 entry,全部挤压到 expunged 状态中,同时将非 expunged 的 entry 浅拷贝到 dirty 中(所谓浅拷贝其实就是指针操作,并不是复制真实数据),这样可以避免 read 的 key 无限的膨胀(也就是存在大量逻辑删除的 key)。最终,在 dirty 再次升级为 read 的时候,这些逻辑删除的 key 就可以被释放了
示例:
package main
import (
"fmt"
"sync"
)
func main() {
wg := sync.WaitGroup{}
//m := make(map[int]int)
m := sync.Map{}
for i := 0; i < 10; i++ {
x := i
wg.Add(1)
go func() {
//m[x] = x
m.Store(x, x)
wg.Done()
}()
}
wg.Wait()
//按key读取
value, ok := m.Load(1)
if ok {
fmt.Println(value)
}
//遍历
m.Range(func(key, value any) bool {
fmt.Println("key:", key, "value:", value)
//if key == 3 {
// // 终止遍历
// return false
//}
return true
})
//支持不同类型的k/v
m.Store("k1", "v1")
v1, ok := m.Load("k1")
if ok {
fmt.Println(v1)
}
}输出:
1
key: 1 value: 1
key: 5 value: 5
key: 6 value: 6
key: 7 value: 7
key: 8 value: 8
key: 0 value: 0
key: 9 value: 9
key: 4 value: 4
key: 3 value: 3
key: 2 value: 2
v1关于并发 map 的使用我们需要注意以下几点:
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。