Golang分享(四):map的底层原理1

0. 前言

这篇分享笔者继续回归Golang的底层源码,尝试着从what-how-why三个维度探讨Golang中重要集合map的底层实现,着重讲解map的底层数据结构及其增删改查的执行流程,不关心map的具体使用。

1. 什么是map

在计算机科学中,map是一个由一组key-value键值对组成的集合,并且每个key只能出现一次,不能重复。

1.1 散列函数

散列技术是在value的存储位置和key之间建立一个对应关系f,使得每个关键字key对应一个存储位置f(key),value则存储在f(key)的位置上,这个位置称之为桶(bucket),这个对应关系f则称之为散列函数,也叫做哈希(Hash)函数。即哈希函数的作用是将key哈希到其对应的桶中。

采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。Go语言中的map实现即采用散列表作为其底层数据结构。

在理想的情况下,不同的key通过散列函数计算出来的存储位置是不一样的,即,

f(key1) ≠ f(key2)

但实际上会出现不同的key通过散列函数计算出来的存储位置是一样的,即,

f(key1) = f(key2)

这种问题称作哈希冲突或者“碰撞”,即不同key被哈希到了同一个桶里。解决这种问题一般有两种方法:链表法和开放地址法。其中链表法是将发生碰撞的key穿成一个链表放在同一个桶里;开放地址法是指发生碰撞后,采取某种方法选择一个空桶将key放入其中。Go语言中的map实现采用链表法解决“冲突”。Go语言中一个桶里最多放8个key。如图1.1为链表法的示意图。
图1.1 链表法

好的哈希函数应该尽量避免碰撞的发生,因为冲突的发生会降低map的查找性能。以上图链表法为例,查询72的时候,需要在编号为0的桶中比较两次,才能查询成功。如果碰撞导致编号为0的桶中存放N个元素,那么查询该桶中的元素最坏的时间复杂度为O(N)。扩容其实是一种提升哈希表查询效率的方法,扩容可以将原哈希表中的元素重新分配至新桶中,减少新哈希表中每个桶里的元素个数,从而进一步提升哈希表的查询效率。

1.2 为什么桶的个数是2的整数次幂

常用的哈希函数有两种,分别是取模法和与运算法。

取模法就是用key和数组长度length取模得到一个桶的编号(数组下标),即,

index = key % length

与运算法就是用key和数组长度length - 1进行与计算得到一个桶的编号(数组下标),即,

index = key & (length - 1)

在计算机内部,采用与运算的速度会非常快,所以哈希函数通常采用与运算法。但是采用与运算法时,桶数组长度必须是2的整数次幂,也即桶的个数必须是2的整数次幂,主要有以下两个原因。

  • 原因1:不会出现空桶(主要原因)

    举一个反例,假如桶的个数是7,那么length - 1为6,其二进制表示为110,即最低位是0。那么无论key为多少,当key & length - 1 = key & (110)时,计算结果最低位恒为0。因为最低位不可能是1,所以计算得到的桶编号不可能是1,3,5等奇数。这些桶即为空桶,key在桶里分配不均,易发生碰撞,不能作为哈希函数。

    当桶的个数是2的B次幂时,length - 1的二进制表示为B个1。比如length = 8时,length - 1为7,7的二进制表示是111;比如length = 16时,length - 1为15,15的二进制表示为1111。因为每一位都是1,所以在做与运算时不会出现某个位置恒为0的情况,不可能出现空桶。且求与运算的到的结果中低B位表示桶的编号。

    低B位表示桶的编号

  • 原因2:key % length = key & (length - 1)

    也就是说,长度为2的n次幂时,模运算 % 可以变换为按位与运算。

    长度取4,key取9;则9 % 4 = 1;9的二进制是 1001,4-1 = 3,3的二进制是 0011。 9 & 3 = 1001 & 0011 = 0001 = 1。计算结果相同。

    长度取8,key取12;则12 % 8 = 4;12的二进制是 1100,8-1 = 7,7的二进制是 0111。12 & 7 = 1100 & 0111 = 0100 = 4。计算结果相同。

    当长度不为2的整数次幂时,该结论不成立。

    长度取5,key取9;则9 % 5 = 4,9的二进制是 1001,5-1 = 4,4的二进制是0100。9 & 4 = 1001 & 0100 = 0000 = 0。计算结果不同。

所以,当桶的个数是2的整数次幂时与运算法其实就是取模法。

2. 为什么用map

低时间复杂度是计算机科学中一个永恒的话题。理想情况下,map强大到其增删改查的时间复杂度均为O(1),因此在开发中map的使用非常频繁。

3. 桶的基本数据结构

笔者之所以从基本数据结构开始,是因为当我们明白了Go map底层用到的每个结构体以后,其实map的增、删、改、查流程大家可以猜到八九不离十。在阅读后续的具体执行流程源码时事半功倍。

本文代码版本均为go version go1.19.6 windows/amd64

bmap是桶对应的结构体。如下代码为bmap的源码,实现在src/runtime/chan.go中

// A bucket for a Go map.
type bmap struct {
  // tophash是一个长度为unit8的数组,所以一个桶里最多有8个键值对
  // tophash是hash值的高8位,发生碰撞时,多个key在同一个桶中,tophash可以用于快速定位桶内的键值对。
  tophash [bucketCnt]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.
}

在编译期间,根据bmap的英文注释(上边),bmap的结构体会被动态创建为以下结构体:

type bmap struct {
  tophash [bucketCnt]uint8     // 8个key的hash值的高8位,用于快速定位桶内的key
  keys    [bucketCnt]keytype   // 8个key
  elems   [bucketCnt]valuetype // 8个value
  padding uintptr              // 对齐使用,按照源码的注释可以省略。
  // 当发生碰撞时,一个桶里最多放8个键值对。
  // 当有第9个key被hash到该桶时,由于没有多余的位置,需要放到溢出桶
  // overflow即为指向溢出桶的指针,溢出桶和桶的内部结构是一样的。
  overflow *bmap
}

如下图所示为桶的内存布局,桶内存放元素超过8,因此需要链接一个溢出桶。溢出桶与常规桶的布局是一样的,其设计目的是为了减少扩容的次数,毕竟扩容非常影响性能。
bmap示意图
最上面蓝色区域的HOBHash即为每个对应key的hash值的高8位,也即tophash数组。

按照源码注释的解释,将所有key放在一起,然后将所有value放在一起可以省略padding,这个应该涉及操作系统,笔者不太清楚,留一个QA供大家讨论。

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

实际上,如果B大于4,那么哈希表需要分配的桶的个数其实大于24。因为当B>4时,系统会认为哈希表用到溢出桶的概率很大,系统会提前预分配2(B - 4)个溢出桶备用。这些溢出桶与常规的桶在内存上是连续的,只是常规桶当作常规桶使用,溢出桶在常规桶满时,链接在常规桶后边。

4. map的基本数据结构

hmap是map底层数据结构对应的结构体,实现在src/runtime/chan.go中。map类型的变量本质上就是一个指针,指向一个hmap结构体。所以不要再记map是引用类型了,也不用查go语言中引用和指针的区别了。

// A header for a Go map.
type hmap struct {
  count     int    // map中键值对的数目,调用len(map)返回该值
  flags     uint8    
  B         uint8    // 记录桶的数目,桶的数目为2^B,即桶数组长度为2^B
  noverflow uint16   // 使用的溢出桶的数量
  hash0     uint32   // 哈希种子

  buckets    unsafe.Pointer // 记录桶数组的位置,指向桶数组的指针
  oldbuckets unsafe.Pointer // 记录扩容阶段原桶数组的位置,指向桶数组的指针
  nevacuate  uintptr        // 记录渐进式扩容(后边讲)的进度,小于该编号的bmap已经被扩容至新桶中

  extra *mapextra // 可选字段,用于记录溢出桶的信息
}

mapextra的结构体源码实现在src/runtime/chan.go中,如下所示,其中主要记录溢出桶的相关信息。

type mapextra struct {
  overflow    *[]*bmap  // 指向已经使用的溢出桶数组
  oldoverflow *[]*bmap  // 指向扩容阶段旧桶使用的溢出桶数组
  nextOverflow *bmap  // 指向下个空闲溢出桶
}

继续往后看,在各个字段中笔者画了几张图帮助大家理解hmap结构体

4.1 B字段

B字段主要用于记录桶的数目,桶的数量是2B,也即桶数组的长度为2B,显然Go语言实现的map其散列函数采用与运算法,因此hash值的低B位表示桶的编号。一个map中最多容纳的键值对个数为loadFactor * 2^B个。**loadFactor为负载因子,它的含义是平均每个桶里的元素个数。**loadFactor为6.5,即平均每个桶里的元素个数为6.5个。如下述代码所示。

  // Maximum average load of a bucket that triggers growth is 6.5.
  // Represent as loadFactorNum/loadFactorDen, to allow integer math.
  loadFactorNum = 13
  loadFactorDen = 2

装载因子超过阈值6.5,即平均每个桶里的元素超过6.5就会触发扩容。

在64位的计算机中,一个key经过哈希以后会得到一个64位的hash值。该hash值的低B位即为key所在桶的编号,高8位(tophash)即为key在桶内的位置。

4.2 noverflow 字段与extra字段

  • noverflow:表示使用的溢出桶数量
  • overflow:指向已经使用的溢出桶数组
  • oldoverflow:指向扩容阶段旧桶使用的溢出桶数组
  • nextOverflow:指向下个空闲溢出桶

当B=5时,map有2的5次方=32个桶,但是B>4,系统会认为哈希表用到溢出桶的概率比较大,因此会提前分配2^(B - 4) = 2个溢出桶,且溢出桶和常规桶在内存上是连续的。假设此时每个常规桶都没有满,所以溢出桶被使用的个数为0,即noverflow = 0,extra字段中的nextOverflow指向下个空闲溢出桶。如下图所示。
不使用预分配溢出桶

假设2号桶满了,又来了一个key,该key被hash到2号桶,因为2号桶满了,所以2号桶需要链接一个溢出桶。一个溢出桶被使用,所以noverflow = 1;预分配的溢出桶1被使用,所以extra的overflow字段指向预分配的溢出桶1;nextOverflow字段指向下一个空闲溢出桶,即预分配的溢出桶2,如下图所示。
使用预分配溢出桶

4.3 oldbuckets字段和nevacuate字段

如果一个map中的键值对数量较多,在扩容时一次性迁移所有桶花费的时间是比较显著的。一种可行的办法是当需要扩容时,先分配足够多的新桶,在每次执行map的读写操作时,分批将旧桶中的所有元素迁徙到新桶中。所以需要oldbuckets来记录旧桶所在的位置,需要nevacuate来指示当前的扩容进度。当执行map读写操作时,如果监测到当前属于扩容阶段,会根据oldbuckets和nevacuate迁徙一部分旧桶中的元素至新桶,直到多次分批迁徙完毕。这种分批迁徙的方式称之为“渐进式扩容”,渐进式扩容可以避免一次性迁移花费过多时间所带来的性能抖动。

举个例子,帮助大家理解oldbuckets字段和nevacuate字段。

假设现在有一个map,该map只有1个桶,桶里的元素是7个,达到了翻倍扩容条件(平均每个桶里的元素个数超过负载因子loadFactor),那么在扩容前,内存布局如下。
map扩容前

扩容后,系统新分配两个桶给该map,buckets则指向新被分配的两个桶,oldbuckets指向原先的桶。nevacuate的值为0,表示接下来迁移编号为0的旧桶。内存布局如下所示
map扩容后
接下来的每次读写,会分批将旧桶中的7个元素迁移至两个新桶中,直到迁移完毕,oldbuckets指向nil。

5. QA

Q1:为什么桶的内存模型中,所有key放在一起,所有value放在一起?

Q2:桶的内存模型中多个key的前后关系是通过内存中的相邻关系指定的么,也即不需要next指针指向桶里的下一个key?

6. 参考

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值