Go map 内部原理

238 阅读31分钟

Note:本文讨论均基于 Go version go1.22 windows/amd64。

map(称字典映射关联数组),被定义为存储键值对集合的数据结构,其中单个键与单个值相关联。它提供了一种基于唯一标识符(键)存储和检索数据的有效方法。Go 语言中提供了一个实现了哈希表的内置map类型[1]

既如此,我们就从什么是哈希表开始本次的讨论。

1. 哈希表

哈希表,又称散列表,一种以「key-value」形式存储数据的结构。所谓以「key-value」形式存储数据,是指任意的键 key 都唯一对应到内存中的某个位置。只需要输入查找的 key,就可以快速地找到其对应的 value[2]

它是一种特别的"存储柜"。想象一下,现在有一排柜子,每个柜子都有编号,例如01、02等。我们要存放一些东西,像是某人的卡包或者其他东西,这或许就需要一个柜子将东西放进去。为了方便以后找到这个东西,我们不直接把它放到柜子里,而是用一个"规则"来计算这个东西适合放在哪个编号的柜子里。这个"规则"就是哈希函数,它会根据将要放入东西的特征计算出一个数字(或者字符串),这个数字能够对应某个柜子的编号。当我们想找某人的卡包时,只需要使用哈希函数,再次计算出这个东西对应的柜子编号,然后直接去对应的柜子里翻找了,不用从头到尾一个一个查找。

最简单的哈希表就是一个数组,数组里每个元素槽位相当于一个柜子(称为哈希桶 Bucket),第一个数组元素槽位被号编为0,以此类推。当一个「key-value」中的 key 经过哈希函数计算后,再对数组元素槽位个数取模,就能得到该「key-value」对应的数组元素槽位索引,也就是第几个柜子[3]

但由于哈希桶的数量有限,多个 key 可能会找到同一个哈希桶(称为哈希冲突)。这时需要利用一些方法,如拉链法或开放寻址法,来避免冲突。

本文中我们只讨论拉链法(因为 Go 用的这个)。以链表为例,拉链法的解决方式是在哈希表的哈希桶中存储一个链表,将冲突的元素链接在一起,从而解决哈希冲突。(Note:Go 1.24 版本会追加使用 Swiss Table(开放寻址) 实现的map[4]

  • 存储:每个哈希桶存储一个链表,所有哈希值相同的 key 都会被添加到这个链表中;
  • 插入:当插入一个新元素时,若目标桶已有其他元素,则将该元素添加到链表的末尾(或开头);
  • 查找:查找元素时,根据 key 计算出哈希值,然后到目标桶的链表中顺序查找该元素;
  • 删除:在目标桶中查找到该元素并从链表中删除。

拉链法的性能依赖于负载因子(即元素数量 m 与哈希桶数量 n 的比值)。在负载因子较低时,容器长度短,查找效率较高;但随着负载因子增加,容器长度增长,查找效率下降。当负载因子超过阈值时,哈希表会自动扩容以保持较高效率。

2. Go map运行时结构

map在运行时中是由一个 hamp 结构体表示的,它相当于是map的头部结构(哈希表头部),其中包含了map的数据信息,如元素数量、桶数量、溢出桶数量等。(每创建一个新的map实例其实都是在创建一个hmap

type hmap struct {
    count     int       // 键值对数量
    flags     uint8     // 哈希表的状态标志位
    B         uint8     // 桶数量的对数(桶数量为 2^B)
    noverflow uint16    // 大概的溢出桶数量
    hash0     uint32    // 哈希种子
​
    buckets    unsafe.Pointer // 指向桶数组,容量为2^B(若count == 0,此值可能为nil)
    oldbuckets unsafe.Pointer // 指向旧桶数组,容量为2^(B-1)(仅在扩容时使用)
    nevacuate  uintptr        // 记录已经迁移的桶数量(仅在扩容时使用)
​
    // 可选字段
    extra *mapextra 
}

hmap中的buckets字段指向一个bmap桶数组(bmap是哈希桶,后面会讨论)。桶数组的大小以 2 的幂次形式增长,这样可以确保每次扩容时,桶的数量翻倍。这种增长方式有助于减少扩容频率,同时保证查找效率。同时可以利用位运算来快速计算桶索引。具体来说,通过keyhash&(2^B -1)可以快速得到桶的索引,而不需要进行复杂的除法运算。这在性能上是非常高效的[5]

在认识hmap之后,我们还需要再认识一个很重要的结构 maptypemaptype并不是map在运行时中的表示,它属于 Go 类型系统,用于表示 map 类型元信息 ,其中包含了与 map 类型相关的各种元数据,比如键值类型、存储布局、对齐信息等。每种map类型在编译期都会生成一个唯一的maptype,并在运行时共享。

type MapType struct {
    Type            // Type提供类型的最基本的描述
    Key        *Type  // 键的类型信息
    Elem       *Type  // 值的类型信息
    Bucket     *Type  // bmap 的结构类型信息
    // Hasher 是一个哈希函数。(key,hmap.hash0) -> hash
    Hasher     func(unsafe.Pointer, uintptr) uintptr
    KeySize    uint8   // 键的大小
    ValueSize  uint8   // 值的大小
    BucketSize uint16  // bucket 的大小
    Flags      uint32  // 标志位
}

maptypehamp两者的区别

我们可以举一个简单的例子来说明。

m1 := make(map[any]any)
m2 := make(map[int]int)
...

hmap:运行时中,m1 和 m2 各自都拥有一个hmap,其中存储了桶、哈希种子等具体数据,但与具体键值类型无关;

maptype:m1 和 m2 的类型信息(包括键值类型)则由maptype承载:

  • 类型map[any]anymaptype包含键和值为interface{}的类型描述、哈希函数函数;
  • 类型map[int]intmaptype则包含键和值为int的类型描述和针对int的高效哈希函数。

hmap是动态结构,管理实际数据,而maptype是静态定义,用于指导hmap如何操作数据。运行时中,Go 不使用interface{}来实现哈希表。所以要想实现一个适用于(几乎)任何类型的 Go 哈希表,就需要使用maptype来指导hmap操作数据[5]。比如向map插入元素时使用的运行时函数mapassign

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer

既然已经有了一个*hmap,为什么还需要一个*maptype

需要*maptype是因为它提供了特定类型的逻辑和元信息,使通用的*hmap结构能够支持不同键值类型的map。虽然hmap负责存储运行时的数据(如桶、哈希种子等),但它与具体的键值类型无关。而maptype则是编译时生成的结构,用于描述特定的map类型,包括键值的类型信息、哈希函数、比较函数等。

比如例子中,m1 和 m2,它们的hmap结构几乎相同,但各自的maptype是不同的。m1 的maptype包含处理interface{}键的哈希函数,而 m2 的maptype则包含处理int键的哈希函数。这种分工让一个通用的hmap能在运行时高效操作不同类型的map

简而言之,maptype是编译时的"特制配方",指导hmap在运行时处理键值类型的正确方式,从而实现 Go 的泛型map

3. Go map与哈希表

在本文的第一小节中,我们讨论了什么是哈希表。从中可以拆分出哈希表实现的四个部分:哈希函数存储解决冲突以及扩容

3.1. 哈希函数

哈希函数的质量决定了映射函数在 O(1) 附近运行的可能性。当与哈希表一起使用时,哈希函数具有两个重要属性。稳定性,哈希函数必须稳定。当给定相同的键时,哈希函数必须返回相同的结果。如果不是,则无法找到放入哈希表中的内容。抗碰撞性,哈希函数应该分布良好。当给定两个几乎相同的键时,结果应该截然不同。 这很重要,原因有二。首先,哈希表中的值应该均匀分布在各个存储桶中,否则访问时间就不是 O(1)。其次,由于用户可以控制哈希函数输入的某些方面,他们可能能够控制哈希函数的输出,从而导致分布不佳[5]

在 Go 语言中,运行时使用了两个核心哈希函数:aeshashmemhash

aeshash用于支持 AES指令集 的平台,通过硬件加速计算哈希值[6][7]。其利用了现代 x86 处理器的 AES 指令扩展,如aesenc指令,加速 AES 执行一轮加密。且aeshash未使用完整的 AES 加密流程,仅执行了 3 轮加密[8]。大致逻辑为[9]

  • 小于等于 16 字节的数据,进行 3 轮 AES 加密;
  • 17 到 128 字节的数据,每个 16 字节块进行 3 轮 AES 加密;
  • 128 字节以上的数据,每个 16 字节块进行 3 轮 AES 加密,并且在最后还会进行额外的 3 轮加密。

若想更具体地了解aeshash,推荐去了解 AES 加密流程,再去阅读汇编 aeshashbody

memhash用于不支持 AES指令集 的平台。Go 1.17 后,其参考了 wyhash 的设计思想(如随机常量和混合策略)[10][11]。在源码中,memhash的回退实现可见于 memhashFallback。除此以外,我们也可以在官方库hash/maphash中找到 Go 版本的 wyhash,如下:

// 其中 rX 一类函数表示读取 X 字节,
// 比如 r3 ,表示读取 3 字节。
// mix 函数通过 64位乘法和高低位异或 扩散数据位信息,
// 确保随机性和哈希分布均匀。 
func wyhash(key []byte, seed, len uint64) uint64 {
    p := key
    i := len
    var a, b uint64
    // const m1 = 0xa0761d6478bd642f
    seed ^= m1
​
    if i > 16 {
        if i > 48 {
            seed1 := seed
            seed2 := seed
            for ; i > 48; i -= 48 {
                // const m2 = 0xe7037ed1a0b428db
                // const m3 = 0x8ebc6af09c88c6e3
                // const m4 = 0x589965cc75374cc3
                seed = mix(r8(p)^m2, r8(p[8:])^seed)
                seed1 = mix(r8(p[16:])^m3, r8(p[24:])^seed1)
                seed2 = mix(r8(p[32:])^m4, r8(p[40:])^seed2)
                p = p[48:]
            }
            seed ^= seed1 ^ seed2
        }
        for ; i > 16; i -= 16 {
            seed = mix(r8(p)^m2, r8(p[8:])^seed)
            p = p[16:]
        }
    }
    switch {
    case i == 0:
        return seed
    case i < 4:
        a = r3(p, i)
    default:
        n := (i >> 3) << 2
        a = r4(p)<<32 | r4(p[n:])
        b = r4(p[i-4:])<<32 | r4(p[i-4-n:])
    }
    // const m5 = 0x1d8e4e27c47d124f
    return mix(m5^len, mix(a^m2, b^seed))
}

wyhash接受输入字节数组、种子和长度,通过分块处理数据并混合不同的常量(上述m1m2),生成哈希值。

  • 长度超过 48 字节的数据,以 48 字节为一组进行多次混合;
  • 长度介于 16 至 48 字节之间的数据,以 16 字节为单位处理;
  • 更短的数据,使用特殊逻辑处理剩余字节。

3.2. 存储

哈希表的第二部分是数据的存储。经典的哈希表是一个桶数组,每个桶包含一个指向键值条目数组的指针。Go 亦是如此,其桶结构如下:

type bmap struct {
    // tophash 通常包含每个键的哈希值的最高字节,
    // 如果 tophash[i] < minTopHash,
    // 则 tophash[i] 表示状态。
    tophash [bucketCnt]uint8
    // 接下来是 bucketCnt 个键,然后是 bucketCnt 个元素
    // 注意:将所有键放在一起,然后将所有元素放在一起,会使代码稍微复杂一些,
    // 但这允许我们消除某些情况下所需的填充,例如 map[int64]int8。
    // 最后是一个溢出指针。
}
​
const (
    // 一个桶可以容纳的最大键值对数量
    bucketCntBits   = 3
    bucketCnt       = 1 << bucketCntBits // 8
)

bmap表示哈希桶,用于存储键值对,其定义中只显式地声明了一个tophash字段,但实际上它还包含了其他未在定义中直接出现的成员。这些成员包括键值对溢出指针。这些成员的具体布局和大小是在编译期间由编译器确定的。根据编译器源码函数 MapBucketType 可以推测出实际运行时中bmap的具体结构为[12]

// 此结构与 Go1.14 之前有所区别,详情可对比:
// https://github.com/golang/go/blob/go1.13.15/src/cmd/compile/internal/gc/reflect.go#L82
// 变化原因的相关 issue 和 commit:
// https://github.com/golang/go/issues/30439
// https://github.com/golang/go/commit/ba97d0d84d4272c4b8497fa6af2eab1580b62d44
type bmap struct {
    tophash     [bucketCnt]uint8
    keys        [bucketCnt]keytype
    elems       [bucketCnt]elemtype
    // pad      uintptr
    overflow    unsafe.Pointer // 溢出桶指针,用于解决哈希冲突
}
​
const bucketCnt = 1 << 3 // 8

bmap中的tophash[i]用于存储 key 哈希的高 8 位比特,以便快速过滤不匹配的哈希值(即使哈希值不同,也可能被映射到同一个桶中)。需要注意,当tophash[i]小于 5 时,其表示对应槽位的状态。所以,若某个槽位中存有元素且没有被迁移(非空槽位),则该槽位对应tophash[i]的值必须大于等于minTopHash才能表示非空槽位。

const (
    // 可能的 Tophash 值
    emptyRest      = 0 // 当前槽位为空,且桶后续所有槽位也都是空的
    emptyOne       = 1 // 当前槽位为空
    evacuatedX     = 2 // key/elem 合法。条目已经迁移到新表的第一个部分
    evacuatedY     = 3 // 同上,表示条目已迁移到新表的第二个部分
    evacuatedEmpty = 4 // 槽位为空,且所属桶已完成迁移
    minTopHash     = 5 // 一个正常状态非空槽位的最小 tophash 值
)

关于 key/elem,从具体的bmap结构中我们发现,map中 key 与 key 之间以及 elem 与 elem 之间是连续放置在一起的。这种布局的权衡是牺牲了一些键值对的紧密性。虽然这样可能稍微增加某些情况下的访问时间,但这能消除某些情况下所需的填充,使得大部分操作都能从紧凑的缓存友好布局中获益。

map[int8]int64为例,key 类型为int8,占用 1 字节,elem 类型为int64,占用 8 字节。若采用键值对紧密存储(键和值交替存放),每个 elem 需要对齐到 8 字节边界(因为int64的对齐需求较高),导致键值之间需要额外填充 7 字节。为了满足内存对齐要求,每对键值可能需要占用 16 字节。(可参考图片

所以,一个普通的bmap的实际内存布局应该图下图所示。

然后就是目标槽位的定位问题。我们直接来看map的指派函数 mapassign

    func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
        ...
        hash := t.Hasher(key, uintptr(h.hash0))
        // 在写操作开始前,设置 hashWriting 标志,确保线程安全
        h.flags ^= hashWriting
        if h.buckets == nil {
            h.buckets = newobject(t.Bucket) // newarray(t.Bucket, 1)
        }
    ​
    again:
        // hash&(2^B -1) 计算目标桶位置
        bucket := hash & bucketMask(h.B)
        ...
        // 获取目标桶
        b := (*bmap)(add(h.buckets, bucket*uintptr(t.BucketSize)))
        
        // 保留key hash高8位,且保证值最小为 5
        top := tophash(hash)
    ​
        // 用于记录将要插入的 top、key、elem 的位置
        var inserti *uint8
        var insertk unsafe.Pointer
        var elem unsafe.Pointer
        
    bucketloop:
        // 外循环处理溢出桶,
        // 若当前桶没有匹配或可用位置时,跳转到下一个溢出桶继续查找
        for {
    ​
            // 内循环遍历当前桶的8个槽位,查找匹配的键或者记录空槽位,
            // 如果键匹配,直接插入并退出。
            for i := uintptr(0); i < bucketCnt; i++ {
                if b.tophash[i] != top {
                    // 若 b.tophash[i] 为空(即 b.tophash[i] <= 1),
                    // 且 inserti 也为空时,则记录 第一个可插入 的位置。
                    // Note: b.tophash[i] 为空有两个情况,原本就是空,或者删除后为空。
                    // 记录 第一个可插入 的槽位后,需要继续遍历后面所有槽位,
                    // 判断此次操作是插入,还是更新。
                    if isEmpty(b.tophash[i]) && inserti == nil {
                        inserti = &b.tophash[i]
                        insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
                        elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
                    }
                    // 若 b.tophash[i] == 0,
                    // 说明当前槽位为空,
                    // 且桶后续所有槽位也都是空的。
                    if b.tophash[i] == emptyRest {
                        break bucketloop
                    }
                    continue
                }
                // 进入这里说明 b.tophash[i] == top,
                // b.tophash[i]对应的槽位非空。
                
                k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
                ...
                // 若当前b.tophash[i]对应槽的 k 与需要插入的 key 不同,
                // 则继续寻找槽位。
                if !t.Key.Equal(key, k) {
                    continue
                }
                // 到此说明本次操作是更新。
                ...
                elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
                goto done
            }
            
            ovf := b.overflow(t)
            if ovf == nil {
                break
            }
            b = ovf
        }
    ​
        ...
    done:
        ...
        return elem
    }

一个哈希桶内,定位目标槽位的过程很简单,就只需要一个单循环遍历桶内所有槽位即可。具体来说:

  • 会使用三个指针变量insertiinsertkelem记录将要插入的 top、key、elem 位置的地址;
  • 遍历寻找桶内合适的槽位(假设不涉及溢出桶),比如槽位i
  • 使用inserti记录tophash[i]的地址;
  • 至于insertk/elem,则会根据实际bmap中键/值大小来计算槽位i对应的键/值地址偏移。

insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))为例,这个过程是在进行指针运算,通过当前桶b起始地址+偏移得到目标槽位对应的 key 的地址。其中 dataOffset 指的是bmap.tophash占用的字节。我们将b起始地址+dataOffset就能得到桶中第一个用于存储 key 的偏移地址。若要找到第 i 个用于存储 key 的偏移地址,就需要再加上 i 个 key 大小的偏移,即i*uintptr(t.KeySize)elem同理。

判断某个槽位是否合适。首先比较tophash[i]与当前top是否匹配:

  • 匹配,则表示tophash[i]对应槽位非空,需要判断槽位中的键与当前需要插入的 key 是否相等。不相等时,继续寻找槽位,反之则意为本次插入操作实际是一次更新操作;
  • 不匹配,则快速跳过。但若tophash[i] <=1,意味着当前槽位为空,则记录第一次遇到的空槽位。然后继续扫描后续槽位。如果后续没有发现其他槽位的键值与当前 key 相同,那么先前记录的槽位即是目标槽位。

3.3. 解决冲突

map解决哈希冲突的方式就是我们最开始介绍哈希表时所说的 (单链)拉链法。产生冲突时,元素按插入顺序依次被放置在bmap的 8 个槽位中,当bmap槽位填满后,便会创建并在末尾链接一个新的bmap,即溢出桶。

而溢出桶有一个小优化。在map的运行时结构hmap中,有一个可选字段extra,其结构如下:

    type mapextra struct {
        // 如果 key 和 elem 都不包含指针并且都是内联的,
        // 那么将标记 bucket 类型为不包含指针。这样可以避免GC扫描这样的映射。
        // 然而,bmap.overflow 是一个活跃指针,
        // 所以需要将指向所有溢出桶的指针存储在 hmap.extra.overflow 
        // 和 hmap.extra.oldoverflow 中。
        // overflow 和 oldoverflow 只有在 key 和 elem 不包含指针的情况下才使用。
        // overflow 包含 hmap.bucket 的溢出桶。
        // oldoverflow 包含 hmap.oldbucket 的溢出桶。
        // 间接允许在 hiter 中存储指向切片的指针。
        overflow    *[]*bmap
        oldoverflow *[]*bmap
    ​
        // nextOverflow 持有一个指向空闲溢出桶的指针
        nextOverflow *bmap
    }

mapextra的设计是为了优化垃圾回收的效率。如果哈希表中的键或值包含指针,GC 必须扫描整个哈希表,包括桶和溢出桶,确保其中的指针被正确标记;如果键和值都不包含指针,GC 无需扫描这些桶,因为其中的数据不会引用其他内存对象,可以直接跳过,以减少 GC 的扫描工作量。因此,对于没有指针的键值对,可以通过标记桶类型为"无指针"来告知 GC,从而避免对这些桶的扫描,显著提升 GC 的性能[13]

但是要实现这种优化,现在的bmap结构不能支撑。因为它的溢出字段overflow是一个指针类型。

    type bmap struct {
        tophash     [bucketCnt]uint8
        keys        [bucketCnt]keytype
        elems       [bucketCnt]elemtype
        overflow    unsafe.Pointer // 溢出桶指针
    }

当`bmap`中的`overflow``unsafe.Pointer`时,GC 无法跳过扫描。因此,我们需要将`unsafe.Pointer`类型换成一个`uintptr`类型,来标记桶类型为"无指针"type bmap struct {
        tophash     [bucketCnt]uint8
        keys        [bucketCnt]keytype
        elems       [bucketCnt]elemtype
        overflow    uintptr
    }

若你阅读了编译器源码函数 MapBucketType 就能发现,当 keytype 或 elemtype 的类型包含指针,或者keytype 或 elemtype 的类型大小超过 128 字节,bmapoverflow字段类型就会被设置为unsafe.Pointer;反之,则被设置为uintptr

此后,每当map创建了一个溢出桶后,该溢出桶就会被追加到mapextra.overflow中(针对无指针bmap)。详细可阅读hmap的方法 newoverflow

我们还有一点需要补充。当初始化一个大小超过 2^4 的map时,Go 会额外为其预留一些溢出桶,这些溢出桶紧跟在主桶数组后面,并且规定了若溢出桶的溢出指针overflow == nil,则表示还有剩余的预留溢出桶,反之则没有。关于本段落的描述,可以阅读函数 makeBucketArray 和方法 newoverflow 进一步了解详细情况。

3.4. 扩容

map在每次插入操作以及删除操作都会判断是否需要扩容[14][15]。相关代码如下:

    if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        hashGrow(t, h)
        goto again // Growing the table invalidates everything, so try again
    }

从上述代码中,我们可以明确两点:

  • map正处于扩容中,不会再次扩容 (由h.growing()判断) ;
  • map会触发扩容的两种情况:① 负载因子超过阈值,② 溢出桶太多

我们先来讨论扩容的第一种情况,负载因子超过阈值。 如果哈希表的负载因子超出预设的上限(Go 中是 6.5),扩容则是将桶数量变为原来的两倍hamp.B += 1[16][17]。在扩容过程中,Go 创建一个大小为原先两倍的新桶数组,并将其赋值给hmap.buckets,同时将原桶数组赋值给hmap.oldbuckets。迁移状态hmap.nevacuate被重置为 0,用于记录迁移的起始位置;hmap.noverflow 也被重置,以统计新的溢出桶。为避免并发迭代器读取数据时出现错误,扩容时会清除hmap.flagsiterator位,并设置oldIterator位为 1,指示迁移时使用旧桶数据。

注意,在扩容创建新桶数组时,若h.B超过 4,则会预留溢出桶,溢出桶的大致数量为2^(h.B-4)[17]。若预留了溢出桶,hamp.extra.nextOverflow也会指向预留溢出桶中的第一个溢出桶,且最后一个溢出桶的溢出指针会指向hmap.buckets的第一个主桶[18](小节 3.3 我们有提及相关信息)。

更详细的扩容细节请查看:func hashGrow(t *maptype, h *hmap)

容量翻倍后,map就需要将旧桶元素复制到新桶数组中。若详细阅读了源码函数hashGrow后,我们就会在函数最后面的注释中了解到,Go map 的这个复制过程是通过函数 growWorkevacuate 渐进式完成的。这两个函数在map的每次插入、删除操作中都会出现,即渐进式迁移元素的整个过程被分摊到了对map的每次插入或者删除操作中了[14][15]

在讨论元素迁移的详细过程之前,我们需要先了解哈希表扩容后,对旧元素迁移、rehash 的相关内容。

假设有个哈希表(容量为 2 的幂次),初始桶数量为 4,我们使用变量numBuckets来表示其桶数量,那么就有numBuckets= 4 => 0b100 (二进制),也就有numBuckets-1 = 3 => 0b011。于是我们就可以通过bucketIndex = keyhash&(numBuckets-1) = keyhash & 0b011来获取哈希表的桶索引(只取哈希值的最低 2 位来确定桶的索引)。初始情况下的桶分布(bucketIndex范围是 0 ~ 3):

  • keyhash...xx00 -> 桶0
  • keyhash...xx01 -> 桶1
  • keyhash...xx10 -> 桶2
  • keyhash...xx11 -> 桶3

现在,哈希表翻倍扩容,扩容后桶数量为 8,我们使用变量newNumBuckets来表示其新桶数量,于是有:

  • newNumBuckets = 8 => 2^3 = 0b1000 (二进制)
  • newNumBuckets-1 = 7 => 0b0111

哈希表容量从 4 变为了 8(即 2 的 2 次幂变成了 2 的 3 次幂),先前用于计算桶索引的公式就需要变更为newBucketIndex = keyhash & (newNumBuckets-1) = keyhash & 0b0111。即新桶索引需要取哈希值的最低 3 位来确定桶分布(newBucketIndex范围是 0 ~ 7):

  • keyhash...x000 -> 桶0
  • keyhash...x001 -> 桶1
  • keyhash...x010 -> 桶2
  • keyhash...x011 -> 桶3
  • keyhash...x100 -> 桶4
  • keyhash...x101 -> 桶5
  • ...

因此,迁移元素时,我们要根据新的索引公式计算元素需要被迁移到哪个新桶。比如:

  • keyhash...x101的元素
    • 旧 桶索引:桶1(根据keyhash最后两位计算索引);
    • 新 桶索引:桶5(根据keyhash最后三位计算索引);
  • keyhash...x001的元素
    • 旧 桶索引:桶1
    • 新 桶索引:桶1

从这个例子中我们发现,旧桶1中的元素被迁移到了两个新桶(新桶1,新桶5)中。这里面其实有个规律,新桶索引要么与旧桶索引相同,要么等于旧桶索引+4

我们来讨论其原理。哈希表容量翻倍后,最大桶索引从 3 变成了 7,这期间其实就相当于增加了一个高位的比特位(3 => 0b00117 => 0b0111),可以发现本例新增高位是第三位0b0100。以 keyhash ...x101...x001为例:

  • 哈希表容量为 4,索引公式为bucketIndex = keyhash & 0b0011
    • ...x101:目标桶为 桶1
    • ...x001:目标桶也为 桶1
  • 哈希表容量为 8,索引公式为bucketIndex = keyhash & 0b0111
    • ...x101:目标桶为 桶55 = 1+2^2,即0b0101 = 0b0001 + 0b0100);
    • ...x001:目标桶为 桶1

能够发现,哈希表扩容后,新桶索引的计算结果会根据新增高位是否为 1 而与旧桶索引计算结果产生变化。若 keyhash 对应新增高位的比特位是 0,则新桶索引与旧桶一致;若对应新增高位的比特位是 1,则新桶索引等于旧桶索引+ 0b01000b0100十进制等于 4)。

若使用newbit来表示这个新增的高位,于是就有newBit = 0b0100,且newBit == numBuckets == 4。现在我们规定:

  • 索引与旧桶索引相同的新桶为x
  • 索引等于旧桶索引+newBit的新桶为y

我们回到 Go map 中,来看看map具体是如何迁移的。以下为迁移函数evacuate的大致内容:

    func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
        // oldbucket 为指定的需要迁移的旧桶索引
    ​
        // 获取指定旧桶
        b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.BucketSize)))
        // 获取新增高位
        newbit := h.noldbuckets()
        if !evacuated(b) {
            // 若指定旧桶没有被迁移
            
            // 以下为获取目标新桶,即我们之前规定的 桶x 和 桶y。
            // type evacDst struct {
            //  b   *bmap         
            //  i   int            
            //  k,e unsafe.Pointer
            // }
            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() {
                // 若 map 是翻倍扩容。
                // 我们之后再讨论为什么会有这个判断。
                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))
            }
    ​
            // 遍历旧桶及其所有溢出桶
            for ; b != nil; b = b.overflow(t) {
                // k/e 是旧桶或其溢出桶的第一个 key/elem
                k := add(unsafe.Pointer(b), dataOffset)
                e := add(k, bucketCnt*uintptr(t.KeySize))
                // 遍历桶中所有元素
                for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.KeySize)), add(e, uintptr(t.ValueSize)) {
                    top := b.tophash[i]
                    if isEmpty(top) {
                        b.tophash[i] = evacuatedEmpty
                        continue
                    }
                    if top < minTopHash {
                        throw("bad map state")
                    }
                    k2 := k
                    if t.IndirectKey() {
                        k2 = *((*unsafe.Pointer)(k2))
                    }
                    // useY == 1 时表示元素迁移到桶y,
                    // useY == 0 时表示元素迁移到桶x,
                    var useY uint8
                    if !h.sameSizeGrow() {
                        hash := t.Hasher(k2, uintptr(h.hash0))
                        // 省略一些对非自反性键值的处理
                        // (例如浮点数中的 NaN 值)
                        ...
                        // 就像我们前面讨论的
                        // 根据新增高位决定旧桶元素需要迁移到桶x,还是桶y
                        if hash&newbit != 0 {
                            useY = 1
                        }
                    }
                    // 省略桶迁移状态标识的校验
                    ...
                    // evacuatedX + 1 == evacuatedY
                    // 其中 evacuatedX = 2,evacuatedY = 3
                    // 表示元素迁移状态。
                    b.tophash[i] = evacuatedX + useY 
                    // 迁移目标桶
                    dst := &xy[useY]
    ​
                    // bucketCnt = 8
                    // 此时表示目标桶已经装满,
                    // 需要为其创建溢出桶。
                    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 // copy pointer
                    } else {
                        typedmemmove(t.Key, dst.k, k) // copy elem
                    }
                    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.ValueSize))
                }
            }
            // 省略拆分迁移旧桶的溢出桶以及清除key/elem
            ...
        }
        // 省略更新迁移进度,详情见:
        // https://github.com/golang/go/blob/go1.22.0/src/runtime/map.go#L1278
        ...
    }

上述可见,map的迁移过程也比较简单。

  • 两个evacDst变量分别表示目标桶,即 新桶x 和 新桶y(变量会记录新桶地址,新槽位的 top 索引、key / elem 地址);
  • 由一个双循环遍历桶及溢出桶中所有槽位,若槽位非空,则根据其对应槽位 key 的哈希和新增高位newbit决定是否将该槽位元素迁移至目标桶y(由变量useY记录);
  • useY == 0,则目标桶是 新桶x,若useY == 1,则是 新桶y
  • 决定目标桶后,原槽位的tophash会被覆盖为迁移状态evacuatedX或者evacuatedY(由目标桶决定);

之后就是复制原槽位的 tophash、key、elem 到目标桶的目标槽位中了。当然还有我们在代码中省略了的一些其他流程,比如:拆分旧桶的溢出桶以及清除key/elem,和更新迁移进度等。若在意更加具体的迁移过程,则最好去阅读源码:evacuate

最初map只有在负载因子超过阈值时才会触发扩容。因此,在某些特殊情况下,比如持续不断地插入和删除元素(key 随机),这时map元素条目总量趋于稳定(载因子不会超过阈值),但内存使用量不断攀升。不断插入随机 key,相同哈希的 key 会被插入同一个哈希桶,产生哈希冲突。当桶槽装满时,map会在桶后创建并链接一个溢出桶,以存储新的 key,这个过程中就可能会产生大量溢出桶,且溢出桶不会被释放。而插入的 key 又会被持续删除,map载因子不会超过阈值。最终就会导致map元素条目总量稳定,但内存使用量不断攀升(积累溢出桶)[19]

所以,接下来我们讨论扩容的第二种情况,溢出桶太多。

溢出桶太多,且从未将其删除,这导致了缓慢的内存泄漏。删除溢出桶的问题在于迭代器。显而易见的方法是重新打包 key/elem 并消除未使用的溢出桶。但是,如果不中断迭代器,就无法操作键、值和溢出桶。于是 Go 官方重用了map的扩容增长机制,该机制已完善、经过充分测试,并且在迭代器存在的情况下是安全的。当map积累了足够多的溢出桶时,会触发map增长,但会增长为与以前大小相同的map。旧的溢出桶将留下来进行垃圾收集[20]

增长过程与第一种情况负载因子超过阈值时一样,但hmap.B没有变化,还是保持原来的大小。hmap.flags会额外添加sameSizeGrow标识位,代表map处于等量扩容。此外,迁移函数evacuate中,我们在讨论旧桶元素将要被复制到 新桶x 还是 新桶y 时,有看到这样一个判断:

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

在等量扩容中,hmap.B保持不变,因此不引入新的高位(newbit)。这意味着旧桶中的元素无需分裂,仅根据原索引规则迁移到 新桶x 中,避免了额外的复杂性。迁移逻辑的其余部分与负载因子扩容时的过程一致。

关于哈希表的补充。通常情况下,哈希表的时间复杂度为什么较低,甚至是O(1)?以及为什么随着负载因子增大,哈希表查找效率会逐步降低?

一般,哈希表的物理存储是一个数组[21]。数组在内存中是一块连续的存储区域,即数组的所有元素都紧密地排列在一起,每个元素的地址与数组的起始地址之间存在固定的偏移量。假设数组的起始地址为 base,每个元素的大小为 size,数组索引为 i,则数组中第 i 个元素的地址 iAdrr 可以直接通过公式计算得出:iAdrr = base + i*size。当通过索引访问数组时(例如 arr[i]),计算第 i 个元素地址的操作是一个数学公式的计算,不涉及循环或复杂的操作流程:

  • 直接从数组的起始地址 base 读取;
  • 根据索引 i 和元素大小 size 计算偏移量i*size
  • 将偏移量加到起始地址上,直接访问目标地址。

这些操作都是单步操作(或常数操作),不随数组的大小 N 而变化,因此我们通过索引访问数组的时间复杂度是 O(1)。但如果只知道数据或者数据中的部分内容,想在数组中找到这个数据,就可能要遍历整个数组,时间复杂度为 O(N)。

我们知道,哈希表中的数据是以「key-value」形式存储,它会根据 key 计算出一个哈希值,并将哈希值映射为数组下标,以快速在数组中查找到需要的 value。而整个过程的时间复杂度是 O(1)(哈希计算时间为常数,且无冲突的情况下)。

此外,由于哈希冲突不可避免,哈希表通常需要处理冲突。我们仍以 (单链)拉链法为例。链表不同于数组,它可以使用零散的内存空间存储数据。因为链表在内存中的数据不是连续的,链表中的每个数据元素都包含一个指向下一个数据元素的内存地址指针,所以要想在链表中查找一个数据,只能遍历整个链表。因此,链表的查找复杂度是 O(N)。在理想情况下,哈希函数完全均匀地把所有元素分布到每个哈希桶中,且每个桶的链表长度接近,那么平均链表长度为α=m/n(负载因子α,元素总数m,哈希桶总数n):

  • 元素查找成功
    • 查找到目标元素前,平均需要访问链表长度的一半节点α/2(概率分布的对称性),平均时间复杂度为 O(α/2);
    • 一些单步操作,时间复杂度为 O(1);
    • 查找成功的平均时间复杂度:O(1+α/2)
  • 元素查找失败
    • 查找失败意味着目标元素不在链表中,即遍历了整个链表,平均时间复杂度为 O(α);
    • 加上一些单步操作,查找失败的平均时间复杂度:O(1+α)

因此,随着负载因子增加,用于存储元素的链表变长,哈希表的查找时间会趋近于 O(N)。实际应用中,哈希表通常通过动态扩容限制负载因子,从而保证查找效率接近 O(1)。

最后我们以一个思考题结束。请问以下程序输出什么,为什么?重点解释为什么。

    // go 1.18 及以上
    func main() {
        m := make(map[any]int)
        m[nil] = 1
        println(m[nil])
    ​
        m2 := make(map[""]int)
        m2[""] = 2
        println(m2[""])
    }    

可参考的函数或链接: