Go-哈希表

本文详细解析了Go语言中哈希表的实现原理,包括哈希函数的选择、哈希冲突的解决(开放地址法与拉链法)、数据结构设计(如hmap、bmap)以及扩容和删除策略。重点介绍了Go如何通过负载因子和溢出桶管理来优化性能和内存使用。同时,文中探讨了Map的初始化、读写操作以及渐进式扩容机制,展示了Go在保持高效访问的同时,如何处理哈希表动态增长的问题。
摘要由CSDN通过智能技术生成

设计原理
哈希表是计算机科学中最重要的数据结构之一,在Java,python,Go等语言中都是哈希表的实现,这主要是由于他提供了键值之间映射,从而读写性能达到了O(1),十分优秀。

哈希函数
实现哈希表的关键点在于哈希函数的选择,好的哈希函数能够大大减少哈希冲突,提升读写效率。常见的哈希函数有MD5,SHA等。完美的哈希函数是将每一个键散列到不同的位置,但是实际使用中是几乎不可能的,因此哈希表还需要解决哈希冲突的问题。

哈希冲突
解决哈希冲突有两种常用的方法,一是开放地址法:当发生哈希冲突时,就会将键值对写入到下一个索引不为空的位置,如下图所示,当key3的哈希值和key1,key2已经发生冲突了,就会向后寻找位置,直到为空、

这种方法当哈希冲突严重时,插入以及查找的效率都比较低,因此大多数语言选择的是第二种方法,拉链法。拉链法就是在哈希冲突的地方加上一个链表,发生冲突时,将冲突的数据放在链表后面,有的语言为了效率还会引入红黑树,比如Java。

当哈希冲突严重的时候,可能会退化为链表,查找效率为O(n),因此Go语言与Java一样,也引入了装载因子的概念,当超过了装载因此,就需要进行扩容,让元素重新分配,减少扩容。装载因子越大,冲突概率越大,但是使用的空间更少,装载因子越小,冲突的概率越小,但使用的空间也就越多,因此装载因子的选择就是对时间和空间的选择,在工作中选择适当的装载因子对我们的空间和时间的效率都有较大的影响。

装载因子:=元素数量÷桶数量

数据结构
// 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表示当前哈希表中元素的数量。
B表示哈希表中桶的数量,由于桶的数量都是2的倍数,这里B表示的是对数,即len(buckets) == 2^B,这是由于当数字是2的倍数,二进制只有最高位为1,其他全为0,在扩容以及以及桶位置的选择等能够利用位运算从而提高效率。
noverflow表示溢出桶的数量,溢出桶时当发生哈希冲突的时候,如果当前桶装满了,就会在尾部链接一个溢出桶继续存储,记录数量是因为溢出桶的数量是不能够太多的,太多就会退化成链表,从而记录下数量,当过多时就会发生扩容,具体在后面扩容规则将。另外,当B>=4时,会认为使用溢出桶的概率较大,会提前创建溢出桶,数量为2^(B-4)。
hash0表示哈希种子,它能为哈希函数的结果引入随机性,再次打乱哈希函数,这个值再创建哈希表的时候就确立了。
buckets是一个指向了桶数组的指针。
oldbuckets用于保存在扩容阶段的旧的桶,他的大小是新桶的一半。
nevacuate由于Go中的Map是渐进式扩容,因此此字段记录了在扩容阶段,即将迁移的旧桶的编号。
extra是由于map可能会预先创建溢出桶,因此extra指向了下一个空闲溢出桶的位置。
// mapextra holds fields that are not present on all maps.
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
// nextOverflow holds a pointer to a free overflow bucket.
nextOverflow *bmap
}
哈希函数
实现哈希表的关键点在于哈希函数的选择,好的哈希函数能够大大减少哈希冲突,提升读写效率。Go语言的实现如下。

func fastrand() uint32 {
mp := getg().m
// Implement xorshift64+: 2 32-bit xorshift sequences added together.
// Shift triplet [17,7,16] was calculated as indicated in Marsaglia’s
// Xorshift paper: https://www.jstatsoft.org/article/view/v008i14/xorshift.pdf
// This generator passes the SmallCrush suite, part of TestU01 framework:
// http://simul.iro.umontreal.ca/testu01/tu01.html
s1, s0 := mp.fastrand[0], mp.fastrand[1]
s1 ^= s1 << 17
s1 = s1 ^ s0 ^ s1>>7 ^ s0>>16
mp.fastrand[0], mp.fastrand[1] = s0, s1
return s0 + s1
}
bmap

如上图所示哈希表 runtime.hmap 的桶是 runtime.bmap。每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时(8个键值对)就会使用 extra.nextOverflow 中桶存储溢出的数据。下面是bmap的数据结构

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.
}

在Go的源码当中,只包含了一个简单的tophash,tophash存储了键的哈希的高8位,主要是为了快速试错,毕竟只有8位,比较起来也快一点。当然bmap的结构不可能这么简单,只有tophash是由于Go中的Map可能会存储不同类型的键值对,而Go中并不支持泛型,所以只能在编译期间进行推导,而真正的bmap的数据结构如下图所示

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

可以看到,除了tophash之外,还存放了键值对,并且一个桶只能存储8个键值对,另外,map为了内存更加紧凑,键和键在一起,值和值在一起,所以,bmap实际如下图

3.初始化
Go中的Map的初始化主要分为两种,字面量和运行时。

3.1字面量
map := map[string]int{“1”:1,“2”:2}

使用这种方式时,最终都会通过cmd/compile/internal/gc.maplit 初始化,过程大致如下:

func maplit(n *Node, m *Node, init *Nodes) {
// make the map var
a := nod(OMAKE, nil, nil)
a.Esc = n.Esc
a.List.Set2(typenod(n.Type), nodintconst(int64(n.List.Len())))
litas(m, a, init)

entries := n.List.Slice()

// The order pass already removed any dynamic (runtime-computed) entries.
// All remaining entries are static. Double-check that.
for _, r := range entries {
	if !isStaticCompositeLiteral(r.Left) || !isStaticCompositeLiteral(r.Right) {
		Fatalf("maplit: entry is not a literal: %v", r)
	}
}

if len(entries) > 25 {
	......
            return
   }

}

当元素小于25个的时候,编译器会将字面量初始化的结构转换成以下的过程

hash := make(map[string]int,3)
hash[“1”] = 1
hash[“2”] = 2

这种初始化的方式几乎和数组与切片相同,不过数组和切片原本也是以int元素为下标的哈希表,只不过哈希函数很简单罢了,因此相同也不足为奇了。

一旦元素的个数超过了25个,编译器会分别创建两个数组来存放键和值,然后通过for循环加入哈希表。

hash := make(map[string]int,26)
vstack := []string{“1”,“2”,“3”,…“26”}
vstatv := []int{1,2,3,…26}
for i := 0;i < len(vstack);i++{
hash[vstack[i]] = vstatv[i]
}

3.2运行时
即使用make创建map,无论make从哪里来,最终都会通过 runtime.makemap创建map,

mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}

// initialize Hmap
if h == nil {
	h = new(hmap)
}
h.hash0 = fastrand()

// Find the size parameter B which will hold the requested # of elements.
// For hint < 0 overLoadFactor returns false since hint < bucketCnt.
B := uint8(0)
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

可以概括成以下过程

计算哈希占用的内存是否溢出或者超出能分配的最大值;
调用 runtime.fastrand 获取一个随机的哈希种子;
根据传入的 hint 计算出需要的最小需要的桶的数量;
使用 runtime.makeBucketArray 创建用于保存桶的数组;
runtime.makeBucketArray会根据传入的B创建对应的桶数量,并申请一块连续的内存来存储数据。

base := bucketShift(b)
nbuckets := base
// For small b, overflow buckets are unlikely.
// Avoid the overhead of the calculation.
if b >= 4 {
// Add on the estimated number of overflow buckets
// required to insert the median number of elements
// used with this value of b.
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}

if dirtyalloc == nil {
	buckets = newarray(t.bucket, int(nbuckets))
} else {
	// dirtyalloc was previously generated by
	// the above newarray(t.bucket, int(nbuckets))
	// but may not be empty.
	buckets = dirtyalloc
	size := t.bucket.size * nbuckets
	if t.bucket.ptrdata != 0 {
		memclrHasPointers(buckets, size)
	} else {
		memclrNoHeapPointers(buckets, size)
	}
}

if base != nbuckets {
	// We preallocated some overflow buckets.
	// To keep the overhead of tracking these overflow buckets to a minimum,
	// we use the convention that if a preallocated overflow bucket's overflow
	// pointer is nil, then there are more available by bumping the pointer.
	// We need a safe non-nil pointer for the last overflow bucket; just use buckets.
	nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
	last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
	last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow

另外,可以看到,当b大于等于4时候,即桶数量大于24,会额外创建2(B-4)个溢出桶。当B小于4的时候,认为使用溢出桶的概率较低,就会省略这一过程。

4.读写操作
4.1访问
当我们使用 map[key]进行访问的时候,会根据左侧接受的参数个数选择不同的函数

当接受一个参数时,会使用 runtime.mapaccess1,该函数仅会返回一个指向目标值的指针;即m := hash[key] 
当接受两个参数时,会使用 runtime.mapaccess2,除了返回目标值之外,它还会返回一个用于表示当前键对应的值是否存在的 bool 值:即m,err:= hash[key] 
具体的流程如下

runtime.mapaccess1 通过哈希表设置的哈希函数以及种子获取当前键对应的哈希值
runtime.bucketMask拿到该键对应的桶序号,进入该桶
 runtime.add拿到哈希的高8位,由于选择桶序号时位运算使用到的是哈希的最低几位,因此虽然低位hash相同,但是高位hash极有可能不同,从而加速数据的读写。
当发现桶中的 tophash 与传入键的 tophash 匹配之后,我们会通过指针和偏移量获取哈希中存储的键 keys[0] 并与 key 比较,如果两者相同就会获取目标值的指针 values[0] 并返回。

另一个同样用于访问哈希表中数据的 runtime.mapaccess2 只是在 runtime.mapaccess1 的基础上多返回了一个标识键值对是否存在的 bool 值。

4.2写入
当形如 hash[k] 的表达式出现在赋值符号左侧时,即hash[key] = value时,该表达式也会在编译期间转换成 runtime.mapassign 函数的调用,该函数与 runtime.mapaccess1 比较相似。

根据传入的键拿到对应的哈希和桶
如果当前键值对在哈希中存在,那么就会直接返回目标区域的内存地址,哈希并不会在 runtime.mapassign 这个运行时函数中将值拷贝到桶中,该函数只会返回内存地址,真正的赋值操作是在编译期间插入的:
如果当前键值对在哈希中不存在,哈希会为新键值对规划存储的内存地址,通过 runtime.typedmemmove 将键移动到对应的内存空间中并返回键对应值的地址 val。
如果在移动的时候,发现当前桶已经满了,就会调用 runtime.hmap.newoverflow 创建新桶或者使用 runtime.hmap 预先在 noverflow 中创建好的桶来保存数据。新创建的桶不仅会被追加到已有桶的末尾,还会增加哈希表的 noverflow 计数器。当然桶的数量并不会无休止的进行增加,在适当的时候是会进行扩容的。
5.扩容
Go中Map的扩容可以根据触发的条件分为两种:

装载因子超过了6.5,触发翻倍扩容。
哈希使用了太多的溢出桶,触发等量扩容。
5.1等量扩容
首先,什么数量的溢出桶算多呢?

如果常规桶的数量少于2^15,那么溢出桶的数量大于常规桶就算多了。
如果常规桶的数量大于215,那么溢出桶的数量一旦超过215就算多了。
所谓等量扩容,就是创建和原来数量相同的桶,然后把旧的键值对放到新桶中。既然是等量,那么迁移来迁移去有什么意义呢?这主要是由于当我们频繁的增加和删除的时候,会造成内碎片,另外当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏。当我们进行等量扩容的时候,就能使内存更加紧凑,并且此时垃圾回收就能够回收老的溢出桶。

5.2翻倍扩容
翻倍扩容就是创建两倍的桶数量,然后使键值对的分布更加均匀,减少哈希冲突。那么是如何分配的呢?由于map的容量大小为2的倍数,因此二进制只有一位最高位为1,那么这时候可以进行^运算,如果键的二进制为1,那么位置就在老位置,如果为1,就在老位置+旧容量。这一点和java的扩容规则是一样的,就不具体叙述了。

5.3渐进式扩容
当扩容时,buckets指向新分配的桶,oldbuckets指向酒桶,nevacuate指向即将分配的桶的位置。如果旧桶还没有迁移完毕,访问的时候还是会访问未迁移的数据,当nevacuate指向了最后一个位置,才扩容完毕。这种方式避免了扩容过程中,map不能访问的尴尬,避免了扩容带来的瞬时性能抖动。

6.删除
如果想要删除哈希中的元素,就需要使用 Go 语言中的 delete 关键字,这个关键字的唯一作用就是将某一个键对应的元素从哈希表中删除,无论是该键对应的值是否存在,函数都不会返回任何的结果。

map := map[int]int{1:1,2:2}
delete(map,1)

哈希表的删除逻辑与写入逻辑很相似,只是触发哈希的删除需要使用关键字,如果在删除期间遇到了哈希表的扩容,就会分流桶中的元素,分流结束之后会找到桶中的目标元素完成键值对的删除工作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值