哈希表
map存储的键值对(key-value),类似于哈希表,我们先来看下哈希表,哈希表通常会有一堆桶来存储键值对,一个键值对要怎样选择一个桶进行存储呢?首先“键”key通过哈希函数得到一个哈希值keyHash,再利用这个哈希值从m个桶中选择一个桶,桶编号区间[0, m-1]
选择哪个桶进行存储有2种比较常用的方法:取模法和与运算法
(1)取模法
即哈希值keyHash % 桶个数m(取模),得到一个桶编号,这个编号一定是在桶编号区间中[0, m-a]中
(2)与运算法
哈希值keyHash & (m-1) (与运算),若想确保运算结果落在区间[0, m-1]中,而不会出现空桶,就要限制桶的个数 m必须为2的整数次幂,这样m的二进制表示 一定只有一位为1,m-1 的二进制表示一定是低于这1位于的所有位均为1,
如:
m =
,即m=8,8 的二进制为 1000,即只有第4位为1,
m-1 = 8-1 = 7, 7 的二进制为 0111,即低于第4位均为1
此时任何与二进制 0111 进行与运算都可落在桶编号 [0, 7] 的范围内,不会出现空桶的情况。
我们假设桶的个数m不是2个整数次幂,如 m=6,那么m-1=5,5的二进制为 0101,第二位为0,即在与运算时,第二位永远为0,即像0010(2号桶),0011(3号桶),0110(6号桶),0111(7号桶)这样第2位为1的桶永远都选不中。
以上是键值对选桶的2种常用方式,接下来我们看下选桶会遇到的一个问题 - 哈希冲突。
哈希冲突
若一键值对已选择桶1,后来又有一个新的键值对选择了桶1,此时就发生了哈希冲突,
解决哈希冲突的2种常用方法:开放地址法与拉链法
(1)开放地址法
桶1被 占用了,那就找它后面没有占用的桶来用,选择下面的2号桶,如2号桶也被占用就找3号桶,直到找到一个没有被占用的桶;在查找这个键值对时,首先定位到2号桶,但经过键的比较不相等,就会遍历 后面的桶,直到键相等,或者遇到空桶(即证明这个键不存在),这种解决哈希冲突的方法就叫开放地址法。
(2)拉链法
桶1被占用了,在它后面链一个新桶存储这个键值对;在查找 这个键时,会先找到桶1,经过键的比较不相等,顺着链表往后继续查找 ,直到键相等或到链表结束(即这个键不存在),这种解决哈希冲突的方法叫拉链法。
发生哈希冲突,会影响哈希表的读写效率,所以选择散列均匀的哈希函数可以减少哈希冲突的发生。对哈希表进行扩容也是保障读写效率的有效手段。
哈希表扩容
扩容的判断依据:存储键值对的数目与桶的数目比值,这个比值称为“负载因子(Load Factor)”。
需要扩容 时,就要分配更多的桶,它们就是新桶,需要把旧桶里存储的键值对都迁移到新桶中。如果哈希表存储的键值对较多时,一次性迁移所有桶花费的时间就比较显著,所以通常会在哈希表扩容时,先分配足够多的新桶,然后用一个字段记录旧桶的位置,再增加一个字段,记录旧桶迁移的进度。
例如记录下一个要迁移的旧桶编号,在哈希表每次读写操作时,如果检测到当前处于扩容 阶段,就完成一部分链值对迁移任务,直到所有的旧桶迁移完成,旧桶不再使用,才算真正完成一次哈希表的扩容,像这样把键值对迁移的时间分摊到多次哈希表的操作方式,就是“渐近式扩容”,可避免一次性扩容带来的性能瞬时抖动。
Map
了解了哈希表、解决哈希冲突的方法及哈希表扩容相关内容,我们来看下Go语言中的map类型。
hmap结构体
map 是一个指针,在64位操作系统中占8字节,指向hmap结构体。map类型的底层实现就是哈希表。来看下hmap结构体:
Go版本1.15,源码可参见:src/runtime/map.go
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/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 | 记录已经存储的键值对的数量 |
flags | 状态标志,如是否下在写入等 |
B | 记录桶的数量是2的多少次幂,如B=1,即桶数量为 由此可看出选择桶使用的是与运算方法 |
noverflow | 溢出桶的数量 |
hash0 | 生成hash的随机数种子 |
buckets | 记录桶的位置,当count=0时(即还没有键值对),值为nil |
oldbuckets | 记录在扩容阶段保存的旧桶位置 |
nevacuate | 记录在扩容阶段下一个将要迁移的旧桶编号 |
extra | 存储溢出桶,是为优化GC扫描设计的 |
桶 - bmap结构体
我们再来看下桶的结构,即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结构是静态结构,在编译过程中runtime.bmap会拓展成以下结构体:
type bmap struct {
tophash [8]uint8
// keytype由编译器编译时确定
keys [8]keytype
// elemtype由编译器编译时确定
values [8]elemtype
// 指向下一个bmap,overflow是uintptr类型而不是*bmap类型,保证bmap完全不含指针,是为了减少gc
overflow uintptr
}
一个桶里可以放8个键值对(key-value),为了让内存排列更加紧凑,8个key放一起,8个value放一起,在8个key前面是8个tophash,每个tophash对应key哈希值的高8位,最后是一个bmap类型的指针,指向一个溢出桶。
溢出桶 - mapextra结构体
溢出桶的结构与常规桶相同,溢出桶是为了减少扩容 次数而引入的,当一个桶存满了,还有可用的溢出桶时,就会在桶后面链一个溢出桶,继续往溢出桶里存。
如果哈希表要分配的桶的数目大于
(即16)就认为使用到溢出桶的几率比较大了,会预分配
个溢出桶备用。
这些溢出桶与常规桶在内存中是连续的,只是前 个是常规桶,后面的是溢出桶,hmap结构体中的最后一个字段 extra,指向一个mapextra结构体,即记录溢出桶相关信息,源码如下:
// mapextra holds fields that are not present on all maps.
type mapextra struct {
// If both key and elem do not contain pointers and are inline, then we mark bucket
// type as containing no pointers. This avoids scanning such maps.
// However, bmap.overflow is a pointer. In order to keep overflow buckets
// alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
// overflow and oldoverflow are only used if key and elem do not contain pointers.
// overflow contains overflow buckets for hmap.buckets.
// oldoverflow contains overflow buckets for hmap.oldbuckets.
// The indirection allows to store a pointer to the slice in hiter.
overflow *[]*bmap
oldoverflow *[]*bmap
// nextOverflow holds a pointer to a free overflow bucket.
nextOverflow *bmap
}
overflow:是一个slice,记录目前已经被使用的溢出桶的地址,假如编号为1的桶存满了,就会在后面链一个溢出桶,此时nextOverflow字段指向下一个空闲溢出桶,hmap.noverflow++,即记录使用溢出桶的数量加1;
oldoverflow:在扩容阶段存储旧桶用到的那些溢出桶的地址;
nextOverflow:指向下一个空闲溢出桶;
扩容规则
如果桶存满了,再继续 存储新的键值对时,这个哈希表会创建溢出桶?还是会发生扩容?这时就要看map的扩容规则了。
还记得上面提到的负载因子吧,即键值对数量与桶数量的比值,map默认的负载因子是6.5,超过这个数就会触发翻倍扩容,即分配新桶的数量是旧桶的2倍,其实也就是hmap.B++操作的结果。
如果负载因子小于等于6.5,但使用的溢出桶较多时,也会触发扩容,此时是等量扩容,那么有多少溢出桶就算多了呢?
(1)如果常规桶数目小于等于
,当溢出桶数目超过常规桶就算多了;
即当B<=15, noverflow >=
(2)如果常规桶数目大于
,当溢出桶的数目超过
就算多了;
即当 B>15,noverflow >=
所谓等量扩容,就是创建和旧桶数目一样多的新桶,然后把原来旧桶中的键值对迁移到新桶中,但是既然是等量,还要迁移有什么用呢?我们想一下,什么情况下负载因子没有超过上限6.5,却偏偏使用了很多溢出桶呢?当然是很多键值对被删除的情况了。此时键值对迁移到新桶中,能够排列更加紧凑,从而减少溢出的使用,这也就是等量扩容的意义啦!