彻底理解Golang Map

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情

本文目录如下,阅读本文后,将一网打尽下面Golang Map相关面试题

image.png

面试题

  1. map的底层实现原理

  2. 为什么遍历map是无序的?

  3. 如何实现有序遍历map?

  4. 为什么Go map是非线程安全的?

  5. 线程安全的map如何实现?

  6. Go sync.map 和原生 map 谁的性能好,为什么?

  7. 为什么 Go map 的负载因子是 6.5?

  8. map扩容策略是什么?

    扫描二维码关注公众号,回复: 13769051 查看本文章

实现原理

Go中的map是一个指针,占用8个字节,指向hmap结构体; 源码src/runtime/map.go中可以看到map的底层结构

每个map的底层结构是hmap,hmap包含若干个结构为bmap的bucket数组。每个bucket底层都采用链表结构。接下来,我们来详细看下map的结构!

image.png

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字段,节省内存空间。

image.png

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]int0)
 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。

总结

  1. map是引用类型

  2. map遍历是无序的

  3. map是非线程安全的

  4. map的哈希冲突解决方式是链表法

  5. map的扩容不是一定会新增空间,也有可能是只是做了内存整理

  6. map的迁移是逐步进行的,在每次赋值时,会做至少一次迁移工作

  7. map中删除key,有可能导致出现很多空的kv,这会导致迁移操作,如果可以避免,尽量避免

猜你喜欢

转载自juejin.im/post/7082735541438906399
今日推荐