3.Map
Go中的map使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点(bucket)
3.1 初始化
var map = make(map[int]string,10)
map := make(map[int]string,10)
使用make进行初始化时,make中最多只能有两个参数,第一个参数为map的类型,第二个参数为map的容量
3.2 map数据结构
runtime/map.go/hmap
结构关键字段
type hmap struct {
count int // 元素的个数
B uint8 // buckets 数组的长度就是 2^B 个
noverflow uint16 //使用的溢出桶数量
buckets unsafe.Pointer // 2^B个桶对应的数组指针
oldbuckets unsafe.Pointer // 发生扩容时,记录扩容前的buckets数组指针
nevacuate uintptr //增量扩容阶段 即将迁移的旧桶编号
extra *mapextra //用于保存溢出桶的信息
}
上图中hmap.B = 2,hmap.buckets的长度为2^B =4。通过key的hash值的后“B”位确定是哪一个桶。
3.2.1 初始化Map时B的计算方式
//初始化一个key为string类型 value为int类型 容量为16的map
map := make([int]string,16)
- B的计算方式
负载因子 = count(map中存储元素个数)/2^B (2^B为bucket的数量)
由于设定 负载因子 > 6.5 map扩容
所以 初始化时 负载因子应 <= 6.5
套用第一行公式
6.5 >= count/2^B
2^B * 6.5 >= count
初始化定义的count 为16
即
2^B * 6.5 >= 16
此时开始计算B
B= 0 1*6.5 < 16 不成立
B=1 2*6.5 < 16 不成立
B=2 4*6.5 >= 16 成立
由此得 B=2
3.3 bucket数据结构
runtime/map.go/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中确定键值对 存放在哪个桶的方法
1.取模法
hash值%桶的个数 -> hash%m
2.与运算
此运算要求 桶的个数是2的整数次幂
hash值&最大桶下标 -> hash&(m-1)
go中采用的 确定键值对 存放在哪个桶的方法
hash值的后B位
hmap中B = 4 则桶数2^B = 2^4 = 16
拿到hash值得后4位 转为10进制 即为 该键值对要存放的桶的编号
此方法和 与运算 的结果相同
分析
B = 4 桶的总数16 桶的编号范围[0,15] 最大桶号为15 桶号 的格式 一定为 0000 1111 或 0000 0011这种形式 记录最大桶号 的二进制中 1出现的位数 例如15的 二进制数 后4位为1 则查看hash值的后4为即可
假设 该hash值为1010 1010 15的二进制为 0000 1111
与运算 结果为 0000 1010 -> 10 该键值对放10号桶
取hash值后B位
B = 4 hash值后4位 = 1010 结果和与运算一样
3.4 put
如上图
1.假设该map的B为4,即buckets数组大小为2^4 = 16
2.将某个元素存入map
3.对key进行计算得到hash的值,hash值的后B位(B为4,即后4位,此处假设为0101)十进制结果是5,所以放在buckets数组的下标为5的元素(bmap)中
4.获取hash值的高8位,如果tophash数组中已经存在该值,则更新对应的value,如果不存在,则放入bmap的tophash中,将key放入keys,value放入values中
5.如果当前的桶存满时,会创建一个新的桶,让overflow指向该桶,将新的元素放入该桶中
3.5 get
如3.3的图
1.假设该map的B为4,即buckets数组大小为2^4 = 16
2.根据key获取map中的value
3.对key进行hash计算,拿到后B位(B=4,即后4位,假设为0101),十进制结果为5,即在buckets的下标为5的元素中
4.拿到hash值的高8位,与bucket中tophash数组的各个元素比较
4.1 如果找到,获取tophash对应的value,返回
4.2 如果没有找到,去overflow指向的溢出桶中按照如上步骤寻找
4.3 如果最终没有找到,会返回value类型的默认值
4.4 如果map处于搬迁过程,则优先从oldbuckets查找
3.6 哈希冲突
指的是:两个或以上的key经过hash计算,后B位相同,要放入同一个bucket中
解决哈希冲突的方法
1.开放地址法
根据hash值获取到桶号后,如果发现该桶中已经存储键值对,则查看下一个桶中是否可以存放...
2.链地址法
如下
Go的解决方法
链地址法:
每一个桶中,默认可以存放8个元素,当该桶存满时,会创建一个新桶,让该桶的overflow指向新桶,将新元素放入新桶中
3.7 扩容
- 负载因子:map中每个桶的平均元素个数
loadFactor = count/2^B
负载因子 = map中元素个数/桶的个数
- 扩容条件
1.当负载因子 > 6.5 时
2.当溢出桶数量过多时
2.1 当B<15时,如果overflow存储的溢出桶数量超过2^B
2.2 当B>=15时,如果overflow存储的溢出桶数量超过2^15
3.7.1 扩容方式
- 等量扩容
并没有扩大map中bucket的数量,而是把松散的键值对重新排列一次,使bucket的使用率更高。
当进行大量的增删操作后,键值对正好集中在一小部分bucket中,会造成overflow指向的bucket数量很多,但是map的负载因子并不高,从而无法指向增量扩容
如图所示:
把后置的元素整理到前面的bucket中。元素会发生重排,但不会换桶。
- 增量扩容
当负载因子过大时,就新建一个bucket数组,新的bucket长度是原来的两倍,将旧的bucket中元素迁移到新的bucket中。用hmap中的nevacuate字段记录旧桶迁移的进度。如果map中存储了大量的数据,一次性迁移会造成比较大的延时,Go采用逐步迁移策略,即每次访问map时都会触发一次迁移,每次迁移2个键值对。
例子:
0.map中要放入新元素
1.当前B = 1 桶的个数2^1 = 2 此时两个桶中都存放7个元素
2.负载因子 loadFactor = 14/2 = 7 > 6.5 触发增量扩容
3.让map中的oldbuckets指向旧的桶数组,创建一个新桶数组,容量为旧桶数组的2倍,hmap中B+1, buckets指向新桶数组
4.新元素根据put执行过程放入新桶数组的某个桶中
5.后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步迁移到buckets中,当oldbuckets中元素全部迁移完成,删除oldbuckets指向的桶数组
5.在迁移没完成前,每次get或put都会先遍历oldbuckets,再遍历buckets
图中只画了一个桶,实际上为2个桶
插入新元素示意图(扩容应该为4个桶,简化图只画了2个桶)
搬迁完成后示意图
注意:
增量扩容后,元素会重排,元素可能会发生桶迁移
1.例如上图中,扩容前map.B = 1,即看key的hash值的后1位 来确定该元素放到哪个bucket中
2.扩容后,map.B = 2 迁移时,key的hash值看后2位
3.扩容前后B的不同,导致同一个元素 在扩容前后放入桶数组的下标 可能不同
3.8 map数据不可取地址
map[string]Student 的value是一个Student结构体值
当list["studnet"] = student 时,是一个值拷贝过程
而list["student"] 是一个值引用,值引用的特点是 只读
所以 无法修改list["student"]的Name
- map中元素的Value类型从Student值类型 改为Student指针类型
此时我们修改的是指针指向的Student
package main
import "fmt"
type Student struct {
Name string
}
var list map[string]*Student
func main() {
list = make(map[string]*Student, 5)
student := Student{"Aceld"}
list["student"] = &student
list["student"].Name = "LDB"
fmt.Println(student)//{LDB}
}
3.9 map的遍历赋值
package main
import "fmt"
type student struct {
Name string
Age int
}
func main() {
m := make(map[string]*student)
stus := []student{
{Name: "zhou", Age: 10},
{Name: "li", Age: 11},
{Name: "wang", Age: 12},
}
for _, stu := range stus {
m[stu.Name] = &stu
}
for k, v := range m {
fmt.Println(k, "=>", v.Name)
}
}
zhou => wang
li => wang
wang => wang
问题
map的value类型为*student 指向student变量的地址
在foreach循环之后,每个key对应的都是stu数组中最后一个元素的地址
正确写法
for i := 0; i < len(stus); i++ {
m[stus[i].Name] = &stus[i]
}
3.10 map的线程不安全
在同一个时间点,两个goroutine对同一个map进行读写操作是不安全的。
例子:
1.map.B = 2 buckets指向的桶数组大小为4
2.goroutine1要插入一个新元素
3.goroutine2 计算key2的hash值 B=2 确定key2存放于桶1
3.插入新元素,负载因子 > 6.5 此时增量扩容 B = 3 桶变为6个
4.增量扩容导致 buckets元素迁移到oldbuckets
5.goroutine2从桶1中遍历,获取数据失败
解决方法
当对一个map进行并发读写时,采用mutex锁
package main
import (
"fmt"
"sync"
"time"
)
type Resource struct {
sync.RWMutex
m map[string]int
}
func main() {
r := Resource{
m: make(map[string]int),
}
go func() {
for j := 0; j < 10; j++ {
r.Lock()
r.m[fmt.Sprintf("resource_%d", j)] = j
r.Unlock()
}
}()
go func() {
for j := 0; j < 10; j++ {
r.RLock()
fmt.Println(r.m[fmt.Sprintf("resouce_%d", j)])
r.RUnlock()
}
}()
time.Sleep(time.Second)
}
3.11 mapextra结构体
type mapextra struct {
overflow *[]*bmap //已经使用的溢出桶地址
oldoverflow *[]*bmap // 存储 扩容阶段 旧桶用到的那些溢出桶的地址
nextOverflow *bmap //下一个空闲的溢出桶
}
当B>4时,Go中认为使用溢出桶的几率较大,在初始化桶时 会预分配2^(B-4)个溢出桶备用。这些溢出桶 和 常规桶 在内存中是连续的。前2^B个作为常规桶,后面的用作溢出桶。
源码 runtime/map.go 中 makeBucketArray函数
if b >= 4 {
// Add on the estimated number of overflow buckets
// required to insert the median number of elements
// used with this value of b.
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}