Go语言中的map是一种内置的数据结构,用于存储键值对。map提供了快速查找、插入和删除操作,是实现字典和哈希表的基础。大多数情况下,它都能在O(1)的时间复杂度下实现增删改查功能,若在极端情况下出现所有key都发生哈希碰撞时则退回成链表形式,此时时间复杂度为O(N)。由于哈希表在使用过程中可能会发生内存分配,因此哈希表一般是不允许并发读写的。
使用make函数:
gom := make(map[string]int, 10) // 创建一个初始容量为10的map
使用字面量:
gom := map[string]int{
"one": 1,
"two": 2,
"three": 3,
}
插入和更新元素:
gom["four"] = 4
m["one"] = 11 // 更新已有的键值对
获取元素:
goval := m["one"]
删除元素:
godelete(m, "two")
检查键是否存在:
goval, ok := m["three"]
if ok {
fmt.Println("Key exists with value:", val)
} else {
fmt.Println("Key does not exist")
}
遍历map:
gofor key, value := range m {
fmt.Println(key, value)
}
Go语言的map底层是一个哈希表,由多个哈希桶(bucket)组成。每个桶可以存储若干个键值对和一些元数据。它使用hash函数将key分配到不同桶中,若出现碰撞冲突时候,则采用链地址法或者开放寻址法解决冲突。 在哈希函数的选择上,会在程序启动时,检测cpu是否支持 AES,如果支持,则使用 AES hash,否则使用 memhash。
冲突解决
- 链地址法:即每个哈希桶内部维护一个链表,当发生冲突时,将新元素添加到链表中。
- 开放地址法:当多个键映射到同一个哈希桶时,通过线性探测或二次探测等方法找到下一个空闲的桶位置。
源码中,map主要涉及的结构体有两个,一个叫 hmap(a header for a go map),一个叫 bmap(a bucket for a Go map,通常叫 bucket)。
下图是完整的关系图
map的结构是 runtime.hmap
go// A header for a Go map.
type hmap struct {
count int // 元素个数
flags uint8 // 标志位,标志当前map正在写等状态
B uint8 // 扩容常量相关字段B是buckets数组的长度的对数 2^B
noverflow uint16 // 溢出的bucket个数
hash0 uint32 // 随机数种子,用于计算key的哈希值
buckets unsafe.Pointer // 指向buckets数组,如果元素个数为0时,该值为nil
oldbuckets unsafe.Pointer // 结构扩容的时候用于赋值的buckets数组
nevacuate uintptr // 搬迁进度
extra *mapextra // 用于扩容的指针,额外记录overflow桶信息
}
可以看到,hamp中有个buckets字段,它是一个指针,最终它指向的桶,是一个结构体bmp
bmap 就是我们常说的“桶”,它的结构是 runtime.bmap
go// A bucket for a Go map.
type bmap struct {
// tophash存储桶内每个key的hash值的高字节
// tophash[0] < minTopHash表示桶的疏散状态
// 当前版本bucketCnt的值是8,一个桶最多存储8个key-value对
tophash [bucketCnt]uint8
}
上面bmap结构是静态结构,在编译过程中 runtime.bmap 会拓展成下面这个新的结构体:
gotype bmap struct{
tophash [8]uint8
keys [8]keytype // keytype 由编译器编译时候确定
values [8]elemtype // elemtype 由编译器编译时候确定
overflow uintptr // overflow指向下一个bmap,overflow是uintptr而不是*bmap类型,是为了减少gc
}
bmap里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。
bmap中tophash就是用于实现快速定位key的位置。tophash计算公式如下:
gofunc tophash(hash uintptr) uint8 {
top := uint8(hash >> (sys.PtrSize*8 - 8))
if top < minTopHash {
top += minTopHash
}
return top
}
上面函数中hash是64位的,sys.PtrSize值是8,所以top := uint8(hash >> (sys.PtrSize*8 - 8))等效top = uint8(hash >> 56),最后top取出来的值就是hash的最高8位值。bmap的tophash字段不光存储key哈希值的高八位,还会存储一些状态值,用来表明当前桶单元状态,这些状态值都是小于minTopHash的。
为了避免key哈希值的高八位值出现这些状态值相等产生混淆情况,所以当key哈希值高八位若小于minTopHash时候,自动将其值加上minTopHash作为该key的tophash。桶单元的状态值如下:
goemptyRest = 0 // 表明此桶单元为空,且更高索引的单元也是空
emptyOne = 1 // 表明此桶单元为空
evacuatedX = 2 // 用于表示扩容迁移到新桶前半段区间
evacuatedY = 3 // 用于表示扩容迁移到新桶后半段区间
evacuatedEmpty = 4 // 用于表示此单元已迁移
minTopHash = 5 // key的tophash值与桶状态值分割线值,小于此值的一定代表着桶单元的状态,大于此值的一定是key对应的tophash值
bmap中可以装载8个key-value,这8个key-value并不是按照key1/value1/key2/value2/key3/value3…这样形式存储,而采用key1/key2../key8/value1/../value8形式存储,因为第二种形式可以减少padding,源码中以map[int64]int8举例说明。 hmap中extra字段是 runtime.mapextra 类型,用来记录额外信息:
go// mapextra holds fields that are not present on all maps.
type mapextra struct {
overflow *[]*bmap // 指向overflow桶指针组成的切片,防止这些溢出桶被gc了
oldoverflow *[]*bmap // 扩容时候,指向旧的溢出桶组成的切片,防止这些溢出桶被gc了
//指向下一个可用的overflow 桶
nextOverflow *bmap
}
当映射的key和value都不是指针类型时候,bmap将完全不包含指针,那么gc时候就不用扫描bmap。bmap指向溢出桶的字段overflow是uintptr类型,为了防止这些overflow桶被gc掉,所以需要mapextra.overflow将它保存起来。如果bmap的overflow是*bmap类型,那么gc扫描的是一个个拉链表,效率明显不如直接扫描一段内存(hmap.mapextra.overflow)
新建map命令,以a := make(map[string]string, len) 为例
go// make(map[k]v, hint), hint即预分配大小
// 不传hint时,如用new创建个预设容量为0的map时,makemap只初始化hmap结构,不分配hash数组
func makemap(t *maptype, hint int, h *hmap) *hmap {
//初始化hmap结构体
if h == nil {
h = new(hmap)
}
h.hash0 = fastrand()
//hint > 8, B一直增长到2的倍数
B := uint8(0)
// overLoadFactor(hint, B)只有一行代码:return hint > bucketCnt && uintptr(hint) > loadFactorNum*(bucketShift(B)/loadFactorDen)
// 即B的大小应满足 hint <= (2^B) * 6.5
// 一个桶能存8对key-value,所以这就表示B的初始值是保证这个map不需要扩容即可存下hint个元素对的最小的B值
for overLoadFactor(hint, B) {
B++
}
h.B = B
if h.B != 0 {
//新建B个buckets,有可能需要新建overflow bucket (B值过大情况下)
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
gofunc mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
//如果有goroutine正在写入map,则不允许读。所以map是非线程安全的
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
......
//确定key所在的bucket内存地址
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
//如果oldbuckets里面有值,从oldbuckets取值
if c := h.oldbuckets; c != nil {
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
if !evacuated(oldb) {
b = oldb
}
}
//获取key hash算法之后的高8位
top := tophash(hash)
bucketloop:
for ; b != nil; b = b.overflow(t) {
//bucketCnt = 8,遍历tophash数组,tophash[i]等于kHash高8位的i值 并且 bucket的第i个的key与所给的key相等的value值
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
continue
}
//对比 bucket的第i个的key与所给的key是否相等,相等就取对应的value值
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if alg.equal(key, k) {
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
return v
}
}
}
return unsafe.Pointer(&zeroVal[0])
}
map使用哈希表的目的就是要快速查找到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。
在golang中,采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。当然,必须有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n)。
因此,需要有一个指标来衡量前面描述的情况,这就是装载/负载因子(load factor),它是哈希表中元素数量与哈希桶数量的比值。Go 源码里是这样定义装载因子的:
goloadFactor := count / (2^B)
其中,count 就是 map 的元素个数,2^B 表示 bucket 数量。
map扩容的触发条件主要是基于装载因子(load factor),在超过某个阈值时会进行扩容。具体的阈值和扩容策略可能会根据Go语言的版本有所不同。 触发条件
go// src/runtime/hashmap.go/mapassign
// 触发扩容时机
if !h.growing() && (overLoadFactor(int64(h.count), h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
}
// 条件1 装载因子超过 6.5
func overLoadFactor(count int64, B uint8) bool {
return count >= bucketCnt && float32(count) >= loadFactor*float32((uint64(1)<<B))
}
// 条件2 overflow buckets 太多
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
if B < 16 {
return noverflow >= uint16(1)<<B
}
return noverflow >= 1<<15
}
go会采用采用渐进式扩容,避免一次性迁移数据过多造成性能问题。在进行新增、更新时候会触发扩容操作然后进行扩容操作,每次最多迁移2个bucket。我们常见的有以下两种扩容类型。
所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。
上面说的 hashGrow() 函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil。
当装载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。 总结来说,不论是等容量扩容,还是双倍容量扩容,都会新创建一个buckets,然后将hmap.buckets指向这个新的buckets,hmap.oldbuckets指向旧的buckets。
在golang规范中,可比较的类型都可以作为map key。不能作为map key 的类型包括:
本文作者:sora
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!