- 当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对的过程
- 通过hash 取得 该key的hash值
- hash值对桶数组的长度取模 确定归属哪个桶
- 在桶中插入kv对
核心原理
桶数组
- 桶数组的长度为2^B B最大取15 桶数组是一个桶的单向链表
- 每个桶中固定可以存放8个k-v对
- 如果有超过8个的k-v对 hash值打到同一个桶上 那么会创建溢出桶链表
解决hash冲突
- 解决hash冲突 传统的两种方法是 1.开放寻址法 (线性探测、二次探测、 随机探测、双重散列等) 2.拉链法
map解决hash冲突结合了这两种方法
- 当key命中一个桶的时候 首先根据开放寻址法 在桶的8个空位中寻找到一个可以插入的位置
- 如果桶中全满 那么根据溢出桶指针寻找下一个溢出桶中是否有合适的位置插入
- 如果到了最后一个溢出桶 还是全满的话 那么使用拉链法 创建一个溢出桶 并插入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
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
读流程
- 如果map没有初始化 或者此时map中k-v对的数量为0 那么直接返回零值
- 如果发现有其他的goroutine在写map 直接抛出fatal error
- 通过maptype.hasher方法计算Key的hash值 并且对桶数组长度取模 取得对应的桶 一个 key 属于哪个桶,取决于其 hash 值对桶数组长度取模得到的结果,因此依赖于其低位的 hash 值结果.
- 取桶之前要判断 是否处于扩容过程中 且扩容类型 且如果是扩容过程中 数据有没有从老桶迁移到了新桶 这里判断的时候是取桶中首个hashtop值 如果是2,3,4中间的一个 都说明数据已经迁移到了新桶 那么由于是渐进式扩容 需要遍历老桶 (如果是增量扩容 此时老桶的值是新桶的一半 那么老桶的长度就是桶长 >> 1)这里key的hash值高8位如果 < 5 就累加6 避开0~4的取值 这几个值会用于枚举 有特殊的含义 0表示 emptyRest 当前桶链表的后续位置都未放入过元素 emptyOne(1)表示当前位置没有放入元素
- 开启两层遍历 外层遍历桶链表 内层遍历桶和溢出桶中的k-v对 直到找到相同的Key 返回对应的value 或者遍历完该桶和溢出桶 那么返回零值
写流程
只有写流程才能触发扩容机制 map.flags中的第三个bit位是写标记位
当遍历完桶链表 都没有为当前待插入的kv对找到空位 则会创建一个新的溢出桶 挂载在桶链表的尾部
删流程
在这里遍历桶链表中各个桶得kv对时 如果命中了相同的key 那么将其删除 并且tophash置为1 emptyOne 如果此时位置是末尾或者下一个位置的tophash是emptyRest 那么沿当前位置向前遍历 将相邻的emptyOne更新成emptyRest
扩容流程
(1)增量扩容
扩容后,桶数组的长度增长为原长度的 2 倍;因为扩容前桶中kv对太多了 此时查找趋近于线性 那么就要降低每个桶中k-v对的数量优化 map 操作的时间复杂度
(2)等量扩容
扩容后,桶数组的长度和之前保持一致;但是溢出桶的数量会下降. 溢出桶太多了 很多空间没有得到合理利用 等量扩容就是让其填充率提高 减少溢出桶的数量
根据 hmap 的 oldbuckets 是否空,可以判断 map 此前是否已开启扩容模式
渐进式扩容
当每次触发写、删操作时,会为处于扩容流程中的 map 完成两组桶的数据迁移:
(1)一组桶是当前写、删操作所命中的桶;
(2)另一组桶是,当前未迁移的桶中,索引最小的那个桶.