深入理解 Golang map 设计理念与实现原理

2,028 阅读5分钟

深入理解 Golang map

〇、前言

大家好,鄙人是丘山子。

我们在使用Go时,经常会用到map,当然map几乎是所有高级编程语言都会设计的数据结构,说到Go的map,我们马上就会条件反射性的想到并发读写导致的panic,脑海里还会浮现一串红色的字母。另外还有Go的map为什么是无序的?看完本文,我相信这些你都能找到答案!

一、Golang map的设计理念

经常参加算法竞赛,熟悉C++ STL的朋友就知道,C++ STL的map底层是用改造的红黑树实现的,红黑树有什么特征呢?其中之一即为红黑树形式存储的键值是有序的。

而使用Java的朋友就知道,Java有很多种map——HashMapTreeMap等等...可以说很齐全了。而这里的HashMap其实和Golang的map设计原理还是挺像的。

而我们Go的map是基于哈希查找算法的,至于为什么使用哈希查找算法来设计这样一个map,没有找到Golang团队的说法,我猜想可能是考虑性能问题吧,毕竟基于哈希查找算法的map大部分情况下速度还是比较快的。

而使用哈希查找算法一般会产生哈希碰撞的问题,处理这种冲突问题一般的方法是:

  • 开放定址法
  • 再散列函数法
  • 链表法

而Golang的map采用的就是链表法。下面来细讲使用链表法的map底层是怎么实现的~

二、map底层实现原理

在Go源码中,map的结构如下:

// A header for a Go map.
type hmap struct {
    // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
    // Make sure this stays in sync with the compiler's definition.
    count     int // # live cells == size of map.  Must be first (used by len() builtin)
    flags     uint8
    B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    hash0     uint32 // hash seed
​
    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)
​
    extra *mapextra // optional fields
}

简单介绍一下重点的几个结构体成员,count是放入map的元素个数,如果我们使用len(map),得到的值就是该值。2的B次方就是buckets数组的长度,下面的buckets就是指向存放map元素的地方,而接下来oldbuckets和nevacuate都是map扩容时需要用到的成员,仅当扩容时oldbuckets才不为空。

我们此处假设B为5,那么buckets指向的是一个大小为32的“桶数组”,为什么叫桶数组,因为将32个桶整整齐齐的有序的放在一个数组里面了。而数组的每个元素都可能是一条链表...

链表中的每个桶,在源码中称之为bmap,最多可以装8个键值对。放入map中的每个键值对,会对其键做哈希运算,将得到的哈希值低5位作为桶号,就是桶数组的编号,决定其放入哪一个桶。而后再取高8位找到桶内的槽编号。如果找不到,就放入第一个空的槽位。

当然每个槽只能放入8个键值对,如果有第九个,就需要在槽的overflow指针后面再接一个槽,放入下一个槽中,这样就组成了链表。

三、map中的key为何无序?

其实这样设计,是为了告诉开发者,因为扩容map中的key的位置是会变化的,为了防止开发者忽略这一事实,导致出现Bug,Golang的设计团队直接将map的key设计为无序的。

它是这样设计的,在遍历map时,是取一个范围内的随机数,然后从序号为这个随机数的桶开始,在桶内,也是找到一个随机的cell开始遍历,如此,map中的key就是无序的。

四、map的并发安全

我一直很好奇,作为一个将高并发作为特性的编程语言,居然基础数据结构map是非并发安全的,当然可能是考虑性能问题,毕竟要想实现并发安全也是可以通过锁等来实现的。

加读写锁是可以解决并发安全,但是太消耗性能了,高并发场景下不可行,于是Go设计了一个concurrent-map:

concurrent-map/concurrent_map.go at master · orcaman/concurrent-map github.com/orcaman/con…

他的实现原理是分区加锁,我们没必要给整个map加上锁,我们将一个map分成多个区,每次读写一个元素时,只给该元素所在的那一区加上锁,其他区可以正常读写。

Go还有一个sync 包中的map,其通过将读写分离实现了某些场景下的性能提升。但是使用它的条件比较苛刻:

a) when the entry for a given key is only ever written once but read many times, as in caches that only grow. b) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys.

即一写多读场景或者各个协程操作的key集合没有交集。

至于两个方案的性能对比,tabbyzhou大佬进行了性能测试:

sync.map在插入不同key时的表现似乎是最差的。在concurrent-map的分区数设置为1时,可以认为是对单个map加了全局读写锁,居然也比sync.map要快。但sync.map和分区为1的concurrent-map在多次测试时差异比较大,有时sync.map快,有时分区为1的concurrent-map快。单都不会比分区为16以上的concurrent-map快。而且并不是分区数越大越快,在分区数为256时,执行速度已经开始变慢了。

——cloud.tencent.com/developer/a…

这个结论仅供参考,各位根据自己的应用场景,进行性能测试再选出合适的那一种方案。

参考文献:

  1. map 的实现原理 | Go 程序员面试笔试宝典 golang.design/go-question…
  1. go 并发安全map之concurrent-map - 腾讯云开发者社区-腾讯云 cloud.tencent.com/developer/a…
  2. Go 并发之三种线程安全的 map - 知乎 zhuanlan.zhihu.com/p/356739568

本文正在参加「金石计划 . 瓜分6万现金大奖」