Golang | 由浅入深理解哈希表Map

3,439 阅读7分钟

引言

哈希表又称 字典散列表, 是最常见的数据结构之一,能够提供一种1对1映射,其 O(1)的读写性能十分优秀

本文从为什么需要哈希表入手,分析了影响哈希表性能的因素,介绍了常见的哈希函数和解决哈希冲突的方法。接着从源码分析在Go中,哈希表是如何设计的,以及其在性能&&稳定性方面做的优化

本文基于go 1.16进行分析

介绍

为什么需要哈希表

  • 链表能够实现O(1)的插入却不能提供O(1)的查找,数组能够提供O(1)的查找却不能提供O(1)的插入
  • 哈希表是对数组链表结构的一个优化,取其精华,实现最佳情况下O(1)的读写

哈希表是一种对随机存储的优化。底层使用数组实现。 在使用时,先按key哈希值进行分类,在做读写时,直接按照key哈希值在对应的内存区域进行读写操作,避免了多余的计算,实现读写操作都能达到O(1)的时间复杂度

影响哈希表性能的因素

哈希表底层基于数组实现, 关键在于计算key哈希值, 这里就需要一个哈希函数, 哈希函数的选择在很大程度上能够决定哈希表的读写性能
当不同的key经过哈希函数的计算得到的哈希值是相同的,即发生了一次哈希冲突。当发生哈希冲突时,哈希表的读写性能通常会下降到O(logN)甚至O(N)级别。
因此我们能够总结出影响哈希表性能的一些因素:

  • 哈希函数是否完美,是否能够尽量减少哈希冲突的发生
  • 当哈希冲突发生时,如何解决哈希冲突
  • 哈希表底层基于数组实现,当数组接近被填满时,发生哈希冲突的概率会大大增大,因此需要对数组做扩容。如何选择合适的扩容阈值并且如何做数组扩容也会影响哈希表的性能与稳定性

常见的哈希函数

直接地址法

取关键字或关键字的某个线性函数值为散列地址。H(key) =key 或 H(key) = a*key +b, 其中a和b为常数。

除留余数法

取关键字被某个不大于散列表长度m 的数p求余,得到的作为散列地址。即 H(key) key % p, p<m。

数字分析法

当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突。

image.png

随机数法

选择一个随机函数,把关键字的随机函数值作为它的哈希值。通常当关键字的长度不等时用这种方法。

如何判断哈希函数的好坏

哈希结果分布越均匀的哈希函数,越能够趋近于O(1)性能的读写操作 image.png

常见的解决哈希冲突的方法

开放寻址法

这种方法的核心思想是依次探测和比较数组中的元素以判断目标键值对是否存在于哈希表中,如果我们使用开放寻址法来实现哈希表,那么实现哈希表底层的数据结构就是数组。

  • 写入 如果当前位置冲突,就将键值对写入到下一个索引不为空的位置。
  • 读取 作的结束。 从索引的位置开始线性探测数组,找到目标键值对或者空内存就意味着这一次查询操作.
  • 装载因子 LoadFactor = 哈希表中元素数量 / 哈希表底层数组长度 装载因子是影响哈希的读写性能的重要因素,当装载因子大于0.7之后,哈希表的性 能就会急剧下降,当装载因子达到1.0时,整个哈希表就完全失效,这时查找和插入 任意元素的时间复杂度都是O(logN)O(N)

拉链法

实现拉链法一般会使用数组加上链表,不过类似java会在拉链法的哈希中引入红黑树优化性能。它的实现比较开放地址法稍微复杂一些,但是平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间

  • 写入 选择桶的方式是直接对哈希返回的结果取模
  1. 找到键相同的键值对— 更新键对应的值
  2. 没有找到键相同的键值对 一 在链表的末尾追加新的键值对
  • 读取 依次遍历桶中的链表,直到遍历到链表的结尾

再哈希法

即当发生哈希冲突时,将key做一定处理后,再次对key计算哈希值

使用

初始化map

map的初始化分两种
第一种是使用make进行初始化,返回一个指针

myMap := make(map[int]int, 0)

第二种是使用字面量初始化

myMap := map[int]int{
   1:1,
   2:2,
}

map的增删改查操作

func mapOp() {
   myMap := map[int]int{
      1:1,
      2:2,
   }
   // 赋值
   myMap[3] = 3
   // 删
   delete(myMap ,3)
   // 改
   myMap[1] = 11
   // 查 val表示 在map里,key=1对应的value,exist表示map中是否存在key=1
   val , exist := myMap[1]
}

nil可以作为key ,后定义的会将先前定义的覆盖

func test1() {
   mmap := make(map[interface{}]interface{}, 0)
   mmap[nil] = 1
   mmap[nil] = 2
   log.Println("nil: ", mmap[nil])
}

image.png

遍历

使用for-range对map遍历

func mapOp() {
   myMap := map[int]int{
      1: 1,
      2: 2,
   }
   log.Printf("地址:[%p]", myMap)
   for key, val := range myMap {
      log.Printf("Map地址:[%p] , key:[%v] key地址:[%p] ,val:[%v] val地址:[%p]", myMap, key, &key, val, &val)
   }
}

image.png 需要注意几点

  1. 每次遍历map的顺序都是随机的
  2. map遍历的过程中,key value都是使用的同一块内存,只是会在遍历中,将实际的key value的值拷贝到那一块内存中
  3. 遍历过程中,如果有一个还没有被遍历到的元素被删除了,或者是新加入一个元素,则此元素不会被遍历到
  4. 被遍历的map是原map的一个副本。那么在遍历过程中对此map元素的修改将体现到循环变量中,因为被遍历的map和原map共用同一片底层内存

需要注意的地方

map的键类型不可以是函数类型、字典和切片。 因为他们不能做相等判断。

如果key为interface{} , 在运行时 以一个func()作为key,会抛出runtime.panic

mmap := make(map[interface{}]interface{} , 0)
mmap[1] = 1
mmap[[]int{12,3}] = 2

image.png

思考

我们可以发现一个有趣的点,在golang中,函数类型、字典和切片都是指针形式的,他们不能作为mapkey。可是同样为指针形式channel可以作为map的key呢?

  • channel本身是可以做比较的,他们比较的结果总为false

  • 个人理解是因为channel在创建后,它的底层指针是不会再发生变化的,但是slice\map\func()可能会发生变化

源码分析

结构

map 运行中主要使用 runtime.hmap来表示,我们看看它的结构

type hmap struct {

   count     int     //  当前哈希表中的元素数量
   flags     uint8   // 扩容标志位
   B         uint8   // 哈希表中 桶的数量
   noverflow uint16  // 记录溢出桶的大致数量 当溢出桶的数量不多时,值为精确值当;溢出桶的数量过多时, 为大致值
   hash0     uint32 // hash种子

   buckets    unsafe.Pointer // 容量为2^B的桶数组,当count=0时,为nil
   oldbuckets unsafe.Pointer // 扩容时保存旧的桶
   nevacuate  uintptr        // 用于记录数据转移的进度,低于此值的数据已经从旧桶转移到了新桶中

   extra *mapextra // 溢出桶
}

我们来看看溢出桶的数据结构

type mapextra struct {
  
   overflow    *[]*bmap // 溢出桶
   oldoverflow *[]*bmap // 旧的溢出桶

   nextOverflow *bmap // 指向空闲溢出桶的指针
}

看看 bmap 结构

type bmap struct {
  tophash [bucketCnt]uint8
}
  • tophash包含该bucket中每个键的哈希值的顶部8个字节。
  • 如果tophash[0]minTopHash // 5 ,则tophash[0]为桶的迁移状态。

我们可以得到map大致的数据结构

image.png

  1. 每个bmap能储存8个键值对
  2. 当某个桶已满时,再次落入该桶的数据会使用nextOverflow指向的桶储存溢出的数据
  3. 正常桶和溢出桶在内存中是连续分布的。 在运行时,bmap结构体不止包含tophash字段。因为哈希表中可能存储不同类型的键值对,所以键值对占据的内存空间大小只能在编译时进行推导。根据编译期间的cmd/compile/internal/gc.bmap 函数重建它的结构:
type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

初始化

使用字面量

使用字面量的方式进行初始化时,最终都会调用cmd/compile/internal/gc/sinit.go.maplit进行初始化。我们来大概看看

func maplit(n *Node, m *Node, init *Nodes) {
  // dosomething 
  
  // 1. 如果字面量超过25个
  if len(stat) > 25 {
  
     // 2. 将key value分为 2 个数组,将key value使用for循环加入到各自的数组中
    tk := types.NewArray(n.Type.Key(), int64(len(stat)))
    tv := types.NewArray(n.Type.Elem(), int64(len(stat)))
  
    fixedlit(inInitFunction, initKindStatic, datak, vstatk, init)
    fixedlit(inInitFunction, initKindStatic, datav, vstatv, init)
    
  } else {
        addMapEntries(m, stat, init)
  }
  addMapEntries(m, dyn, init)
}
  • 使用字面量初始化时,如果字面量小于25个时,编译器会将字面量初始化的结构体转换成以下的代码,将所有的键值对一次加入到哈希表中
hash := make(map[string]int, 3)
hash["1"] = 2
hash["3"] = 4
hash["5"] = 6
  • 字面量超过25个时,编译器会创建两个数组分别存储键和值,这些键值对会通过如下所示的for 循环加入哈希:
hash := make(map[string]int, 26)
vstatk := []string{"1", "2", "3", ... , "26"}
vstatv := []int{1, 2, 3, ... , 26}
for i := 0; i < len(vstak); i++ {
    hash[vstatk[i]] = vstatv[i]
}

使用make

当创建的哈希被分配到栈上并且其容量小于8时,在编译阶段会使用如下方式快速初始化哈希,这也是编译器对小容量的哈希做的优化:

func makemap_small() *hmap {
  h := new(hmap)
  h.hash0 = fastrand()
  return h
}

其他情况会调用runtime.makemap方法。其实无论使用哪种方法初始化map,最后都会调用runtime.makemap

func makemap(t *maptype, hint int, h *hmap) *hmap {
        // 1. 计算哈希占用的内存是否溢出或者超出能分配的最大值
	mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
	if overflow || mem > maxAlloc {
		hint = 0
	}
        // 2. 调用 runtime.fastrand 获取一个随机的哈希种子
	if h == nil {
		h = new(hmap)
	}
	h.hash0 = fastrand()
        // 3. 根据传入的 hint 计算出需要的最小需要的桶的数量
	B := uint8(0)
	for overLoadFactor(hint, B) {
		B++
	}
	h.B = B

	if h.B != 0 {

                // 4. 使用 runtime.makeBucketArray 创建用于保存桶的数组
		h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
		if nextOverflow != nil {
			h.extra = new(mapextra)
			h.extra.nextOverflow = nextOverflow
		}
	}
	return h
}

runtime.make 方法主要做了这四件事情

  1. 计算哈希占用的内存是否溢出或者超出能分配的最大值
  2. 调用 runtime.fastrand 获取一个随机的哈希种子
  3. 根据传入的 hint 计算出需要的最小需要的桶的数量
  4. 使用 runtime.makeBucketArray 创建用于保存桶的数组

runtime.makeBucketArray 会根据传入的 B 计算出的需要创建的桶数量, 并在内存中分配一片连续内存空间用于存储数据:

func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
	base := bucketShift(b)
	nbuckets := base
        // 1. 当桶的数量小于 2^4 时,由于数据较少、使用溢出桶的可能性较低,会省略创建的过程, 以减少额外开销
        // 当桶的数量多于 2^4 时,会额外创建 2^(B−4) 个溢出桶
	if b >= 4 {
		nbuckets += bucketShift(b - 4)
		sz := t.bucket.size * nbuckets
		up := roundupsize(sz)
		if up != sz {
			nbuckets = up / t.bucket.size
		}
	}
        // 溢出桶的溢出指针 最后一个溢出桶需要一个安全的非空指针
	buckets = newarray(t.bucket, int(nbuckets))
	if base != nbuckets {
		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
}
  • 当桶的数量小于 2^4 时,由于数据较少、使用溢出桶的可能性较低,会省略创建的过程以减少额外开销
  • 当桶的数量多于 2^4 时,会额外创建 2^(B-4) 个溢出桶,且最后一个溢出桶需要一个安全的非空指针

流程图

image.png

读写操作

map 的读主要包含2个接口 runtime.mapaccess1runtime.mapaccess2,第二个接口相对于第一个接口来说能够知道 目标key是否在map里存在, 考虑到零值的情况,推荐使用第二个接口。 我们来分析一下runtime.mapaccess1:

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	alg := t.key.alg
        // 1,计算key的哈希值
	hash := alg.hash(key, uintptr(h.hash0))
	m := bucketMask(h.B)
        // 桶序号
	b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
       
       // 2. map是否正在迁移中
       // 如果旧桶不为空,证明不处于迁移状态,从旧桶中定位数据
        if c := h.oldbuckets; c != nil {
           // dosomething
        }
	top := tophash(hash)
         //  3.遍历查找该桶中是否含有key对应的数据
bucketloop:
	// dosomething
	return unsafe.Pointer(&zeroVal[0])
}

bucketloop循环中, 依次遍历正常桶和溢出桶中的数据,它会先比较哈希的高8位和桶中存储的tophash,再比较传入key的和桶中的值 ,这样可以加速数据的读写

  • 用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高8位,这种设计能够减少同一个桶中有大量相等 tophash 的概率影响性能。

map的赋值操作主要在runtime.mapassign函数中

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
   // 检查 map是否处于写状态
   if h.flags&hashWriting != 0 {
      throw("concurrent map writes")
   }
   
   // 1. 根据哈希种子计算key对应的哈希值
   hash := t.hasher(key, uintptr(h.hash0))

again:
   bucket := hash & bucketMask(h.B)
   // 扩容检查
   if h.growing() {
      growWork(t, h, bucket)
   }  
  // 2. 遍历比较 key的哈希值 和 桶中存储的tophash
bucketloop:
   for {
      for i := uintptr(0); i < bucketCnt; i++ {
      // 3. 桶中不存在相应的键
         if b.tophash[i] != top {
           // 插入新键值对
         }
         // 4. 桶中已存有该键 
         k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
         if t.indirectkey() {
            k = *((*unsafe.Pointer)(k))
         }
         // 5. 哈希值相同的情况下,二次比较:直接比较key是否相同, 防止哈希冲突
         if !t.key.equal(key, k) {
            continue
         }
         // 6. map中已有值,更新
         if t.needkeyupdate() {
            typedmemmove(t.key, k, key)
         }
        // 退出
         goto done
      }
      // 7. 桶中不存在该key ,遍历溢出桶
      ovf := b.overflow(t)
      if ovf == nil {
         break
      }
      b = ovf
   }
    // 8. 溢出桶中不存在该key
   if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
       // 扩容并再次执行search
      hashGrow(t, h)
      goto again // Growing the table invalidates everything, so try again
   }
   // 9. 使用新桶保留数据
   if inserti == nil {
     // dosomething

done:
   if h.flags&hashWriting == 0 {
      throw("concurrent map writes")
   }
   h.flags &^= hashWriting
   if t.indirectelem() {
      elem = *((*unsafe.Pointer)(elem))
   }
   return elem
}

注意

  • 如果创建新的溢出桶,新的溢出桶和旧桶内存地址不连续,随后会触发扩容

扩容

map会在两种情况时发生扩容

  1. 装载因子已经超过 6.5
  2. 哈希使用了太多溢出桶 如果是使用了太多的溢出桶引发的扩容,那么这就是一次sameSizeGrow扩容.

为什么引入sameSizeGrow

  • 当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏
  • 引入了 sameSizeGrow 通过复用已有的哈希扩容机制解决该问题,一旦哈希中出现了过多的溢出桶,它会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存

分析

扩容通常分为两个步骤

  1. 创建新的桶
  2. 数据迁移
    map将这两个步骤拆成两个函数:hashGrow 用于创建新桶,evacuate用于数据迁移

创建新的桶

func hashGrow(t *maptype, h *hmap) {
   
   // 创建一组新桶和预创建的溢出桶
   oldbuckets := h.buckets
   newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
   
   // 将原有的桶数组设置到 oldbuckets  
   h.oldbuckets = oldbuckets
   //  将新的空桶设置到 buckets
   h.buckets = newbuckets
   
   h.nevacuate = 0
   h.noverflow = 0
}

创建新桶时,使用makeBucketArray 创建新桶并预创建溢出桶, 随后将原有的桶数组设置到oldbuckets上,并将新的空桶设置到buckets

数据迁移

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
      x := &xy[0]
       // 如果不是等量扩容
      if !h.sameSizeGrow() {
         // 分配一个高位迁移目的桶:y
         y := &xy[1]
         y.b = (*bmap)(add(h.buckets (oldbucket+newbit)*uintptr(t.bucketsize)))
         y.k = add(unsafe.Pointer(y.b), dataOffset)
         y.e = add(y.k, bucketCnt*uintptr(t.keysize))
      }

      for ; b != nil; b = b.overflow(t) {
            var useY uint8
            // 按照不同的扩容类型进行分流
            if !h.sameSizeGrow() {
            // dosomething
            }
       }
      // 清除旧桶的内存
      if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {    
         memclrHasPointers(ptr, n)
      }
   
   // 增加迁移计数器,并在所有的旧桶都被分流后清空map的 oldbuckets 和 oldoverflow
   if oldbucket == h.nevacuate {
      advanceEvacuationMark(h, t, newbit)
   }
}

如果是翻倍扩容,evacuate会将旧桶中的数据分流到两个新桶中,所以它会创建两个evacDst结构体用于保存分配上下文,这两个结构体分别指向了一个新桶:

image.png

map的扩容总体属于增量扩容,能够避免扩容时造成的瞬时内存抖动

遍历

对map的遍历是无序

map的遍历也分两步

  1. mapiterinit生存map迭代器
  2. mapiternext进行map迭代

mapiterinit

func mapiterinit(t *maptype, h *hmap, it *hiter) {
   // dosomething
   
   // 生成随机数,用于决定从哪里开始循环迭代
   r := uintptr(fastrand())
   if h.B > 31-bucketCntBits {
      r += uintptr(fastrand()) << 31
   }
   mapiternext(it)
}

可见,fastrand方法会生成一个随机数,根据随机数,选择一个桶位置作为起始点进行遍历迭代

mapiternext

func mapiternext(it *hiter) {
   h := it.h
 
   if h.flags&hashWriting != 0 {
      throw("concurrent map iteration and map write")
   }
// 定位下一个桶
next:
   if b == nil {
    // dosomething
   }
   // 从当前桶开始遍历
   for ; i < bucketCnt; i++ {
      // dosomething
   }
   // 遍历溢出桶
   b = b.overflow(t)
   i = 0
   goto next
}

思考

map迭代的时候为什么要随机化

  • 主要是为了降低开发者对map遍历顺序的依赖性
  • map本身是一个偏随机化的结构,不应该存在某种固定的顺序 ,并且这个顺序可能随着版本迭代而改变

待补充

压测数据

设计思想

哈希表map是最常见的数据结构中的一种,利用数组能够O(1)定位数据与链表O(1)更新数据的特性结合实现,达到了最优情况下O(1)时间复杂度的数据的读。

在Golang中,map底层使用数组存储,当发生哈希冲突时,将数据存入溢出桶中。当需要扩容时,使用增量扩容的方法,避免了瞬时的内存抖动

结语

map作为我们最常用的数据结构之一,性能上要做到最优的同时,还要考虑,这和我们程序设计中的某些思想有异曲同工之妙