map的Q&A
- 非常重要的一点:makemap()函数返回的是*hmap, makeslice()函数返回的是slice
map是线程安全的吗?(map可以并发写入吗)
1. go语言中内置的map不是并发安全的
2. 大量goroutine并发写入map就会报错:fatal error: concurrent map writes
案例:
var m = make(map[string]int)
var wg sync.WaitGroup
const MAX = 10000
func set(key string, value int) {
m[key] = value
}
func get(key string) int {
return m[key]
}
func main() {
for i := 0; i < MAX; i++{
wg.Add(1)
go func(i int) {
defer wg.Done()
set(strconv.Itoa(i), i)
fmt.Printf("key:%s, value:%d\n", strconv.Itoa(i), get(strconv.Itoa(i)))
}(i)
}
wg.Wait()
}
3. 像这种场景下就需要为map加锁来保证并发的安全了,go中的sync包中提供了一个开箱即用的并发安全的map-sync.Map
4. 开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。
5. 同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。
上面的案例用sync.Map就不会报错:
//var m = make(map[string]int)
var sm = sync.Map{}
var wg sync.WaitGroup
const MAX = 10000
func set(key string, value int) {
sm.Store(key, value)
}
func get(key string) int {
value, ok := sm.Load(key)
if ok{
return value.(int)
}
return -1
}
func main() {
for i := 0; i < MAX; i++{
wg.Add(1)
go func(i int) {
defer wg.Done()
set(strconv.Itoa(i), i)
fmt.Printf("key:%s, value:%d\n", strconv.Itoa(i), get(strconv.Itoa(i)))
}(i)
}
wg.Wait()
sm.Delete("999")
v, ok := sm.Load("999")
fmt.Println(v, ok) // <nil> false
v, loaded := sm.LoadOrStore("999", 999)
fmt.Println(v, loaded) // 999 false
}
float类型可以作为map的key吗?
1. go中只要是可以比较的类型都可以作为map的key, slice、map、function不能作为map的key
2. string、int、bool、float、channel、指针、结构体、接口都可以进行比较,所以都可以当做map的key
3. 任何类型都可以当作value, 包括map类型
4. float类型可以作为key,但是由于精度问题,会导致一些诡异问题,慎用之
案例:
func main() {
var m = map[float64]int{3.14: 18, 8.88: 99}
fmt.Println(m)
m[math.NaN()] = 100
m[math.NaN()] = 200
m[math.Pi] = 3
fmt.Println(m)
fmt.Println(m[math.Pi])
fmt.Println(m[math.NaN()]) // 输出0
}
map中的key为什么是无序的?
1. map在扩容后,会发生key的搬迁,原来落在同一个bucket中的key, 搬迁后,有些key就要远走高飞了
2. 而遍历的过程就是按顺序遍历bucket,同时按顺序遍历bucket中的key,
3. 搬迁后,key的位置发生了重大变化,遍历map的结果就不可能按照原来的顺序了
4. 当然go做的更绝对,当我们遍历bucket的时候,并不是从0号bucket开始遍历,
5. 每次都是从一个随机值序号的bucket开始遍历,并且是从这个bucket的随机序号的cell开始遍历
6. 这样即使你是写死的map,遍历的时候也不太可能返回固定序列的key/value对了
案例:
func main() {
var m = map[float64]int{3.14: 18, 8.88: 99, math.Pi: 100, 1.1:2, 2.2:3, 3.3:4}
fmt.Println(m)
for k, v := range m{
// 每次打印的结构都有可能不一致,因为每次遍历时都会从随机序号的bucket,
// 以及随机序号的cell还是遍历,所以即使相同的map每次遍历也很大可能会不一致
fmt.Println(k, v)
}
}
map是线程安全的吗?
1. 在查找、赋值、遍历、删除的过程中都会检查写标志,一旦发现写标志位等于1,直接panic
2. 赋值和删除操作时,会先将map的写标志位设置为1,当写完之后在归零
map的删除过程是怎么样的?
1. 写操作底层执行的函数是mapdelete函数,每次执行之前会先检查h.flags标志位,如果发小写标志位为1表名有其它协程在进行写操作,
直接panic,
2. 先计算key的hush值,找到落入的bucket, 在通过高8位快速找到大概位置,在通过低8位准确找到key/value位置,然后进行清零
3. 然后将hmap中的count值减1,将对应位置的tophash值置成Empty
map的底层实现原理是什么?
什么是map
1. map是由key-value组成的,每个key只能出现一次
2. map它的任务是设计一种数据结构来维护一个集合的数据,最主要的数据结构有两种:哈希查找表、搜索树
3. 哈希查找表用一个哈希函数将key分配到不同的桶(bucket, 也就是数组的不同index),
4. 这样开销主要在哈希函数的计算和数组的常数访问时间,哈希查找表的性能很高
5. 哈希查找表一般会存在碰撞问题,也就是不同的key被哈希到了同一个bucket中,
6. 一般有两种解决方法:链表法,开放地址法
1. 链表法:将一个bucket实现成一个链表,落在同一个bucket中的key都会插入到这个链表
2. 开放地址法:碰撞发生后,按照一定的规律,在数组后面挑选空位,放置新的key
7. 搜索树法一般采用的自平衡搜索树,包括AVL树和红黑树
8. go语言中的map采用的是哈希查找表,链表法解决哈希冲突
map的底层如何实现
1. map由两个结构体组成,hmap和bmap,hmap存放的是map的一些元信息:count、B、buckets、flags等
bmap存放的是决堤key/value值,包含tophash、keys、values、*overflow
2. bmap在内存中的存储结构是key/key/key...value/value/value
3. 每个bucket中最多存放8个key/value键值对,如果有第9个key/value放入到当前bucket,
那么就会新建一个bucket,通过*overflow指针连接起来
引申1,slice和map分别作为函数参数时有什么区别?
1. makemap函数返回的是*hmap 指针类型,makeslice函数返回的是slice 结构体类型(结构体包含底层数组的指针)
2. makemap和makeslice的不同点,当作为函数参数传递时,在函数内部对map的操作会影响map自身,而对silce却不会
3. 主要原因是:一个是指针(*hmap),一个是结构体(slice)
4. go语言中函数传参都是值传递,在函数内部,参数会被copy到本地,*hmap copy完成之后仍然指向同一个map
因此函数内部对map的操作会影响实参,
而slice在函数内部被copy之后,会生成新的slice,对它进行的操作不会影响到实参
哈希函数
1. 哈希函数有加密型和非加密型两种
1. 加密型的典型代表有:md5、sha1、sha256、aes256
2. 非加密型的一般就是查找,在map的应用场景中用的就是查找,选择哈希函数主要考虑两点:性能、碰撞概率
key的定位过程
1. key经过哈希函数计算后得到的哈希值,共64个bit位,最终key放入哪个bucket中由后B为决定
2. 桶的个数也是由B决定的 = 2 ** B,也就是2的B次方
3. 再用哈希值的高8位查找key在桶中的位置,这是在寻找已有的key,最开始桶内没有key,新加入的key会找到第一个空位放入
4. bucket编号就是桶编号,当不同的key放入到同一个桶中时就会发生哈希冲突,通过链表法解决哈希冲突
在bucket中从前往后找到第一个空位放入,查找某个Key时,先通过哈希值后B位找到对应的桶,bmap中的高8位找到桶中的索引位置
5. 如果在bucket中没有找到,并且overflow不为空,会去overflow bucket中找,如果最后都没找到,返回对应类型的零值,不会返回nil
6. 真正遍历map的时候有两层循环,外层循环遍历所有bucket,里层循环遍历所有cell(或者说所有的槽位最大=8)
map的扩容过程是怎样的?
1. 使用哈希表的目的就是快速找到目标key,随着map中添加的key越来越多,key发生的碰撞会越来越大,
bucket中的8个cell会逐渐被塞满,查找、插入、删除的效率会变低,最理想的情况是一个bucket只装一个key,
这样查找效率就是O(1),但这样空间消耗太大,空间换时间的代价太高了
2. 装载因子
loadFactor = count / 2^B // count: map中元素的个数,2^B桶bucket的个数
2. map扩容的时机,在向map插入key时,会进行条件检测,符合下面两个条件任意一个时就会发生扩容
1. 装载因子超过阈值,源码中定义的阈值是6.5 也就是元素个数/桶的个数>6.5,引发翻倍扩容
2. 使用了太多的溢出桶时,(溢出桶使用的太多,会导致map的处理效率变低)
1. 当 B <= 15 时,溢出桶的个数>=2^B次方时,引发等量扩容
2. 当 B > 15 时,溢出桶的个数>=2^15次方时,引发等量扩容
map的赋值过程是怎样的?
1. map中插入或者修改key,最终调用的都是mapasign
2. 函数首先会检查map的标志位flags,如果被置为1了,说明有其它协程正在执行"写"操作,进而导致panic
这也说明了map对协程是不安全的
3. 当向map中插入或更新一个key/value值时,正好该key所在的bucket正在扩容,就需要将老的bucket中的内容
迁移到新的bucket中之后,在进行更新或删除操作
map的遍历过程是怎样的?
1. 本来map的遍历过程比较,外层遍历所有的bucket和overflow bucket,内层遍历所有的cell
每个bucket中包含8个cell, 从有key的cell中取出key和value就完了
2. 但是,现实并没有这么简单,扩容并不是一个原子操作,每次最多搬运2个bucket,如果触发了扩容
map很长时间处于一个中间态,有些bucket已经搬到了新家,有些bucket还在老地方
3. 因此遍历如果发生在扩容过程中,就会遍历新老bucket,这是难点所在
可以对map的元素取地址吗?
1. 不可以,因为map一旦发生扩容,key和value的位置就会发生改变,之前保存的地址也就失效了
map可以边遍历边删除吗?
1. map并不是一个线程安全的数据结构,同时读写一个map是未定义的行为,如果检测到,会直接panic
2. 一般可以通过读写锁来解决,sync.RWMutex,读之前调用RLock()函数,读完后调用RUnlock()函数
写之前调用Lock()函数,写完后调用Unlock()函数
3. 另外,sync.Map是线程安全的map,也可以直接使用
如何实现两种get操作?
1. go中读取map有两种用法,带comma和不到comma
2. 当要查询的key不在map里时,带comma时,会返回一个bool类型的变量提示key是否在map中
不带comma是,会返回对应类型的零值
案例:
func main() {
m1 := map[string]int{"name": 88, "age": 100}
name := m1["name"]
fmt.Println(name)
gender, ok := m1["gender"]
fmt.Println(gender, ok)
}
如何比较两个map相等
1. map深度相等的条件
1、都为 nil
2、非空、长度相等,指向同一个 map 实体对象
3、相应的 key 指向的 value “深度”相等
案例:
func main() {
var m1 map[string]int
var m2 map[string]int
fmt.Println(m1)
fmt.Println(m2)
fmt.Println(m1 == nil)
fmt.Println(m2 == nil)
//fmt.Println(m1 == m2) // 此种写法有无,编译器不允许
}
2. 因此只能遍历map的每个元素,比较每个元素是否深度相等