Go-Map相关

并发

Go中map默认不安全的,也实现了并发安全的对象:sync.Map

和Java的HashMap一样,并发安全的是:ConcurrentHashMap

并发不安全

不安全是因为源码中没有实现读写分离。进行了判断异常:

在哈希表写操作时,会将哈希表的标志位 hashWriting 设置为 1,以表明当前正在执行写操作。当其他协程执行哈希表的读操作时,会根据当前的标志位判断是否能够执行读操作,如果标志位为 hashWriting,则说明当前正在执行写操作,就会抛出 concurrent map read and map write 的异常,提示读写并发冲突。

    if h.flags&hashWriting != 0 {
        fatal("concurrent map read and map write")
    }

sync.Map

要在并发环境下安全地使用哈希表,需要使用 Go 语言提供的并发安全的哈希表 sync.Map。使用 sync.Map 可以避免在多个协程同时读写哈希表时发生并发冲突的问题。

sync.Map 中的读写操作是并发安全的,因为它内部实现了读写锁,能够保证并发读写时的线程安全。sync.Map 的读写操作与普通哈希表的操作基本相同,主要有以下几个方法:

  • Load(key interface{}) (value interface{}, ok bool):根据键获取值,并返回是否存在;
  • Store(key, value interface{}):根据键存储值;
  • LoadOrStore(key, value interface{}) (actual interface{}, loaded bool):根据键获取值,如果不存在则存储给定的值;
  • Delete(key interface{}):删除给定键的值;
  • Range(f func(key, value interface{}) bool):遍历哈希表,并将每个键值对传递给给定的函数 f。
// 创建一个 sync.Map 对象
    var m sync.Map

    // 存储数据
    m.Store("key1", "value1")
    m.Store("key2", "value1")

    // 加载数据
    //if value, ok := m.Load("key1"); ok {
    //    fmt.Println(value)
    //}

    m.Load("key2")
    // 删除数据
    //m.Delete("key1")

    // 遍历哈希表
    m.Range(func(key, value interface{}) bool {
        fmt.Println(key, value)
        return true
    })
    
    //key1 value1
    //key2 value1

底层数据结构

很多语言的设计思想是想通的:Go、Java对底层map的实现也是如此,

Go的实现:数组 + 链表(如果链表过长导致性能下降 ,也许后续的Go版本会像Java转成红黑树吧)

Java中Map:数组 + 链表 或者 红黑树 具体可参考HashMap原理

Golang的map使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,也即bucket(哈希桶),而每个bucket就保存了map中的一个或一组键值对。

map数据结构由 runtime/map.go/hmap 定义:

// A header for a Go map.
type hmap struct {
    // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/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
}

 字段说明

 下图展示一个拥有4个bucket的map:

本例中 hmap.B=2 , 而hmap.buckets长度是2^B为4. 元素经过哈希运算后会落到某个bucket中进行存储。查找过程类似。
bucket 很多时候被翻译为桶,所谓的哈希桶 实际上就是bucket。

 bucket数据结构

在常量定义里面 bucketCnt

    // Maximum number of key/elem pairs a bucket can hold.
    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits

bucketCntBits 表示每个桶的容量可以存储的键值对数量的比特数,这里的值为 3,即每个桶最多可以存储 2的3次幂=8个键值对。

bucketCnt 表示每个哈希表中桶的数量,这里的值为2的3次幂=8,即每个哈希表中有 8 个桶。

这两个常量的定义表明,Go 语言的哈希表实现中,每个桶的容量是固定的,并且为 8 个键值对,而每个哈希表中桶的数量也是固定的,并且为 8 个桶。当哈希表中的元素数量达到一定阈值时,会通过增加桶的数量来扩容哈希表,以保证哈希表的性能和容量。

bucket数据结构由
runtime/map.go/bmap 定义:

bmap源码:

// A bucket for a Go map.
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.
}

 

type bmap struct {
    tophash [8]uint8 //存储哈希值的高8位
    data byte[1] //key value数据:key/key/key/.../value/value/value...
    overflow *bmap //溢出bucket的地址
}
  • tophash是个长度为8的数组,哈希值相同的键(准确的说是哈希值低位相同的键)存入当前bucket时会将哈 希值的高位存储在该数组中,以方便后续匹配。

  • data区存放的是key-value数据,存放顺序是key/key/key/…value/value/value,如此存放是为了节省 字节对齐带来的空间浪费。

  • overflow 指针指向的是下一个bucket,据此将所有冲突的键连接起来。

注意:上述中data和overflow并不是在结构体中显示定义的,而是直接通过指针运算进行访问的

下图展示bucket存放8个key-value对:

 哈希冲突

当有两个或以上数量的键被哈希到了同一个bucket时,我们称这些键发生了冲突。Go使用链地址法来解决键冲突。由
于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个键值对,用类似链表的
方式将bucket连接起来。
下图展示产生冲突后的map:

bucket数据结构指示下一个bucket的指针称为overflow bucket,意为当前bucket盛不下而溢出的部分。事实上
哈希冲突并不是好事情,它降低了存取效率,好的哈希算法可以保证哈希值的随机性,但冲突过多也是要控制的,

 负载因子

负载因子用于衡量一个哈希表冲突情况,公式为:

负载因子 = 键数量/bucket数量

例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.
哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:
哈希因子过小,说明空间利用率低
哈希因子过大,说明冲突严重,存取效率低
每个哈希表的实现对负载因子容忍程度不同,比如Redis实现中负载因子大于1时就会触发rehash,而Go则在在负载
因子达到6.5时才会触发rehash,因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,
所以Go可以容忍更高的负载因子。

渐进式扩容

扩容的前提条件

为了保证访问效率,当新元素将要添加进map时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。触发扩容的条件有二个:

  1. 负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个。

  2. overflow数量 > 2^15时,也即overflow数量超过32768时。

增量扩容

当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。
考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访
问map时都会触发一次搬迁,每次搬迁2个键值对。
下图展示了包含一个bucket满载的map(为了描述方便,图中bucket省略了value区域):

 

当前map存储了7个键值对,只有1个bucket。此地负载因子为7。再次插入数据时将会触发扩容操作,扩容之后再将
新插入键写入新的bucket。
当第8个键值对插入时,将会触发扩容,扩容后示意图如下:

 

hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的
bucket中。后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键
值对全部搬迁完毕后,删除oldbuckets。
搬迁完成后的示意图如下:

 

数据搬迁过程中原bucket中的键值对将存在于新bucket的前面,新插入的键值对将存在于新bucket的后面。实际搬
迁过程中比较复杂,将在后续源码分析中详细介绍。

等量扩容

所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对
重新排列一次,以使bucket的使用率更高,进而保证更快的存取。在极端场景下,比如不断的增删,而键值对正好集
中在一小部分的bucket,这样会造成overflow的bucket数量增多,但负载因子又不高,从而无法执行增量搬迁的
情况,如下图所示:

 

上图可见,overflow的buckt中大部分是空的,访问效率会很差。此时进行一次等量扩容,即buckets数量不变,
经过重新组织后overflow的bucket数量会减少,即节省了空间又会提高访问效率。

查找过程

查找过程如下:

  1. 跟据key值算出哈希值

  2. 取哈希值低位与hmpa.B取模确定bucket位置

  3. 取哈希值高位在tophash数组中查询

  4. 如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较

  5. 当前bucket没有找到,则继续从下个overflow的bucket中查找。

  6. 如果当前处于搬迁过程,则优先从oldbuckets查找

注:如果查找不到,也不会返回空值,而是返回相应类型的0值。

插入过程

新员素插入过程如下:

  1. 跟据key值算出哈希值

  2. 取哈希值低位与hmap.B取模确定bucket位置

  3. 查找该key是否已经存在,如果存在则直接更新值

  4. 如果没找到将key,将key插入

 

posted @ 2023-05-31 18:12  GJH-  阅读(103)  评论(0)    收藏  举报