一 map的数据结构
map其实是一种key-value的键值对存储结构,其中key不能重复,底层是通过使用hash表进行存储的。
源码:
type hmap struct {
count int // 元素的个数
B uint8 // buckets 数组的长度就是 2^B 个
overflow uint16 // 溢出桶的数量
buckets unsafe.Pointer // 2^B个桶对应的数组指针
oldbuckets unsafe.Pointer // 发生扩容时,记录扩容前的buckets数组指针
extra *mapextra //用于保存溢出桶的地址
}
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
type bmap struct {
tophash [bucketCnt]uint8
}
//在编译期间会产生新的结构体
type bmap struct {
tophash [8]uint8 //存储哈希值的高8位
data byte[1] //key value数据:key/key/key/.../value/value/value...
overflow *bmap //溢出bucket的地址
}
下图是对它的一个解释
由图加上面的源码我们不难看出,其实map的底层结构体是hmap,同时hmap里面维护着若干个bucket数组(即桶数组)。bucket数组中每个元素都是bmap结构的,bmap中存储着8个key-value的键值对,如果是满了的话,当再来一个键值对的时候就会放到下一个溢出桶中,同时是通过这个overflow进行连接。如果当溢出桶过多的时候可能会发生扩容的操作,在扩容的时候会通过渐进式的方式进行扩容,oldbuckets指向原来的桶。
二 map的操作过程
1.查找过程
1.首先根据key值计算出哈希值
2.取哈希值的低八位与hmap.B取模确定bucket位置
3.取高八位在桶数组中进行查找
4.如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较
5.当前bucket没有找到,则继续从下个overflow的bucket中查找。
6.如果当前处于搬迁过程,则优先从oldbuckets查找
2.插入的过程
1.根据key值算出哈希值
2.取哈希值低位与hmap.B取模确定bucket位置
3.查找该key是否已经存在,如果存在则直接更新值
4.如果没找到将key,将key插入
三 map的扩容操作
map的扩容方式有两种,一种是等量扩容,一种是增量扩容
1.等量扩容
时机:溢出桶过多:由于map中不断的put和delete key,桶中可能会出现很多断断续续的空位,这些空位会导致连接的bmap溢出桶很长,导致扫描时间边长。
操作:申请和原来等量的内容,将原来的数据重新整理后,写入到新的内存中。可以简单的认为是一次内存整理,目的是提高查询效率。
引申,如果没有等量扩容会出现什么问题?随着溢出桶缓慢增长,有内存溢出的风险。
2.增量扩容
时机:装载因子(键值对/桶)>6.5
操作:增量扩容 分成两步: 第一步进入扩容状态,先申请一块新的内存,翻倍增加桶的数量,此时buckets指向新分配的桶,oldbuckets指向原来的桶。 第二步,重新计算老的桶中的哈希值在新的桶内的位置(取模或者位操作),将旧数据用渐进式的方式拷贝到新的桶中。
渐进式迁移分两块,一方面会从第一个桶开始,顺序迁移每一个桶,如果下一个桶已经迁移,则跳过。另一方面,当我们操作某一个桶的元素时,会迁移两个桶,进而保证经过一些操作后一定能够完成迁移。
当我们访问一个正在迁移的Map时,如果存在oldbuckets,那么直接去中oldbuckets寻找数据。当我们遍历一个正在迁移的Map时,新的和旧的就会遍历,如果一个旧的的桶已经迁移走了,那么就直接跳过,反正不在旧的就在新的里。Map遍历本身就是无序的。
引申,如何实现map的有序遍历?
1.使用切片,将Key先排好序,再遍历逐个去Map中取Value。
2.包装一个新的数据结构。链表,或者其他结构。
四 问题引申
1.如何安全的使用map?
Map本身是不安全的,非原子操作的。多个GoRoutine并发操作Map轻则数据混乱,重则直接panic。如果要在并发中使用Map,要么自己加读写锁sync.RWMutex,要么使用sync.Map。
2.map加锁和使用sync.Map那种好?
map加锁,在并发访问频繁的情况下,由于锁的竞争,可能会造成性能瓶颈。
sync.Map通过分段锁实现并发安全的。
分段锁:sync.Map
内部使用了一种分段锁的机制,将整个映射分为若干个段(segments)。每个段都是一个小的哈希表,每个段有自己的锁来保护对该段的访问。这样做的好处是在大多数情况下,只需要锁住其中的一部分数据,而不是整个映射,从而提高了并发性能。
无锁读取:在进行读取操作时,不需要获取任何锁。每个段的读取操作都是并发安全的,因为读取操作不会修改数据,所以不需要锁定。