Map的一站学习| 青训营笔记

120 阅读7分钟

映射Map

字典

字典是一种无序的键值对的集合。字典称为map。

map的实现原理:

开放地址法(新键值对发现原键值对被占用,遍历其之后的桶)

拉链法(新键值对发现原键值对被占用,在这个桶之后拉出新的链表,查找链表后新的桶)

字典map的数据结构

//hmap是Go map的底层实现,每个hmap内都含有多个bmap
//buckets桶、oldbuckets旧桶、overflow溢出桶,既每个哈希表都由多个桶组成。

type hmap struct {
    count     int    //元素的个数
    flags     uint8  //状态标志
    B         uint8  //可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子 (桶的数目)
    noverflow uint16 //溢出的个数
    hash0     uint32 //哈希种子

    buckets    unsafe.Pointer //指向一个桶数组(记录桶位置)
    oldbuckets unsafe.Pointer //指向一个旧桶数组,用于扩容 (记录旧桶位置)
    nevacuate  uintptr        //搬迁进度,小于nevacuate的已经搬迁 (记录旧桶搬迁进度)
    overflow *[2]*[]*bmap     //指向溢出桶的指针
}

//buckets
//buckets是一个指针,指向一个bmap数组,存储多个桶。

//oldbuckets
//oldbuckets是一个指针,指向一个bmap数组,存储多个旧桶,用于扩容。

//overflow
//overflow是一个指针,指向一个元素个数为2的数组,数组的类型是一个指针,
//指向一个slice,slice的元素是桶(bmap)的地址,这些桶都是溢出桶。
//为什么有两个?因为Go map在哈希冲突过多时,会发生扩容操作。
//[0]表示当前使用的溢出桶集合,[1]是在发生扩容时,保存了旧的溢出桶集合。
//overflow存在的意义在于防止溢出桶被gc。

//bmap是一个隶属于hmap的结构体,一个桶(bmap)可以存储8个键值对。
//如果有第9个键值对被分配到该桶,那就需要再创建一个桶,通过overflow指针将两个桶连接起来。
//在hmap中,多个bmap桶通过overflow指针相连,组成一个链表。

type bmap struct {
    //元素hash值的高8位代表它在桶中的位置,如果tophash[0] < minTopHash,表示这个桶的搬迁状态
    tophash [bucketCnt]uint8
    //接下来是8个key、8个value,但是我们不能直接看到;为了优化对齐,go采用了key放在一起,value放在一起的存储方式,
    keys     [8]keytype   //key单独存储
		values   [8]valuetype //value单独存储
		pad      uintptr
		overflow uintptr	  //指向溢出桶的指针
}

6481680833685_.pic.jpg

(k1和v1占用字节数目都为2) ⬆️

1、map中的元素并不是一个变量,不能对map的元素进行取址操作

_ = &ages["bob"] // 编译错误:不能对map元素取地址

字典的创建方式

//1、通过make函数创建
dict := make(map[string]int) // 声明一个键为string类型,值为int类型的空字典
//2、通过字面量创建
dict := map[string]int{
    "a":1, 
    "b":2, 
    "c":3
} // 声明一个包含键值对"a":1,"b":2,"c":3的字典

//3、字典的元素可以通过键来访问
dict["a"] = 1 // 给键为"a"的元素赋值为1

字典的操作

  • 字典的添加和删除
//1、通过键来添加或修改字典中的元素

dict := make(map[string]int)
dict["a"] = 1 // 添加键值对"a":1
dict["a"] = 2 // 修改键"a"对应的值为2

//2、通过delete()函数删除字典中的元素
dict := map[string]int{"a":1, "b":2, "c":3}
delete(dict, "b") // 删除键为"b"的元素
  • 字典的遍历
//1、通过for range循环遍历字典中的元素
dict := map[string]int{"a":1, "b":2, "c":3}
for key, value := range dict {
    fmt.Printf("%s:%d\\n", key, value)
}
// 输出:
// a:1
// b:2
// c:3

字典的扩容机制

一、增量扩容(渐进式扩容) 当map开始扩容后,每一次map操作都会触发一部分扩容搬迁工作(每进行一次赋值,会做至少一次搬迁工作)。由hmap中的nevacuate成员记录当前的搬迁进度。

PS:在map进行扩容迁移的期间,不会触发第二次扩容。只有在前一个扩容迁移工作完成后,map才能进行下一次扩容操作。

二、扩容触发(两种情况)

1⃣️存储的键值对数量过多,负载因子超过当前界限(6.5)

—负载因子 = 哈希表中元素数量 / 桶的数量 2⃣️由overflow指针所连接的溢出桶数量过多

判断依据⬇️

— B≤15时, noverflow ≥ 2 ^ B

— B>15时, noverflow ≥ 2 ^ 15

扩容情况一:翻倍扩容→存储的键值对数量过多

Go创建一个新的buckets数组,这个buckets数组的容量是旧buckets数组的两倍,并将旧数组的数据逐步迁移至新数组。

旧的buckets数组不会被直接删除,而是会把原来对旧数组的引用去掉,让GC来清除内存。

扩容情况二:内存整理→溢出桶数量过多(可能原因:过多键值对被删除) 如果出现了这种情况,可能是因为哈希表里有过多的空键值对,很多桶的内部出现了空洞(装不满)。这个时候就需要通过map扩容做内存整理。目的就是为了清除bmap桶中空闲的键值对。

这种情况下map扩容步骤与情况一基本相同,只不过扩容后map容量还是原来的大小。Go会创建一个与原buckets数组容量相同的buckets数组,并将旧数组的数据逐步迁移至这个新数组。再去除旧数组的引用,让GC来清除内存。

PS:GC,全称 Garbage Collection,即垃圾回收,是一种自动内存管理的机制。

截屏2023-04-07 10.32.59.png

扩容迁移

Untitled.png

底层map扩容机制源码:juejin.cn/post/710200…

⚠️:

1、未初始化的 map 都是 nil,直接赋值会报 panic。map 作为结构体成员的时候,很容易忘记对它的初始化。

2、并发读写是我们使用 map 中很常见的一个错误。多个协程并发读写同一个 key 的时候,会出现冲突,导致 panic。

三种线程安全的 map

线程安全map:zhuanlan.zhihu.com/p/356739568

⚠️线程安全的 map 通过在多协程之间共享锁来增加内部状态的访问控制,从而避免了竞态条件的产生。但是,由于共享锁的存在,线程安全的 map 在并发量较大时会导致性能下降。

1、加读写锁

常见的 map 的操作有增删改查和遍历,这里面查和遍历是读操作,增删改是写操作,因此对查和遍历需要加读锁,对增删改需要加写锁。

type RWMap struct { // 一个读写锁保护的线程安全的map
    sync.RWMutex // 读写锁保护下面的map字段
    m map[int]int
}
// 新建一个RWMap
func NewRWMap(n int) *RWMap {
    return &RWMap{
        m: make(map[int]int, n),
    }
}
func (m *RWMap) Get(k int) (int, bool) { //从map中读取一个值
    m.RLock()
    defer m.RUnlock()
    v, existed := m.m[k] // 在锁的保护下从map中读取
    return v, existed
}
 
func (m *RWMap) Set(k int, v int) { // 设置一个键值对
    m.Lock()              // 锁保护
    defer m.Unlock()
    m.m[k] = v
}
 
func (m *RWMap) Delete(k int) { //删除一个键
    m.Lock()                   // 锁保护
    defer m.Unlock()
    delete(m.m, k)
}
 
func (m *RWMap) Len() int { // map的长度
    m.RLock()   // 锁保护
    defer m.RUnlock()
    return len(m.m)
}
 
func (m *RWMap) Each(f func(k, v int) bool) { // 遍历map
    m.RLock()             //遍历期间一直持有读锁
    defer m.RUnlock()
 
    for k, v := range m.m {
        if !f(k, v) {
            return
        }
    }
}

2、分片加锁

通过读写锁 RWMutex 实现的线程安全的 map,功能上已经完全满足了需要,但是面对高并发的场景,仅仅功能满足可不行,性能也得跟上。锁是性能下降的万恶之源之一。所以并发编程的原则就是尽可能减少锁的使用。当锁不得不用的时候,可以减小锁的粒度和持有的时间。

在第一种方法中,加锁的对象是整个 map,协程 A 对 map 中的 key 进行修改操作,会导致其它协程无法对其它 key 进行读写操作。一种解决思路是将这个 map 分成 n 块,每个块之间的读写操作都互不干扰,从而降低冲突的可能性。

Go 比较知名的分片 map 的实现是 orcaman/concurrent-map

var SHARD_COUNT = 32
   
// 分成SHARD_COUNT个分片的map
type ConcurrentMap []*ConcurrentMapShared
   
// 通过RWMutex保护的线程安全的分片,包含一个map
type ConcurrentMapShared struct {
    items        map[string]interface{}
    sync.RWMutex // Read Write mutex, guards access to internal map.
}
   
// 创建并发map
func New() ConcurrentMap {
    m := make(ConcurrentMap, SHARD_COUNT)
    for i := 0; i < SHARD_COUNT; i++ {
        m[i] = &ConcurrentMapShared{items: make(map[string]interface{})}
    }
    return m
}
   
 
// 根据key计算分片索引
func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
    return m[uint(fnv32(key))%uint(SHARD_COUNT)]
}

3、sync.map

分片加锁的思路是将大块的数据切分成小块的数据,从而减少冲突导致锁阻塞的可能性。

在内置的 sync 包中(Go 1.9+)也有一个线程安全的 map,通过将读写分离的方式实现了某些特定场景下的性能提升。

官方推荐使用场景:

a) when the entry for a given key is only ever written once but read many times, as in caches that only grow. b) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys.

type Map struct {
    mu Mutex
    // 基本上你可以把它看成一个安全的只读的map
    // 它包含的元素其实也是通过原子操作更新的,但是已删除的entry就需要加锁操作了
    read atomic.Value // readOnly
 
    // 包含需要加锁才能访问的元素
    // 包括所有在read字段中但未被expunged(删除)的元素以及新加的元素
    dirty map[interface{}]*entry
 
    // 记录从read中读取miss的次数,一旦miss数和dirty长度一样了,就会把dirty提升为read,并把dirty置空
    misses int
}
 
type readOnly struct {
    m       map[interface{}]*entry
    amended bool // 当dirty中包含read没有的数据时为true,比如新增一条数据
}
 
// expunged是用来标识此项已经删掉的指针
// 当map中的一个项目被删除了,只是把它的值标记为expunged,以后才有机会真正删除此项
var expunged = unsafe.Pointer(new(interface{}))
 
// entry代表一个值
type entry struct {
    p unsafe.Pointer // *interface{}
}

	

sync.map实现思路:

  • 读写分离。读(更新)相关的操作尽量通过不加锁的 read 实现,写(新增)相关的操作通过 dirty 加锁实现。
  • 动态调整。新写入的 key 都只存在 dirty 中,如果 dirty 中的 key 被多次读取,dirty 就会上升成不需要加锁的 read。
  • 延迟删除。Delete 只是把被删除的 key 标记成 nil,新增 key-value 的时候,标记成 enpunged;dirty 上升成 read 的时候,标记删除的 key 被批量移出 map。这样的好处是 dirty 变成 read 之前,这些 key 都会命中 read,而 read 不需要加锁,无论是读还是更新,性能都很高。

字典的优点:

  • 查询速度快

字典的缺点:

  • 随机访问速度慢
  • 内存占用大