Go map 底层原理(上) | 青训营

75 阅读6分钟

Go map

       Map是一种常见的数据结构,用于表示键值对之间的映射关系。它采用哈希算法来实现快速的插入、查找和删除操作。哈希表的底层通常由一个数组和一组哈希函数组成。通过将键映射到数组的索引位置,并使用哈希函数处理冲突,使得元素可以快速定位和访问。Map在Go语言中是一种内置的数据结构,提供了方便的操作方法。

       Go 语言同时使用了多个数据结构组合表示哈希表,其中 runtime.hmap 是最核心的结构体,让我们一起试着阅读go的源码。

哈希表

       我们先简单了解一下哈希表:

  1. 哈希函数:哈希函数将键映射到数组的索引位置。好的哈希函数应该具有均匀分布性和高效性,以避免冲突和提高访问效率。
  2. 冲突解决:由于不同的键可能映射到相同的索引位置,冲突是哈希表中的常见问题。常用的冲突解决方法包括链表法(使用链表存储冲突的键值对)和开放地址法(通过探测空槽的方式解决冲突)。
  3. 插入操作:通过哈希函数计算键的索引位置,将键值对存储到对应的索引位置。
  4. 查找操作:通过哈希函数计算键的索引位置,查找对应位置的值。
  5. 删除操作:通过哈希函数计算键的索引位置,删除对应位置的键值对。
  6. 负载因子:表示哈希表中已存储键值对数量与数组容量之比,用于衡量哈希表的装填程度。过高的负载因子可能导致冲突增加,影响性能。
  7. 哈希表的优势:哈希表具有高效的插入、查找和删除操作,平均时间复杂度为O(1)。它适用于需要快速访问和查找元素的场景,例如索引、缓存和唯一标识等。

数据结构

       一般的Map会包含两个主要结构数组和链表,链表的目的是为了使用链表法来解决hash冲突。Go语言解决hash冲突不是链表,实际主要用的数组(内存上的连续空间)。

       Go map有两个重要的结构体hmap和bmap。我们先来看源码。

  1. hmap
// 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
}

我们大致看一下他们的作用

  • count int:记录当前 map 中的存活元素数量。这个字段在执行 len() 内建函数时使用。
  • flags uint8:用于记录 map 的当前状态和标志位信息。
  • B uint8:桶(bucket)数量的对数,即 2^B。决定了哈希表中桶数组的大小。
  • noverflow uint16:近似的溢出桶(overflow bucket)数量。这个值用于估计溢出桶的个数。
  • hash0 uint32:哈希种子(hash seed),用于哈希函数的初始种子值。
  • buckets unsafe.Pointer:指向桶数组的指针,数组的大小为 2^B。如果 count 为0,则这个指针可能为 nil
  • oldbuckets unsafe.Pointer:扩容时用于放置先前的桶数组,大小为当前桶数组的一半。只有在进行扩容操作时才会非空。
  • nevacuate uintptr:用于记录迁移进度的计数器,值小于此计数器的桶索引表示已完成迁移的桶。
  • extra *mapextra:可选的附加字段,保存了 map 的一些额外数据。
  1. 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 [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,当第一次看这个结构体时我很疑惑。既然是一个bucket的底层实现,为什么结构体中没有存key和value的地方。bmap 这个结构并没有将字段定义出来,而在后面的赋值、访问等逻辑中,我们能看到 map 是通过计算偏移量来定位 key/value 的。这些详细的注释就像是定义了一个协议,我们可以抽象出一个新的结构体。 image.png        bucket并没有明确的定义,而是通过偏移量来操作,bmap是bucket的底层实现。在bmap中,key和value是分开存放的,形式为k1/k2/...v1/v2...(键值对紧密排列),而不是key/value/key/value/...(键值对交替排列)。这样做是为了节省空间。举个例子,如果有一个map[int64]int8,其中key占据8个字节,value占据1个字节,如果采用键值对交替排列的形式,需要考虑value对齐占用8个字节的空间,这样就会浪费7个字节的存储空间。

哈希函数

       哈希函数用于计算哈希值,使用哈希函数对键进行运算,将键的内容转换为一个整数,即哈希值。Go的哈希函数的选择取决于CPU的支持情况。如果CPU支持AES指令集,Go语言将使用AES哈希算法。否则,将使用MemHash算法。 image.png        tophash用于快速查找key是否在bucket中。在实现过程中,使用key的哈希值的高8位作为tophash值,并将其存储在bmap的tophash字段中。tophash字段不仅存储key哈希值的高8位,还存储一些状态值,用于表示当前桶单元的状态。

哈希冲突

       Go map当面临哈希冲突时,即不同的key放入了相同的桶,所采用的方法是链表法。每个bucket设计成最多只能放8个key-value对,如果有第9个 key-value落入当前的bucket,那就需要再构建一个bucket,通过overflow指针连接起来。

image.png

       每个bucket最多只能容纳8个键值对。如果有第9个键值对要放入当前的bucket,就需要创建一个新的bucket,并通过overflow指针将它们连接起来。