映射(map)
map(映射)是一种用于存储key-value(键-值对)的数据结构。与切片中存储元素的有序性相比,map中的key-value是无序的。map的主要优势在于可以根据key来快速查找对应的value。
读、写、删时间复杂度O(1)
go
//声明
var mapName map[keyType]valueType
//使用make创建
charMap:=make(map[string]int)
//使用for-range遍历
若目标元素不存在,则返回零值(数据类型不同,对应的零值会有不同)。那么,对于返回零值的场景,便可能存在两种情况:
一是key对应的元素不存在;
二是key对应的元素存在,其值就是零值。
这就是map查找中的二义性。
Golang利用多返回值的特点同时返回value和"是否存在"的标识。
go
v,ok:=charMap["a"]//ok是bool类型的,标识是否value存在
//删除元素,
delete(charMap,"a")
map使用hash实现key的映射和寻址。
一组KV写入map的过程
- 哈希计算:首先,对于给定的键(Key),Go 运行时会计算其哈希值。哈希函数会尝试产生一个均匀分布的哈希值,以减少哈希冲突。
- 确定桶位置:使用哈希值,Go 运行时确定键值对应该存储在哈希表的哪个桶(bucket)中。这通常通过取哈希值对哈希表大小的模(modulo operation)来完成。每个桶固定可以存放 8 个 key-value 对;
- 处理哈希冲突:如果两个或多个键映射到同一个桶,会发生哈希冲突。Go 运行时使用链地址法来解决冲突,即每个桶包含一个链表,所有映射到该桶的键值对都会存储在这个链表中。
- 检查桶状态:在将键值对添加到桶之前,Go 运行时会检查桶的状态。如果桶是空的,可以直接添加键值对。如果桶中已经有元素,需要遍历链表以检查键是否已经存在。
- 插入键值对:如果键不存在于桶中,Go 运行时会在链表的适当位置插入新的键值对。如果键已存在,将更新其对应的值。
- 扩容检查:在插入新元素后,Go 运行时可能会检查哈希表的负载因子(即元素数量与桶数量的比率)。如果负载因子超过某个阈值,Go 运行时可能会触发哈希表的扩容。
- 哈希表扩容:如果需要扩容,Go 运行时会创建一个新的、更大的哈希表,并重新哈希所有现有的键值对,将它们迁移到新的哈希表中。这个过程是自动的,对开发者透明。
- 更新迭代器 :如果有正在进行的
map
迭代,Go 运行时会确保迭代器能够正确地适应扩容后的新状态。
map的数据结构
map
的数据结构主要由两个重要的部分组成:hmap
结构体和 Buckets
数组。
hmap
结构体定义了 map
的整体属性,其中包括了存储键值对数量、哈希种子、桶的大小等信息。
go
type hmap struct {
count int // 存活的键值对数量,必须放在第一位(被 len() 内置函数使用)
flags uint8
B uint8 // buckets 数量的对数(可以容纳 loadFactor * 2^B 个条目)
noverflow uint16 // 溢出桶的大致数量;详细信息请参阅 incrnoverflow
hash0 uint32 // 哈希种子
buckets unsafe.Pointer // 2^B 个桶的数组。如果 count==0,则可能为 nil。
oldbuckets unsafe.Pointer // 先前的一半大小的桶数组,仅在扩容时为非 nil
nevacuate uintptr // 迁移进度计数器(小于此值的桶已经迁移)
extra *mapextra // 可选字段
}
具体而言,hmap
结构体中的字段包括:
count
:表示当前存在的键值对的数量。flags
:用于存储一些标志位的字段。B
:表示Buckets
数组的大小,即 2^B。noverflow
:用于表示溢出桶的大致数量。hash0
:用作哈希函数的种子。buckets
:指向存储键值对的桶数组的指针。oldbuckets
:指向先前的桶数组的指针,在扩容时会使用。nevacuate
:表示迁移过程中的进度计数器。
map的查找本质上都是对key进行hash运算,获得一个整数,然后通过该整数按照一定运算规则(例如取余数)获得桶号。无论存还是取,相同的key都会基于同一个桶进行操作。这可以看作第一层的优化,将一个全量的范围缩小到单个桶的范围
Buckets
数组是哈希表中用于实际存储键值对的结构,它由一系列指针组成,每个指针指向一个桶,而每个桶则包含了键值对的集合。Buckets
数组的大小由 hmap
结构体中的 B
字段决定。
golang中桶的数据结构是bmap
桶bmap
go
// 用于Go map的一个桶。
type bmap struct {
// tophash 通常包含哈希值的最高字节,用于每个键在这个桶中的情况。
// 如果 tophash[0] < minTopHash,则 tophash[0] 实际上表示桶的迁移状态。
tophash [bucketCnt]uint8
// 后面跟着 bucketCnt 个键,然后是 bucketCnt 个元素。
// 注意:将所有键集中存储,然后所有元素集中存储,虽然会使代码变得复杂一些,但能够消除为一些情况(例如 map[int64]int8)所需的填充。
// 最后是一个溢出指针。
在编译期,bmap结构将被动态追加字段,形成新的结构
bmap中的方法
go
// overflow 方法返回当前桶的溢出桶
// t 是 map 类型的信息,用于获取桶的大小
func (b *bmap) overflow(t *maptype) *bmap {
return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-goarch.PtrSize))
}
// setoverflow 方法设置当前桶的溢出桶
// t 是 map 类型的信息,ovf 是要设置的溢出桶
func (b *bmap) setoverflow(t *maptype, ovf *bmap) {
*(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-goarch.PtrSize)) = ovf
}
// keys 方法返回当前桶的键的起始地址
func (b *bmap) keys() unsafe.Pointer {
return add(unsafe.Pointer(b), dataOffset)
}
- tophash 字段:这是一个数组,用于存储每个 key 的哈希值的高 8 位。这有助于在查找时快速排除不匹配的 key。
- keys 字段:这是一个数组,用于存储桶中的 8 个 key。
- values 字段:与 keys 字段相对应,这是一个存储桶中 8 个 value 的数组。
- 顺序对应:在 tophash、keys 和 values 字段中,存储的元素在顺序上是一一对应的,即 tophash[i] 与 keys[i] 和 values[i] 相对应。
map
用来解决哈希冲突的主要方法是链地址法(Chaining)。每个哈希表的桶(bucket)实际上是一个链表的头节点。当发生哈希冲突时,即两个键映射到同一个桶,它们的值会被存储在这个链表中。在查找时,会遍历链表,直到找到匹配的键。
- 哈希值计算:首先,对给定的键(例如"key1")进行哈希计算,得到一个唯一的哈希值。
- 桶号确定 :使用哈希值的低位(在这个例子中是低4位,即
B=4
)来确定该键值对应该存储在哈希表的哪个桶(bucket)中。桶是哈希表中的一个存储区域,用于存放具有相同低位哈希值的键值对。 - Tophash索引 :哈希值的高8位用于在
bmap
的tophash
数组中查找对应的索引。tophash
是一个辅助数组,存储了额外的哈希信息,帮助快速定位具体的桶。 - 桶内元素定位 :通过
tophash
找到索引后,就可以确定键值对在桶内的确切位置。 - 内存地址计算 :对于
keys
数组中的元素(例如keys[1]
),其内存地址可以通过以下公式计算得出:
| 💡 keys[1]的内存地址=bmp[0]的内存地址+8+len(key_slot)*1。
其中,bmp[0]
是指向第一个桶的指针,8
字节是tophash
数组的长度,len(key_slot)
表示存储一个键所需的内存空间。
在Go语言的map
实现中,tophash
机制被用于高效地筛选和定位键值对。以下是tophash
的工作原理和优势:
- 快速筛选 :
tophash
类似于布隆过滤器,它提供了一种快速的键值匹配预检机制。如果tophash
未能匹配预期的值,我们可以立即确定所搜索的键不存在于当前桶中,从而避免了不必要的进一步搜索。 - 初步匹配 :当
tophash
匹配成功时,这表明所搜索的键可能存在于桶中。然而,这只是一个初步的匹配,还需要进一步检查keys
数组中的实际数据以确认。 - 整数比较 :
tophash
中的元素是8位无符号整数。整数的比较操作通常比其他数据类型的比较要快得多,这是因为整数比较可以利用处理器的位操作指令,从而提高效率。 - 无需内存对齐 :
tophash
的存储结构简单,它不需要进行内存对齐,这简化了内存管理并减少了内存使用。 - 固定大小数组 :
tophash
、keys
和values
数组的大小都被固定为8,这样做是为了简化内存地址的计算。固定大小的数组允许我们使用简单的算术运算来快速定位每个元素的内存地址,从而提高访问速度。
通过tophash
的这些特性,Go语言的map
实现了快速且高效的键值对查找,即使在面对大量数据时也能保持高性能。
map扩容
Go 语言中的 map
使用 bmap
结构来管理桶中的元素。当一个桶中的元素数量超过 bmap
能够容纳的固定数量(通常是 8 个)时,需要使用额外的存储空间。
- 溢出处理 :当一个
bmap
桶中的元素达到其容量上限时,会使用overflow
字段来管理更多的元素。 overflow
字段 :overflow
字段是一个指向bmap
的指针,用于链接到另一个bmap
结构,从而形成一个链表。- 链表形成 :通过
overflow
指针,可以创建一个bmap
的链表,其中每个bmap
节点存储一定数量的元素。这种结构允许map
在同一个桶号下存储任意数量的元素。 - 动态链表 :
overflow
链表是动态的,随着元素的增加,链表可以不断扩展,直到所有元素都被存储。 - 内存效率 :通过
overflow
链表,map
可以有效地管理内存,避免一次性分配过大的空间,同时保持对元素的快速访问。 - 查找优化 :尽管
overflow
链表增加了查找元素的复杂性,但通过哈希和tophash
的快速筛选,可以迅速定位到正确的bmap
节点,从而优化查找性能。
通过这种方式,Go 语言的 map
能够灵活地处理不同数量级的元素,同时保持内存使用和访问效率的平衡。
扩容机制
渐进式扩容
Go 语言的 map
采用渐进式扩容 策略,以避免大规模数据迁移带来的性能延迟。当 map
存储了大量键值对时,一次性迁移所有数据将导致显著的延迟。因此,Go 的 map
扩容过程中,原有键值对不会一次性迁移完成,而是每次最多迁移两个桶(bucket)。每次插入、修改或删除操作时,都会尝试执行桶的迁移工作。迁移过程首先检查旧桶(oldbuckets)是否已经完全迁移,这通过检查 oldbuckets
是否为 nil
来实现。
翻倍扩容
当 map
的负载因子(即 count/(2^B)
)超过 6.5 时,会触发翻倍扩容 。例如,如果初始时 B = 0
,表示只有一个桶,当桶满时,就会触发翻倍扩容,此时 B = 1
,buckets
指向两个新的桶,而 oldbuckets
指向旧桶。nevacuate
变量标识接下来需要迁移的旧桶编号。旧桶中的键值对将渐进式地迁移到两个新桶中,直到所有键值对迁移完毕,随后旧桶(oldbuckets)将被删除。
在迁移过程中,使用位运算 hash & (m-1)
来确定键值对在新桶中的新位置。这里 hash
是键的哈希值,m
是扩容后桶的总数,通过与运算(&)确定键值对应迁移到的新桶位置。
等量扩容
即使没有超过负载因子限制,如果 map
使用了过多的溢出桶(overflow buckets),也会触发等量扩容 。在这种情况下,会创建与旧桶数量相同的新桶,并将原有键值对迁移到这些新桶中。这种策略有助于减少因溢出桶过多导致的性能问题,同时保持 map
的性能和效率。
通过这种细致的扩容策略,Go 语言的 map
能够灵活适应数据量的增长,同时保持高效的性能和资源利用。