Go理解map实现,又get到了一个知识点

577 阅读5分钟

前言

map作为经典的元素集合方式,能通过键来直接读取到值,极大的方便了程序员的日常开发。在某个摸鱼时刻,咱忽然想到,map这么强大,读写才为O(1)时间复杂度的,它到底是什么原理呢,而go又是怎么实现的呢?

map方案

首先咱们来了解下map的实现思路,常见的map实现有两种方案,分别是 开放寻址法拉链法

开放寻址法

开放寻址法是什么意思呢,举个例子来理解下。

image.png

这是总的流程图,咱们一步步拆解来看。

image.png

这是我们要写入的键值对,键是a,储存的值是A

image.png

Hash代表某种算法,作用是接收一个字符串,计算得出唯一的很长的哈希值,然后让该哈希值取余数组长度(储存map数据的底层数组长度),得到数组索引。比如传入a,得到哈希值0x5c520101110001010010b, 若底层数组长度为5,该哈希值取余后得到2,然后返回。

image.png

这是map储存数据的底层数组,里面每个元素都储存着键值对,K代表键,V代表值,若数组为空则表示没有储存数据。

理解每一层的含义后,咱们回到总流程图。

image.png

现在咱们要写入一个键值对a:A

首先map会通过Hash算法,接收a计算并返回映射的索引2,说明a:A要储存在数组的第三个位置。

然后将a:A储存到该位置,若发现数组中该位置已有数据,即发生哈希冲突,则继续往后找,直到找到数组空闲的位置。

比如图中的第三个位置已经储存了一个键值对K:V了,则map会安排a:A往后寻找,发现数组的第四个位置是没有数据的,故将a:A储存到该位置。

若数组存满了,则将数组扩容即可。

拉链法

拉链法的思想和开放寻址法类似,但相比开放寻址法,拉链法储存的数据位置更加明确,因此使用更加广泛。

image.png

这是总的流程图,同样咱们拆解一步步来看。

image.png

前面部分和寻址法一样,a:A为要写入的键值对,Hash哈希算法,将字符串计算取余后得出数组索引。

image.png

但是这个底层数组的结构就不一样了,拉链法的数组储存的不再是键值对了,而是链表的指针,这种差异会导致什么变化呢。

咱们回到总流程图。

image.png 例如,咱们要写入键值对为a:A,同样通过Hash得出数组索引为2,而在数组这个位置,已经有数据了,a:A便储存到该链表的尾部。

以上便是拉链法的原理,比较一下开放寻址法,拉链法有几个优点:

  1. 若有哈希冲突,拉链法会循着链表竖向往下储存,减少了频繁扩容。
  2. 拉链法对每个键的索引定位明确,

Go的map实现

接下来我们分析下go是怎么实现map的。

map的底层结构位于runtime\map.go

// A header for a Go map.
type hmap struct {
	count     int 
	flags     uint8
	B         uint8  
	noverflow uint16 
	hash0     uint32 // hash seed
        
	buckets    unsafe.Pointer
	oldbuckets unsafe.Pointer
	nevacuate  uintptr      
        
	extra *mapextra 
}

咱们理解下重要的几个属性

buckets,可看作储存数据的底层数组,称之为桶。大小永远是2的B次方

B,为log_2的对数,与buckets的大小有关系。若buckets大小为8,为2的3次方,则B=3

hash0, 哈希算法的随机种子

停上几秒,我们继续

buckets是一个可指向任意类型的指针unsafe.Pointer,实际指向的是bmap结构体,bmap里是一个大小为8的uint8数组,但到编译时会动态生成新的结构。

// A bucket for a Go map.
type bmap struct {
   tophash [bucketCnt]uint8
}
const (
    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits
    ...
)
// 以下是编译时生成的新结构
type bmap struct {
   topbits [8]uint8    // 储存hash值的高8位
   keys    [8]keytype  // 储存键的数组
   elems   [8]elemtype // 储存值的数组
   overflow uintptr    // 溢出的bucket指针
}

通过图示来理解会更加清楚,蓝色bmap为正常桶,优先储存数据。红色bmap为溢出桶,当蓝色的bmap空间不足时,会用溢出桶来储存数据。

image.png

那map如何读写的呢?比如读取键值对a:A

首先计算桶号,咱们得知道a:A储存在哪个桶里。

如图,a为键,hash0为hmap中哈希算法的随机种子,hasher为哈希算法

map会将a、hash0传入hasher计算出哈希值0x5c520101110001010010b,再根据B的值取哈希值的后面几位010

图示B=3,则取010,010十进制为2,故取buckets里的第三个bmap。

image.png

然后使用tophash(),用于计算哈希值的高地址。

通过取出哈希值前八位,传入tophash(), 得出值0x5c,再拿该值去遍历topbits后得到索引0,然后到elems中取出第一个值A返回,即读取成功。

image.png

以上即是map的实现过程,由于读写只遍历了topbits, 而topbits又是固定8个容量,为常数,因此平均情况下时间复杂度为O(1)。

思考

此处留下几个问题思考,有兴趣的可以根据这些问题继续深入了解map。

  1. 溢出桶是什么,为什么要弄溢出桶?
  2. 如果哈希冲突过多,map是怎么应对的?
  3. map容量不够时是如何扩容的?