Golang哈希Map

Posted by 淦 Blog on January 26, 2023

map又被称为哈希表,是使用频率极高的一种数据结构。哈希表的原理是将多个键/值(key/value) 对分散存储在buckets (桶)中。给定一个键(key), 哈希(Hash) 算法会计算出键值对存储的位置。

哈希碰撞与解决方法

Go语言中的哈希表采用的是开放寻址法中的线性探测(Linear Probing)策略,线性探测策略是顺序(每次探测间隔为1)的。

开放寻址法(Open Addressing) :所有元素都存储在桶的数组中。当必须插入新条目时,将按某种探测策略操作,直到找到未使用的数组插槽为止。当搜索元素时,将按相同顺序扫描存储桶,直到查找到目标记录或找到未使用的插槽为止。

基本操作

map声明与初始化

map 的第一种声明方式如下:

1
var hash map[T]T

其并未对map进行初始化操作,值为nil,因此一旦进行hash[key]=value这样的赋值操作就会报错。

1
panic(plainError("assignment to entry in nil map"))

比较意外的是,Go语言允许对值为nil的 map进行访问,虽然结果毫无意义。 map的第二种声明方式是通过make 函数初始化。make函数中的第二个参数代表初始化创建map的长度,当NUMBER为空时,其默认长度为0。

1
var hash = make (map [T]T, NUMBER)

此种方式可以正常地对map进行访问与赋值。map还有字面量形式初始化的方式,如下所示,country与rating在创建map时即在其中添加了元素。

1
2
3
4
5
6
7
8
var country = map[string]string{
	"China":  "Beijing",
	"Japan":  "Tokyo",
	"India":  "New Delhi",
	"France": "Paris",
	"Italy":  "Rome",
}
rating := map[string]float64{"c": 5, "Go": 4.5, "Python": 4.5, "C++": 3}

访问

1
2
v := hash[key]
v, ok := map[key]

当返回两个参数时,第2个参数代表当前key在map中是否存在。

map赋值

hash[key]=value代表将value与map1哈希表中的key绑定在一起。

delete是Go语言中的关键字,用于进行map的删除操作,形如delete(hash,key),可以对相同的key进行多次删除操作而不会报错。

哈希表底层结构

1
2
3
4
5
6
7
8
9
10
11
type hmap struct{
   count    int
   flags     uint8
   B        uint8
   noverflow uint16
   hash0    uint32
   buckets   unsafe.Pointer
   oldbuckets unsafe.Pointer
   nevacuate uintptr
   extra     *mapextra
}
  • count代表map中元素的数量。
  • flags代表当前map的状态(是否处于正在写入的状态等)。
  • 2的B次幂表示当前map中桶的数量,2^B=Buckets size。
  • noverflow为map中溢出桶的数量。当溢出的桶太多时,map会进行same-size map growth,其实质是避免溢出桶过大导致内存泄露。
  • hash0代表生成hash的随机数种子。
  • buckets是指向当前map对应的桶的指针。
  • oldbuckets是在map扩容时存储旧桶的,当所有旧桶中的数据都已经转移到了新桶中时,则清空。
  • nevacuate在扩容时使用,用于标记当前旧桶中小于nevacuate的数据都已经转移到了新桶中。
  • extra存储map中的溢出桶。
1
2
3
4
5
type bmap struct{
   tophash [bucketCnt]uint8 //一个固定长度为8的数组。此字段顺序存储key的哈希值的前8位。
   key    [bucketCnt]T
   value  [bucketCnt]T
}

map在编译时即确定了map中key、value 及桶的大小,因此在运行时仅仅通过指针操作就可以找到特定位置的元素。桶在存储的tophash字段后,会存储key数组及value数组。

哈希表原理图解

Go语言选择将key与value分开存储而不是以key/value/key/value的形式存储,是为了在字节对齐时压缩空间。

在进行hash[key]的 map访问操作时,会首先通过hash算法找到桶的位置,找到桶的位置后遍历tophash数组,如果在数组中找到了相同的hash,那么可以接着通过指针的寻址操作找到对应的key与value。 在Go语言中还有一个溢出桶的概念,在执行hash[key]=value赋值操作时,当指定桶中的数据超过8个时,并不会直接开辟一个新桶,而是将数据放置到溢出桶中,每个桶的最后都存储了overflow,即溢出桶的指针。在正常情况下,数据是很少会跑到滥出桶里面去的。同理,在map执行查找操作时,如果key的hash在指定桶的tophash数机中不存在,那么需要遍历滥出桶中的数据。

如果一开始,初始化map的数量比较大,则map会提前创建好一些溢出桶存储在extra *mapextra字段。

1
2
3
4
5
type mapextra struct {
	overflow     *[]*bmap
	oldOverflow  *[]*bmap
	nextOverflow *bmap
}

当出现溢出现象时,可以用提前创建好的桶而不用申请额外的内存空间。只有预分配的溢出桶使用完了,才会新建溢出桶。

当发生以下两种情况之一时,map 会进行重建:

  • map 超过了负载因子大小。

在哈希表中有经典的负载因子的概念:负载因子 = 哈希表中的元素数量 / 桶的数量

负载因子的增大,意味着更多的元素会被分配到同一个桶中,此时效率会减慢。Go语言中的负载因子为6.5,当超过其大小后,map会进行扩容,增大到旧表2倍的大小,旧桶的数据会存到oldbuckets字段中,并想办法分散转移到新桶中。当旧桶中的数据全部转移到新桶中后,旧桶就会被清空。

  • 溢出桶的数量过多。

  • map只会新建和原来相同大小的桶,目的是防止溢出桶的数量缓慢增长导致的内存泄露。

当进行map的delete操作时,和赋值操作类似,delete 操作会根据key找到指定的桶,如果存在指定的key,那么就释放掉key与value引用的内存。同时tophash中的指定位置会存储emptyOne,代表当前位置是空的。

总结

指定了map的长度N,则在初始化时会生成桶,桶的数量为log2N。如果map的长度大于24,则会在初始化时生成溢出桶。溢出桶的数量为2^(B-4),其中,B为桶的数量。

在涉及访问、赋值、删除操作时,会首先计算出数据的hash值,然后进行简单的&运算计算出数据存储在桶中的位置,接着将hash值的前8位与存储在桶中的hash和key进行比较,最后完成赋值与访问操作。

如果数据放不下了,则会申请放置到溢出桶中。如果map超过了负载因子大小,则会进行双倍重建,如果溢出桶太大,则会进行等量重建。数据的转移采取了写时复制的规则,即在用到时才会将旧桶的数据打散放入新桶。