一、map的结构与设计原理
golang中map是一个kv对集合。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv。在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。具体hash函数的性能比较可以看:http://aras-p.info/blog/2016/08/09/More-Hash-Function-Tests/ 每个map的底层结构是hmap,是有若干个机构为bmap的bucket组成的数组。每个bucket底层都采用链表结构。接下来,我们来详细看下map的结构:
// A header for a Go map.
type hmap struct {
count int // 元素个数
flags uint8
B uint8 // 扩容常量相关字段B是buckets数组的长度的对数 2^B
noverflow uint16 // 溢出的bucket个数
hash0 uint32 // hash seed
buckets unsafe.Pointer // buckets 数组指针
oldbuckets unsafe.Pointer // 结构扩容的时候用于赋值的buckets数组
nevacuate uintptr // 搬迁进度
extra *mapextra // 用于扩容的指针
}
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8 // len为8的数组
}
//底层定义的常量
const (
// Maximum number of key/value pairs a bucket can hold.
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
)
但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
hmap和bmap的结构是这样的 :
而bucket又是一个链表,所以,整体的结构应该是这样的:
bmap
就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key得定位我们在map得查询和赋值中详细说明。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 hmap的extra 字段来。这部分我们在分析扩容操作的时候再详细说明。下面我们看下bmap的内部组成图:
HOBHash
指的就是 top hash,每个bucket中topHash唯一。key 和 value 是各自放在一起的,并不是 key/value/...
这样的形式。可以省略掉 padding 字段,节省内存空间。
例如,有这样一个类型的 map:map[int64]int8
,如果按照 key/value...
这样的模式存储,那在每一个 key/value 对之后都要额外 padding 7 个字节;而将所有的 key,value 分别绑定到一起,这种形式 key/key/.../value/value/...
,则只需要在最后添加 padding,每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 overflow
指针连接起来。
二、map操作底层原理分析
1.map初始化:
方法1:
var m map[string]string // 声明变量 --nil map 支持查询 返回类型默认值 赋值、delete操作会panic
m = make(map[string]string, 10) // 初始化 --empty map 可以进行赋值操作了
方法2:
m := make(map[string]string,10) // 容量参数可省略
方法3:
m := map[string]string{ // 通过直接赋值进行初始化
"test": "test",
"name": "lili",
"age": "one",
}
第一步:入参校验,判断key的类型是否合法,必须为可比较类型。
第二步:底层调用makemap函数,计算得到合适的B,map容量最多可容纳6.5*2^B个元素,6.5为装载因子阈值常量。装载因子的计算公式是:装载因子=填入表中的元素个数/散列表的长度,装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
func makemap(t *maptype, hint int, h *hmap) *hmap {
//边界校验
if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) {
hint = 0
}
// initialize Hmap
if h == nil {
h = new(hmap)
}
//生成hash种子
h.hash0 = fastrand()
// find size parameter which will hold the requested # of elements
B := uint8(0)
//计算得到合适的B
for overLoadFactor(hint, B) {
B++
}
h.B = B
// allocate initial hash table
// if B == 0, the buckets field is allocated lazily later (in mapassign)
// If hint is large zeroing this memory could take a while.
//申请桶空间
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
//常量loadFactorNum=13 ,loadFactorDen=2
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
makemap函数会通过 fastrand
创建一个随机的哈希种子,然后根据传入的 hint
计算出需要的最小需要的桶的数量,最后再使用 makeBucketArray
创建用于保存桶的数组,这个方法其实就是根据传入的 B
计算出的需要创建的桶数量在内存中分配一片连续的空间用于存储数据,在创建桶的过程中还会额外创建一些用于保存溢出数据的桶,数量是 2^(B-4)
个。初始化完成返回hmap指针。
2.查找操作
Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。
value := m["name"]
fmt.Printf("value:%s", value)
value, ok := m["name"]
if ok {
fmt.Printf("value:%s", value)
}
两种语法对应到底层两个不同的函数,那么在底层是如何定位到key的呢?稍后我们对函数进行源码分析。
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)
key的定位:
key 经过哈希计算后得到哈希值,共 64 个 bit 位(64位机,32位机就不讨论了,现在主流都是64位机),计算它到底要落在哪个桶时,只会用到最后