golang map原理分析

221 阅读5分钟

什么是map?
map就是一种key -value的数据结构

接下来我们将从以下几点探讨goalng中map的实现

map的底层实现

hmap

golang使用了很多结构体表示哈希表,其中最核心的是hmap,可以说hmap就是map

type hmap struct{
    count int    //元素个数
    flags uint8   //写标志位
    
    B uint8    //有 2 ^ B 个bucket
    hash0 uint32  //哈希种子,生成哈希值要用,在建立map时就存在
    
    buckets unsafe.Pointer  //指向buckets数组
    oldbucketes unsafe.Pointer  
    
    navacuate uintptr
    extra *mapextra
}
  • count表示map中的元素个数,len() 就是调用了这个字段
  • B表示这个map中有个2^B个buckets
  • buckets就是一个指向桶切片的指针,这个桶中存放键值对

bmap

bmap 就是buckets指向的结构体,里面是真正存key和value,每个桶能存8个key、value,还会记录8个topbits用来查找key

func bmap struct{
    topbits [8]int8
    key [8]keytype
    value [8]valuetype
    pad uintptr
    overflow uintptr  
}
  • 一个key值经过哈希运算生成一个64位二进制,其中低B位(有2^B次方个桶),会用来确定需要放入哪个桶中,高8位来确定放入桶的那个位置
  • 一个桶中只能放8个键值对,如果有第九个,那么就会创建一个新的bucket,然后通过overflow连接两个桶
image.png

map如何查找

那么一个key是如何查找到map中具体的位置呢

  • 首先key会经过哈希函数运算,变成一个64位的二进制值
  • 这个key的低B位,会用来确定存在于哪个桶。比如B=3,低3位是010 = 2,接下来就会进入[]bmap下标为2的桶中
image.png
  • topbits存的就是key高8位的值(称为tophash),通过遍历,topbits如果找到这个tophash。那么就会更新value
  • 找的过程中遇到空topbits(empty)就会记录下来,然后继续遍历,这个桶遍历完会遍历overflow关联的桶
  • 如果最终没有找打这个tophash,就会在前面记录的那个空位,把值存上
  • topbits存tophash,key存hash后的key,value就是vlaue
  • 如果桶位置满了,就会创建一个新的关联桶

map如何解决冲突 -- 拉链法

  • 常见的哈希解决冲突的方式有两种

    • 开放寻址法:类似数组,如果key对应的位置已经有value了,那么就发生冲突了。就继续往下遍历,就把value放入下一个value为空的位置
    • 拉链法:拉链法,key对应的不是一个单独的value,而是一个链表,有相同的key,就把链表更新,添加一个新的value
  • golang使用了溢出桶和overflow指针来解决了冲突,毫无疑问使用的拉链法

map如何扩容

首先讲一个概念--负载因子
负载因子 = count(元素数) / 2 ^ B (桶个数)
一个桶可以放8个键值对,就是说如果桶都放满的情况,负载因子 = 8
go发生扩容有两种情况:

  • 1、负载因子> 6.5:6.5这就代表有很多的桶已经装满了,查找、插入效率都会变低
  • 2、但是还有一种情况,就是插入的时候元素很多,桶会创建溢出桶。如果再进行删除操作,就会造成溢出桶过多的情况,这个要分情况讨论
    • B < 15 : 当B < 15, 但是溢出桶总数 > 2 ^ B
    • B >= 15 : B >= 15, 但是溢出桶总数 >= 2^15
    • 是对第一个中情况的补充,元素少,桶多,key会很分散,查找插入效率非常低

具体的扩容策略:

  • 对于第一种情况,其实就是buckets太少,而元素太多,扩容策略就是把B+1,bucket总数变成了原来的2倍,这时候就有了新老Buckets。

    • 这个时候oldbucktes会指向原来的bucktes,bucktes会重新申请一个原来2倍容量的内存
    • 称之为翻倍扩容
  • 对于第二种情况,负载因子<6.5,那就是元素少,而buckets太多。这个时候我们要对做的重新对buckets中的元素进行梳理,是key排列的更紧密,提高空间利用率。

    • 这个时候oldbucktes会指向原来的bucktes,bucktes会重新申请一个原来1倍容量的内存
    • 称之为等量扩容

map扩容使用的是渐进式库容,就是确定好扩容策略后,不会一次性将新旧桶的数据全部搬迁,而是每次最多会搬迁2个桶数据,而且只有在发生插入、删除、修改操作时候,才会进行桶的搬迁。判断搬迁是否完成的条件就是判断oldbuckets == nil

那么,为什么负载因子阈值是6.5

这是经过官方测试的结果。如果负载因子太大,就会有很多的溢出桶。如果负载因子太小,就会浪费空间。

go map为什么是非线程安全的

  • 线程安全:就是说我们对这个map进行并发读写的时候,结果是符合预期的

  • go map是线程非安全的。如果多个协程对map进行读写,会报painc错误

  • 为什么?

    • 设计师设计的时候,认为map使用场景大部分都是典型场景,不需要并发访问。不应该为了小部分情况,而付出一个加锁的代价来实现并发
  • 怎么做到并发安全

    • 1、虽然map不支持并发,但是我们依然可以使用锁来保证并发安全
    • 2、go设计师为我们设计了一个Sync.Map,这个map是线程安全的,我们也可以使用这个来实现线程安全