深入学习go语言(三):数据结构-哈希表

哈希表用于存储映射关系,作为常用的数据结构,几乎在每个高级语言都有其标准实现。这里就了解一下go语言中哈希表的实现。

数据结构

go语言哈希表的核心结构就是hmap

type hmap struct {
	count     int //当前哈希中的元素数量
	flags     uint8
	B         uint8 //哈希表的buckets的数量为 2^B
	noverflow uint16 //溢出桶的大致数量
	hash0     uint32 // 哈希种子。在创建哈希表时确定,作为哈希函数的参数传入,为哈希函数引入随机性

	buckets    unsafe.Pointer
	oldbuckets unsafe.Pointer // 哈希表在扩容的时候用于保存之前的buckets
	nevacuate  uintptr // 扩容时使用,比这个数小的桶都已经被移动

	extra *mapextra //溢出桶信息
}

type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

在上面的mapextra结构体中我们可以看到bmap结构,实际上hmap的buckets和oldbuckets也是指向bmap数组的指针。
bmap就是哈希表的桶,其在go源码中的定义很简单

type bmap struct {
	tophash [bucketCnt]uint8
}

但是实际上,bmap的运行时结构是要比这复杂的,因为哈希表可以存储不同类型的键值,所以键值对的类型需要在编译时进行推导才能够确认,然后根据类型信息构建出bmap的运行时结构

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

可以看到每个桶只能存储8对键值对,并且不是键值对交错排列的,而是键存放在一起,值存放在一起,这样内存排列更加紧密。桶限制只能存储8对,避免桶过大造成性能下降,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。。并且正常桶和溢出桶在内存上是连续的。
image

但是溢出桶也只是一种临时的解决方案,当创建过多的溢出桶同样也会影响哈希表的性能,所以溢出桶达到一定的数量同样也会触发哈希表的扩容。

初始化

当创建的哈希表分配到栈上,并且容量小于BUCKETSIZE=8的时候,Go语言在编译阶段对进行优化,使用下面方式快速创建哈希表

var h *hmap
var hv hmap
var bv bmap
h := &hv
b := &bv
h.buckets = b
h.hash0 = fashtrand0()

可以说是直接创建一个hmap结构体并赋予初值,因为容量小于BUCKETSIZE,所以也只创建了一个桶。
除了上面的优化场景,那么就需要按照下面的步骤创建哈希表:

  1. 计算哈希表占用的内存是否能够满足
  2. 使用runtime.fastrand 获取一个随机的哈希种子
  3. 根据传入的容量来确定桶的数量,也就是大于容量的最小的2B的B值
  4. 然后创建出保存桶的数组。当B小于4,也就是桶的数量小于24的时候,因为数量较少,所以不会创建溢出桶。当桶的数量大于24的时候,会创建2B4个溢出桶。

添加键值对

哈希表通过形如hash[k]=v的方式添加键值对。

  1. 根据key使用哈希函数计算hask和对应的桶。
  2. 遍历桶中的tophash与键的哈希进行对比,找到该键在桶中存在的位置或者找到空位。
  3. 如果在桶中既没有找到该键,也没有找到空位,说明桶满了。就会创建新桶或者使用预先创建好的溢出桶进行上面的查找操作。

找到键对应的值的内存地址,然后在编译阶段插入命令,将值拷贝到值对应的内存地址,就此完成键值对的赋值操作。

image

访问键值对

访问与添加的流程基本一致,找到key在哈希桶中的位置,然后读取对应的值,而不是赋值。

  1. 通过哈希表的哈希函数以及哈希种子计算出访问key的哈希。
  2. 根据bucketMask和key的哈希获得该key所在桶的序号,并获得hash的高8位。
  3. 依次遍历正常桶和溢出桶,先用key的tophash与桶中的tophash数组进行比较。不同直接跳过,相同再比较key是否相同,两者都相同则说明找到了key的位置,同样也获取到的value的值。

可以发现tophash就是为了加速key的比较的,key的hash与bucketMask获得桶序号使用的是哈希的低位部分,在哈希桶内的比较使用的是hash的高8位。这两者就已经过滤掉了大部分的key,只要再进行少量的key的字符串比较,就可以验证是否找到指定的key了。

删除键值对

键值对的删除使用delete关键字。要删除一个键值对,那么就要找到这个键值对,然后将所在的内存清除。所在这个过程与写入非常相似,可以认为就是写入,只不过写入的值比较特殊。

扩容

上面对哈希表的读写操作都没有涉及哈希表的扩容,实际上随着哈希表中的元素增多,哈希表的性能会恶化。因此为了保证哈希表的性能,我们需要对哈希表进行扩容。

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	...
	if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
		hashGrow(t, h)
		goto again
	}
	...
}

因为哈希的扩容并不是一个原子操作,也就是说并不是一次完成的,跟redis渐进式哈希有些类似,是一个逐步完成的扩容过程。
哈希表的扩容有两个触发条件

  • 装载因子超过6.5
  • 溢出桶过多
    根据触发的条件不同,哈希表的扩容实际上可以分为两类。也就是用于处理溢出桶过多的等量扩容和装载因子过大的翻倍扩容

等量扩容是一种比较特殊扩容场景。发生在向哈希表中持续插入键值对然后全部删除这种类型的场景中,在这个场景中,持续插入的过程中装载因子并没有达到阈值,并新建了溢出桶,然后删除键值对再继续插入,虽然键值对删除了但是创建的溢出桶并没有删除,而后又继续创建新的溢出桶。造成了溢出桶的累积
这就导致了,虽然哈希表中键值对的数目相对稳定,但是使用的哈希表的内存确实越来越大,甚至会导致内存泄漏
等量扩容便是为了解决这个问题的,当溢出桶的数量过多的使用,就会触发,在这个过程中清理回收老的溢出桶。
扩容的入口函数为runtime.hashGrow:

func hashGrow(t *maptype, h *hmap) {
	bigger := uint8(1)
	if !overLoadFactor(h.count+1, h.B) {
		bigger = 0
		h.flags |= sameSizeGrow
	}
	oldbuckets := h.buckets
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

	h.B += bigger
	h.flags = flags
	h.oldbuckets = oldbuckets
	h.buckets = newbuckets
	h.nevacuate = 0
	h.noverflow = 0

	h.extra.oldoverflow = h.extra.overflow
	h.extra.overflow = nil
	h.extra.nextOverflow = nextOverflow
}

从上面的代码中边可以看到,对于非负载因子过大扩容,bigger为0,也就是h.B+bigger并没有增大,随意新建的桶的容量还是与原本的容量相同。b.B也并没有加1。
除了容量不同,在这个函数中等量扩容和翻倍扩容没有什么区别。都是创建新桶,然后让oldbuckets指向原来的桶,buckets指向新桶。

哈希表新桶到旧桶的数据迁移是在runtime.evacuate中完成的。

func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
	b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
	newbit := h.noldbuckets()
	if !evacuated(b) {
		var xy [2]evacDst
		x := &xy[0]
		x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
		x.k = add(unsafe.Pointer(x.b), dataOffset)
		x.v = add(x.k, bucketCnt*uintptr(t.keysize))

		y := &xy[1]
		y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
		y.k = add(unsafe.Pointer(y.b), dataOffset)
		y.v = add(y.k, bucketCnt*uintptr(t.keysize))

上面是翻倍扩容的过程,可以看出go的扩容过程还是比较特殊的。对于翻倍扩容来说,原来的一个旧桶对应两个新桶,会将一个旧桶中的数据分流到两个新桶当中。举例来说,我们通过键的哈希与掩码的结果来确定键值对所在的哈希桶,桶的容量翻倍之后,掩码也会多一位,原来容量为4,掩码为0x11,那么现在容量为8,掩码就为0x111了。因此原来旧桶中的数据只要求哈希的后两位相同,现在需要将哈希倒数第三位不同的键分流到两个桶中了。

所以在上面的代码中,创建了两个evacDst结构体,分别指向两个新桶,作为扩容的上下文。

runtime.evacuate 最后会调用 runtime.advanceEvacuationMark 增加哈希的 nevacuate 计数器并在所有的旧桶都被分流后清空哈希的 oldbucketsoldoverflow

func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
	h.nevacuate++
	stop := h.nevacuate + 1024
	if stop > newbit {
		stop = newbit
	}
	for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
		h.nevacuate++
	}
	if h.nevacuate == newbit { // newbit == # of oldbuckets
		h.oldbuckets = nil
		if h.extra != nil {
			h.extra.oldoverflow = nil
		}
		h.flags &^= sameSizeGrow
	}
}

因为go的哈希扩容也是一个渐进式的过程,所以现在我们来补充一下上面哈希操作在扩容时的内容。

当哈希表处于扩容状态时,在写哈希表和删除哈希表元素的时候,都会触发runtime.growWork增量拷贝数据;读哈希表的时候,会先定位到旧桶,并且旧桶没有还没有分流的话就从旧桶读取数据,如果已经分流,那么就从新桶读取数据。

小结

我们来对go的哈希表进行一下总结。
首先go的哈希表是采用拉链法解决哈希冲突的,虽然文中我们一直使用的是溢出桶的说法,其实溢出桶本身就可以看作链表的节点,并且每个节点可以存放8个键值对。

对键值对采用哈希函数+掩码的方式确定所在桶,但也只能确定到这个层级,桶中的元素是按照先后顺序放置的,于是要确定元素是否存在就需要从前到后进行遍历,为了加速这个比较过程,桶中还存储了元素的tophash即哈希的高8位(之所以是高8位,是因为确定桶使用的掩码用的是哈希的低位,这样能避免哈希相同的绝大部份情况)来加速比较过程。

扩容是一个渐进式的过程,在每次更新哈希表的键值对的时候都会触发增量更新,读取的时候会先确定旧桶,然后判断是否分流来决定是否读取新桶。

posted @   三尺山  阅读(257)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示