Go 之 map 源码记录篇

前言

每次面试的时候,都会被问到底层的一些东西,所谓是看过即忘,所以还是记录一下吧,有不同理解的,大家可以直接源码解读一下~https://github.com/golang/go/blob/master/src/runtime/map.go,或许更加深印象。

一、map 的数据结构

Golang的map使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,也即bucket,而每个bucket就保存了map中的一个或一组键值对。

以下 图1.1 是map 的主要组成部分代码:

// 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)

	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
}

// 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 [abi.MapBucketCount]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.
}

 图1.1

 hmap

map的主要底层实现。

count :                     存储的键值对数目

flags :                      状态标志(是否处于正在写入的状态等)
B :                           桶的数目 2^B
noverflow                   使用的溢出桶的数量
hash0 :                       生成hash的随机数种子

buckets :                     bucket数组指针,数组的大小为2^B(桶)
oldbuckets :                扩容阶段用于记录旧桶用到的那些溢出桶的地址
nevacuate :                 记录渐进式扩容阶段下一个要迁移的旧桶编号
extra :                          指向mapextra结构体里边记录的都是溢出桶相关的信息

 图1.2

关系如图 1.3 下所示: 

图1.3

bmap

buckets 则是指向哈希表节点 bmap 即 bucket 的指针,Go 中一个桶里面会最多装 8 个 key。

上面bmap结构是静态结构,在编译过程中runtime.bmap会拓展成以下图1.4 结构体:

type bmap struct {
    tophash [8]uint8 //存储哈希值的高8位
    data    byte[1]  //key value数据:key/key/key/.../value/value/value...
    overflow *bmap   //溢出bucket的地址
}

图1.4 

1. tophash 是个长度为8的数组,哈希值相同的键(准确的说是哈希值低位相同的键)存入当前bucket时会将哈希值的高位存储在该数组中,以方便后续匹配。

2. data 区存放的是key-value数据,存放顺序是key/key/key/…value/value/value,如此存放是为了节省字节对齐带来的空间浪费。

3. overflow 指针指向的是下一个bucket,据此将所有冲突的键连接起来。

图1.5

 键值对存储(key, value)

哈希表通常会有一堆桶来存储键值对,一个个键值对来了必然要选择一个桶,先通过hash函数将“键”处理一下,得到一个hash值,利用hash值从m的个中选择一个,桶编号区间为[0,m-1]。

图1.6 

如图 1.6 所示,Go语言中是通过 与运算 来确定桶的位置,通过 拉链法 来解决hash冲突。

若想确保运算结果落在区间[0,m-1]而不会出现空桶,就要限制桶的个数m必须是2的整数幂,这样m的二进制数肯定只含一个1(比如:m = 4 二进制表示:0000 0100,m-1 二进制表示:0000 0011),如果桶的数目不是2的整数次幂,就会出现有些桶不会被选中,如果后来又有新的键值对选择这个桶,就会发生hash冲突。

 基本过程如下(与运算法)图1.7
假设当前 B=4 即桶数量为2^B=16个,要从map中获取k4对应的value

 图1.7

  1. 根据key值算出哈希值
  2. 取哈希值低位B 位与m-1(m表示桶的数量)与运算确定bucket位置
  3. 查找该key是否已经存在,如果存在则直接更新值
  4. 如果没找到将key,将key插入
  5. 如果当前桶元素已满,会通过overflow链接创建一个新的桶,来存储数据。

注:如果查找不到,也不会返回空值,而是返回相应类型的0值。 

查找过程如图1.8 所示:

 图1.8

hash冲突


当有两个或以上数量的键被哈希到了同一个bucket时,我们称这些键发生了冲突。

解决hash冲突的方法有两种,第一种开放地址法 , 第二种: 链地址法 , Go语言使用链地址法也叫拉链法来解决冲突键

拉链法:

由下图 1.9 {k1,v1} 和 {k2,v2}被hsah到同一个桶(bucket 1)中, {k1,v1}在bucket 1中,由于k2中的tophash和k1中的tophash相同便会在该桶的后面链接一个新桶,用于存放k2, 查找{k2,v2}时也是先找到1号桶,经比较k不相等,就会顺着链表往后面查,从而找到键值k2。

                                               图1.9
图1.10 为冲突后的map

图1.10 

bucket数据结构指示下一个bucket的指针称为overflow bucket,意为当前bucket盛不下而溢出的部分。哈希冲突并不是好事情,它降低了存取效率。

负载因子
负载因子用于衡量一个哈希表冲突情况,公式为:负载因子 = 键数量 / bucket数量

哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:

哈希因子过小,说明空间利用率低
哈希因子过大,说明冲突严重,存取效率低


扩容


扩容的条件
为了保证访问效率,当新元素将要添加进map时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。
触发扩容的条件有二个:

负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个。
overflow数量 > 2^15时,也即overflow数量超过32768时。

增量扩容
当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。 

考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。

下图展示了包含一个bucket满载的map。

当map种存储了7个键值对,只有一个bucket.此负载因子为7.再次插入数据时发生扩容操作,扩容之后将新插入键写入bucket.

当第8个键值对插入时,触发扩容,扩容示意图如下:

hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的bucket中。

后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets。

二、map 特性


map 为什么无序?


Go 底层实现并不保证 map 遍历顺序稳定,请大家不要依赖 range 遍历结果顺序。

主要原因有2点:

1. map在遍历时,并不是从固定的0号bucket开始遍历的,每次遍历,都会从一个随机值序号的bucket,再从其中随机的 cell 开始遍历。


2. map遍历时,是按序遍历bucket,同时按需遍历 bucket 中和其 overflow bucket中的cell。但是map在扩容后,会发生key的搬迁,这造成原来落在一个bucket中的key,搬迁后,有可能会落到其他bucket中了,从这个角度看,遍历 map 的结果就不可能是按照原来的顺序了。


3. map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map。

map 非线程安全
Go 官方认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),决定了不支持,若并发读写 map 直接报错。

在扩容中怎么保障旧的数据和新的数据不会丢失?
  1. 双重写入: 在扩容期间,新插入的数据可能同时写入旧的哈希表和新的哈希表中。这样可以确保即使在扩容过程中,数据也不会丢失。

  2. 重新哈希现有数据: 当创建新的哈希表时,需要将旧哈希表中的所有现有数据重新哈希并迁移到新的哈希表中。这个过程通常在扩容时发生,并在完成前不会删除旧的哈希表。

  3. 复制旧哈希表: 在某些实现中,扩容可能涉及创建旧哈希表的一个副本,并在新哈希表完全就绪后,再逐步将数据迁移到新哈希表中。

  4. 读写锁: 使用读写锁(或共享/独占锁)来控制对哈希表的访问。在扩容期间,可以允许多个读取操作同时进行,但写入操作需要独占访问。

  5. 渐进式扩容: 一些 map 实现采用渐进式扩容,即逐步迁移数据到新的哈希表,而不是一次性迁移所有数据。这样可以减少扩容对性能的影响,并允许在扩容过程中继续处理读写请求。

  6. 保留旧哈希表: 在数据迁移到新哈希表的过程中,旧哈希表仍然保留,直到确认新的哈希表完全包含所有数据。这样,即使在扩容过程中发生错误,也可以从旧哈希表中恢复数据。

  7. 使用版本号: 在一些实现中,每个 map 可能有一个版本号或时间戳。在扩容期间,通过检查版本号来确保操作的是正确的数据集。

  8. 并发安全的数据结构: 使用并发安全的数据结构和算法,确保在扩容期间对 map 的访问是安全的。

  9. 测试和验证: 在扩容逻辑实现后,进行彻底的测试和验证,确保在各种情况下数据都不会丢失。

  10. 用户文档和约定: 在某些库中,可能要求开发者在 map 扩容期间遵循特定的约定或模式,以确保数据安全。

 总结

 祝大家生活愉快~

文献引用:

go/src/runtime/map.go at master · golang/go · GitHub

go语言map底层学习_golang map bucket-CSDN博客

  • 36
    点赞
  • 50
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值