一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情。
本文目录如下,阅读本文后,将一网打尽下面Golang Map相关面试题
面试题
-
map的底层实现原理
-
为什么遍历map是无序的?
-
如何实现有序遍历map?
-
为什么Go map是非线程安全的?
-
线程安全的map如何实现?
-
Go sync.map 和原生 map 谁的性能好,为什么?
-
为什么 Go map 的负载因子是 6.5?
-
map扩容策略是什么?
实现原理
Go中的map是一个指针,占用8个字节,指向hmap结构体; 源码src/runtime/map.go
中可以看到map的底层结构
每个map的底层结构是hmap,hmap包含若干个结构为bmap的bucket数组。每个bucket底层都采用链表结构。接下来,我们来详细看下map的结构!
hmap结构体
// A header for a Go map.
type hmap struct {
count int
// 代表哈希表中的元素个数,调用len(map)时,返回的就是该字段值。
flags uint8
// 状态标志,下文常量中会解释四种状态位含义。
B uint8
// buckets(桶)的对数log_2
// 如果B=5,则buckets数组的长度 = 2^5=32,意味着有32个桶
noverflow uint16
// 溢出桶的大概数量
hash0 uint32
// 哈希种子
buckets unsafe.Pointer
// 指向buckets数组的指针,数组大小为2^B,如果元素个数为0,它为nil。
oldbuckets unsafe.Pointer
// 如果发生扩容,oldbuckets是指向老的buckets数组的指针,老的buckets数组大小是新的buckets的1/2;非扩容状态下,它为nil。
nevacuate uintptr
// 表示扩容进度,小于此地址的buckets代表已搬迁完成。
extra *mapextra
// 这个字段是为了优化GC扫描而设计的。当key和value均不包含指针,并且都可以inline时使用。extra是指向mapextra类型的指针。
}
复制代码
bmap结构体
bmap
就是我们常说的“桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和插入中详细说明。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8
// len为8的数组
// 用来快速定位key是否在这个bmap中
// 桶的槽位数组,一个桶最多8个槽位,如果key所在的槽位在tophash中,则代表该key在这个桶中
}
//底层定义的常量
const (
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
// 一个桶最多8个位置
)
但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
// 溢出桶
}
复制代码
bucket内存数据结构可视化如下:
注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/...
这样的形式。源码里说明这样的好处是在某些情况下可以省略掉 padding字段,节省内存空间。
mapextra结构体
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来。
// mapextra holds fields that are not present on all maps.
type mapextra struct {
// 如果 key 和 value 都不包含指针,并且可以被 inline(<=128 字节)
// 就使用 hmap的extra字段 来存储 overflow buckets,这样可以避免 GC 扫描整个 map
// 然而 bmap.overflow 也是个指针。这时候我们只能把这些 overflow 的指针
// 都放在 hmap.extra.overflow 和 hmap.extra.oldoverflow 中了
// overflow 包含的是 hmap.buckets 的 overflow 的 buckets
// oldoverflow 包含扩容时的 hmap.oldbuckets 的 overflow 的 bucket
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
// 指向空闲的 overflow bucket 的指针
}
复制代码
主要特性
引用类型
map是个指针,底层指向hmap,所以是个引用类型
golang 有三个常用的高级类型slice、map、channel, 它们都是引用类型,当引用类型作为函数参数时,可能会修改原内容数据。
golang 中没有引用传递,只有值和指针传递。所以 map 作为函数实参传递时本质上也是值传递,只不过因为 map 底层数据结构是通过指针指向实际的元素存储空间,在被调函数中修改 map,对调用者同样可见,所以 map 作为函数实参传递时表现出了引用传递的效果。
因此,传递 map 时,如果想修改map的内容而不是map本身,函数形参无需使用指针
func TestSliceFn(t *testing.T) {
m := map[string]int{}
t.Log(m, len(m))
// map[a:1]
mapAppend(m, "b", 2)
t.Log(m, len(m))
// map[a:1 b:2] 2
}
func mapAppend(m map[string]int, key string, val int) {
m[key] = val
}
复制代码
共享存储空间
map 底层数据结构是通过指针指向实际的元素存储空间 ,这种情况下,对其中一个map的更改,会影响到其他map
func TestMapShareMemory(t *testing.T) {
m1 := map[string]int{}
m2 := m1
m1["a"] = 1
t.Log(m1, len(m1))
// map[a:1] 1
t.Log(m2, len(m2))
// map[a:1]
}
复制代码
遍历顺序随机
map 在没有被修改的情况下,使用 range 多次遍历 map 时输出的 key 和 value 的顺序可能不同。这是 Go 语言的设计者们有意为之,在每次 range 时的顺序被随机化,旨在提示开发者们,Go 底层实现并不保证 map 遍历顺序稳定,请大家不要依赖 range 遍历结果顺序。
map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map。
func TestMapRange(t *testing.T) {
m := map[int]string{1: "a", 2: "b", 3: "c"}
t.Log("first range:")
// 默认无序遍历
for i, v := range m {
t.Logf("m[%v]=%v ", i, v)
}
t.Log("\nsecond range:")
for i, v := range m {
t.Logf("m[%v]=%v ", i, v)
}
// 实现有序遍历
var sl []int
// 把 key 单独取出放到切片
for k := range m {
sl = append(sl, k)
}
// 排序切片
sort.Ints(sl)
// 以切片中的 key 顺序遍历 map 就是有序的了
for _, k := range sl {
t.Log(k, m[k])
}
}
复制代码
非线程安全
map默认是并发不安全的,原因如下:
Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),决定了不支持。
场景: 2个协程同时读和写,以下程序会出现致命错误:fatal error: concurrent map writes
func main() {
m := make(map[int]int)
go func() {
//开一个协程写map
for i := 0; i < 10000; i++ {
m[i] = i
}
}()
go func() {
//开一个协程读map
for i := 0; i < 10000; i++ {
fmt.Println(m[i])
}
}()
//time.Sleep(time.Second * 20)
for {
;
}
}
复制代码
如果想实现map线程安全,有两种方式:
方式一:使用读写锁 map
+ sync.RWMutex
func BenchmarkMapConcurrencySafeByMutex(b *testing.B) {
var lock sync.Mutex //互斥锁
m := make(map[int]int, 0)
var wg sync.WaitGroup
for i := 0; i < b.N; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
lock.Lock()
defer lock.Unlock()
m[i] = i
}(i)
}
wg.Wait()
b.Log(len(m), b.N)
}
复制代码
方式二:使用golang提供的 sync.Map
sync.map是用读写分离实现的,其思想是空间换时间。和map+RWLock的实现方式相比,它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求(增删改查遍历),那就不用去操作write map(它的读写都要加锁),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式。
func BenchmarkMapConcurrencySafeBySyncMap(b *testing.B) {
var m sync.Map
var wg sync.WaitGroup
for i := 0; i < b.N; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m.Store(i, i)
}(i)
}
wg.Wait()
b.Log(b.N)
}
复制代码
哈希冲突
golang中map是一个kv对集合。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv。在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。
总结
-
map是引用类型
-
map遍历是无序的
-
map是非线程安全的
-
map的哈希冲突解决方式是链表法
-
map的扩容不是一定会新增空间,也有可能是只是做了内存整理
-
map的迁移是逐步进行的,在每次赋值时,会做至少一次迁移工作
-
map中删除key,有可能导致出现很多空的kv,这会导致迁移操作,如果可以避免,尽量避免