Golang - map

哈希表

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 = 2^{3} ,即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,即桶数量为2^{1}=2

由此可看出选择桶使用的是与运算方法

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结构体

溢出桶的结构与常规桶相同,溢出桶是为了减少扩容 次数而引入的,当一个桶存满了,还有可用的溢出桶时,就会在桶后面链一个溢出桶,继续往溢出桶里存。

如果哈希表要分配的桶的数目大于2^{4}(即16)就认为使用到溢出桶的几率比较大了,会预分配2^{(B-4)} 个溢出桶备用。

这些溢出桶与常规桶在内存中是连续的,只是前2^{B} 个是常规桶,后面的是溢出桶,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)如果常规桶数目小于等于2^{15},当溢出桶数目超过常规桶就算多了;

         即当B<=15, noverflow >= 2^{B}

(2)如果常规桶数目大于2^{15},当溢出桶的数目超过2^{15}就算多了;

         即当 B>15,noverflow >= 2^{15}

所谓等量扩容,就是创建和旧桶数目一样多的新桶,然后把原来旧桶中的键值对迁移到新桶中,但是既然是等量,还要迁移有什么用呢?我们想一下,什么情况下负载因子没有超过上限6.5,却偏偏使用了很多溢出桶呢?当然是很多键值对被删除的情况了。此时键值对迁移到新桶中,能够排列更加紧凑,从而减少溢出的使用,这也就是等量扩容的意义啦! 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值