Go map
Map是一种常见的数据结构,用于表示键值对之间的映射关系。它采用哈希算法来实现快速的插入、查找和删除操作。哈希表的底层通常由一个数组和一组哈希函数组成。通过将键映射到数组的索引位置,并使用哈希函数处理冲突,使得元素可以快速定位和访问。Map在Go语言中是一种内置的数据结构,提供了方便的操作方法。
Go 语言同时使用了多个数据结构组合表示哈希表,其中 runtime.hmap 是最核心的结构体,让我们一起试着阅读go的源码。
哈希表
我们先简单了解一下哈希表:
- 哈希函数:哈希函数将键映射到数组的索引位置。好的哈希函数应该具有均匀分布性和高效性,以避免冲突和提高访问效率。
- 冲突解决:由于不同的键可能映射到相同的索引位置,冲突是哈希表中的常见问题。常用的冲突解决方法包括链表法(使用链表存储冲突的键值对)和开放地址法(通过探测空槽的方式解决冲突)。
- 插入操作:通过哈希函数计算键的索引位置,将键值对存储到对应的索引位置。
- 查找操作:通过哈希函数计算键的索引位置,查找对应位置的值。
- 删除操作:通过哈希函数计算键的索引位置,删除对应位置的键值对。
- 负载因子:表示哈希表中已存储键值对数量与数组容量之比,用于衡量哈希表的装填程度。过高的负载因子可能导致冲突增加,影响性能。
- 哈希表的优势:哈希表具有高效的插入、查找和删除操作,平均时间复杂度为O(1)。它适用于需要快速访问和查找元素的场景,例如索引、缓存和唯一标识等。
数据结构
一般的Map会包含两个主要结构数组和链表,链表的目的是为了使用链表法来解决hash冲突。Go语言解决hash冲突不是链表,实际主要用的数组(内存上的连续空间)。
Go map有两个重要的结构体hmap和bmap。我们先来看源码。
- 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的一些额外数据。
- 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 的。这些详细的注释就像是定义了一个协议,我们可以抽象出一个新的结构体。
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算法。
tophash用于快速查找key是否在bucket中。在实现过程中,使用key的哈希值的高8位作为tophash值,并将其存储在bmap的tophash字段中。tophash字段不仅存储key哈希值的高8位,还存储一些状态值,用于表示当前桶单元的状态。
哈希冲突
Go map当面临哈希冲突时,即不同的key放入了相同的桶,所采用的方法是链表法。每个bucket设计成最多只能放8个key-value对,如果有第9个 key-value落入当前的bucket,那就需要再构建一个bucket,通过overflow指针连接起来。
每个bucket最多只能容纳8个键值对。如果有第9个键值对要放入当前的bucket,就需要创建一个新的bucket,并通过overflow指针将它们连接起来。