map 底层原理 自我梳理

191 阅读7分钟
  • 当map没有初始化的时候 直接写入会产生panic
  • map是无序的 遍历的时候每次可能得到的遍历数组都不一样
  • map不是并发安全的 如果要使用并发安全的 可以使用sync.map 这里map可以并发读 但是如果有并发写操作的话 会产生fatal error
package main
​
import (
    "context"
    "sync"
    "time"
)
​
/*
    要求实现一个map
    1.面向高并发场景
    2.只存在插入和查询操作 O(1)
    3.查询时 若key存在 直接返回val, 如果key不存在 阻塞直到key val对被放入之后, 获取val返回; 等待指定时长仍未被放入 返回超时错误
    4.不能出现死锁或者panic
*/type MyConcurrentMap struct {
    sync.Mutex
    mp        map[int]int
    keyToChan map[int]chan struct{} //控制信号量
}
​
func NewMyConcurrentMap() *MyConcurrentMap {
    return &MyConcurrentMap{
        mp:        make(map[int]int),
        keyToChan: make(map[int]chan struct{}),
    }
}
​
func (m *MyConcurrentMap) Put(key, value int) {
    m.Lock()
    defer m.Unlock()
​
    m.mp[key] = value
    ch, ok := m.keyToChan[key]
    //判断此时有没有读goroutine
    if !ok {
        return
    }
    //此时可能有多个读goroutine读取key 需要将其全部唤醒来返回数值 close channel可以唤醒所有阻塞的读写协程 但是写协程会panic 读协程会读取到相应的值
    //这里 <-ch 读取到数据唯一的情况就是我已经关闭了channel 但是不能重复关闭 所以直接return
    select {
    case <-ch:
        return
    default:
        close(ch)
    }
}
​
func (m *MyConcurrentMap) Get(key int, maxWaiting time.Duration) (int, error) {
    m.Lock()
    value, ok := m.mp[key]
    if ok {
        return value, nil
    }
    //读取不到 那么等待写goroutine写入对应的key value
    //给一个信号 使其阻塞
    ch, ok := m.keyToChan[key]
    if !ok {
        ch = make(chan struct{})
        m.keyToChan[key] = ch
    }
    tCtx, cancel := context.WithTimeout(context.Background(), maxWaiting)
    defer cancel()
    m.Unlock()
    select {
    case <-tCtx.Done():
        return -1, tCtx.Err()
    case <-ch:
        break
    }
    m.Lock()
    value = m.mp[key]
    m.Unlock()
    return value, nil
}

map 在算法上是基于hash形成的key->value的映射和寻址 在数据结构上是基于桶数组实现的存储

存入一个key-value对的过程

  1. 通过hash 取得 该key的hash值
  2. hash值对桶数组的长度取模 确定归属哪个桶
  3. 在桶中插入kv对

核心原理

桶数组
  • 桶数组的长度为2^B B最大取15 桶数组是一个桶的单向链表
  • 每个桶中固定可以存放8个k-v对
  • 如果有超过8个的k-v对 hash值打到同一个桶上 那么会创建溢出桶链表
解决hash冲突
  • 解决hash冲突 传统的两种方法是 1.开放寻址法 (线性探测、二次探测、 随机探测、双重散列等) 2.拉链法

map解决hash冲突结合了这两种方法

  1. 当key命中一个桶的时候 首先根据开放寻址法 在桶的8个空位中寻找到一个可以插入的位置
  2. 如果桶中全满 那么根据溢出桶指针寻找下一个溢出桶中是否有合适的位置插入
  3. 如果到了最后一个溢出桶 还是全满的话 那么使用拉链法 创建一个溢出桶 并插入k-v对
map的扩容

1.等量扩容 当桶内 key-value 总数/桶数组长度 > 6.5 时发生增量扩容,桶数组长度增长为原值的两倍;

2.增量扩容 当桶内溢出桶数量大于等于 2^B 时(桶数组的长度),发生等量扩容,桶的长度保持为原值;

采用渐进式扩容的方法 当桶被实际操作到的时候 由使用者负责数据迁移 避免由于一次性的全量数据迁移而发生性能抖动

数据结构

type hmap struct {
    count     int 
    flags     uint8
    B         uint8  
    noverflow uint16 
    hash0     uint32 
    buckets    unsafe.Pointer 
    oldbuckets unsafe.Pointer 
    nevacuate  uintptr       
    extra *mapextra 
}
​
//nevacuate:扩容时的进度标识,index 小于 nevacuate 的桶都已经由老桶转移到新桶中
//noverflow 溢出桶的数量 当其大于 2^B时 发生等量扩容
type mapextra struct {
    overflow    *[]*bmap //供桶数组 buckets 使用的溢出桶
    oldoverflow *[]*bmap //扩容过程中供老桶使用的溢出桶
​
​
    nextOverflow *bmap //指向下一个溢出桶的指针
}
​
//map中的桶 
const bucketCnt = 8
type bmap struct {
    tophash [bucketCnt]uint8
}
//bmap中每堆k-v对存储了三部分 tophash 高八位的hash值, key, value

makemap

image-20230922200046492

const loadFactorNum = 13
const loadFactorDen = 2
const goarch.PtrSize = 8
const bucketCnt = 8
​
​
func overLoadFactor(count int, B uint8) bool {
    return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
​
​
func bucketShift(b uint8) uintptr {
    return uintptr(1) << (b & (goarch.PtrSize*8 - 1))
}
​
//计算map预分配容量 如果预分配容量小于等于8 那么B= 0 桶数组的长度为1 这时只需要一个桶就可以了
//需要保证预分配容量(未来k-v对存储的总数量) < 桶数组长度*6.5 

读流程

image-20230922200133802

  1. 如果map没有初始化 或者此时map中k-v对的数量为0 那么直接返回零值
  2. 如果发现有其他的goroutine在写map 直接抛出fatal error
  3. 通过maptype.hasher方法计算Key的hash值 并且对桶数组长度取模 取得对应的桶 一个 key 属于哪个桶,取决于其 hash 值对桶数组长度取模得到的结果,因此依赖于其低位的 hash 值结果.
  4. 取桶之前要判断 是否处于扩容过程中 且扩容类型 且如果是扩容过程中 数据有没有从老桶迁移到了新桶 这里判断的时候是取桶中首个hashtop值 如果是2,3,4中间的一个 都说明数据已经迁移到了新桶 那么由于是渐进式扩容 需要遍历老桶 (如果是增量扩容 此时老桶的值是新桶的一半 那么老桶的长度就是桶长 >> 1)这里key的hash值高8位如果 < 5 就累加6 避开0~4的取值 这几个值会用于枚举 有特殊的含义 0表示 emptyRest 当前桶链表的后续位置都未放入过元素 emptyOne(1)表示当前位置没有放入元素
  5. 开启两层遍历 外层遍历桶链表 内层遍历桶和溢出桶中的k-v对 直到找到相同的Key 返回对应的value 或者遍历完该桶和溢出桶 那么返回零值

写流程

image-20230922201939971

只有写流程才能触发扩容机制 map.flags中的第三个bit位是写标记位

当遍历完桶链表 都没有为当前待插入的kv对找到空位 则会创建一个新的溢出桶 挂载在桶链表的尾部

image-20230922202502270

删流程

image-20230922202704040

在这里遍历桶链表中各个桶得kv对时 如果命中了相同的key 那么将其删除 并且tophash置为1 emptyOne 如果此时位置是末尾或者下一个位置的tophash是emptyRest 那么沿当前位置向前遍历 将相邻的emptyOne更新成emptyRest

扩容流程

image-20230922203220609

(1)增量扩容

扩容后,桶数组的长度增长为原长度的 2 倍;因为扩容前桶中kv对太多了 此时查找趋近于线性 那么就要降低每个桶中k-v对的数量优化 map 操作的时间复杂度

(2)等量扩容

扩容后,桶数组的长度和之前保持一致;但是溢出桶的数量会下降. 溢出桶太多了 很多空间没有得到合理利用 等量扩容就是让其填充率提高 减少溢出桶的数量

根据 hmap 的 oldbuckets 是否空,可以判断 map 此前是否已开启扩容模式

渐进式扩容

当每次触发写、删操作时,会为处于扩容流程中的 map 完成两组桶的数据迁移:

(1)一组桶是当前写、删操作所命中的桶;

(2)另一组桶是,当前未迁移的桶中,索引最小的那个桶.