golang map & sync.Map

432 阅读4分钟

简介

本来写了一大堆介绍map的,想了想还是删了,如果不知道map是什么的应该也不会看这个文章,还是留给有缘人自己看吧。

golang中有两种map,不支持并发的普通map和并发安全的sync.Map。之所以要分两种,是因为go的设计人员认为,并发安全的结构必然有锁的损耗,不应当让没有这类需求的普通用户来承担这部分性能损失,而作为一个面向高并发的语言,一个并发安全的map又是不可或缺的,因此就分别实现了这两种map

map

数据结构

关于引用类型和值传递的内容,可以跳转golang 参数传递和引用类型

这里我们直接明确,map是个指针,指向*hmap结构。为了表达方便,之后我们也用hmap结构来指代map。

直接偷个map概念图 from 图文并茂详解数据结构之哈希表

image.png

然后再偷个golang的map的概念图 from map数据结构底层详解

image.png

这里可能不会太过详细的讲各个结构的含义和用法,毕竟场景有点多,我们会分场景来讨论(增改/删/查)

没有扩容的时候

我们先看没有扩容的场景,比较简单,

首先对key进行哈希,根据最后的B位找到对应的bucket,然后从bucket的所有key中搜索当前的key。如果找到则返回,找不到返回对应value类型的空值。

这里bucket其实是一连串的bucket链表,所以如果第一个没有还会向下找直到找到最后一个。

在bucket内部做比较时,会先使用hash值的高位进行比较,(tophash位置存储的就是对应位置的hash最高位)尽可能减少两个key的比较,因为复杂的key的比较可能很慢。

对key的查找逻辑在增改和删除中都有使用。

增&改

首先用查找的逻辑找到value的位置(会标记第一个可以写入的位置)。并返回一个指针,如果找到了,说明本来就有,直接改就行。如果没找到,需要增加。还有空位,则写入空位,如果没有空位了,就申请一个新的bucket挂在原来的链最后。

首先用查找的逻辑找到value的位置,如果没找到说明没有,不用删。如果找到了,则删除key并且删除对应的tophash(其实是改成了某个枚举值)。

扩容

扩容的触发仅在增和改的时候。扩容触发时,buckets被改成oldBuckets,然后在之后的处理中,一旦发现oldbuckets不为空就认为在扩容的途中。

每当请求(不只是增和改)的时候,如果发现map正在扩容中,则可能需要对当前的bucket链表进行搬迁。扩容只会扩充到原来的两倍,由于B增加了1,所以bucket的位置对应的哈希值也多了一位。这也保证了原来的bucket的元素只会落到新的buckets数组里的两个位置。

搬迁之后,再重新进行增删查操作。

在map结构中,还有一个nevacuate,指代小于这个数字的bucket都已经搬迁完了,也可以理解为下一个需要搬迁的bucket。在正常请求的搬迁之后还会进行一次额外的搬迁,搬迁的位置就是nevacuate这个位置。这样可以加速整个map的搬迁速度。因为在搬迁过程中,map的访问速度会变慢。

额外结构

map有一个mapextra结构,存放了指向所有overflow结构的指针。这是为了减少gc的开销。在map不存储带指针的数据时,我们希望gc不扫描map的内部,但是因为bucket使用指针连接,所以为了防止gc把更多的bucket释放掉,使用了额外的数据结构持有map中的数据。

并发

map支持并发读,但是有单独的写状态位。在开始写的时候会标记写状态,如果有其他请求尝试读写这个状态的map,会直接报错。

sync.Map

为什么要有sync.Map

如果只实现一种并发安全的map,那势必需要大量加锁,影响map性能。因此,go开发团队使用了两种数据结构,分别用于这两种场景

sync.Map基于map实现,主要侧重在并发请求时的性能及数据同步。

数据结构

按照惯例偷个图过来 from 深度解密Go语言之sync.map

image.png

这里的dirty和readOnly的m都是普通的map类型,具体的key寻址和增删和普通map的逻辑相同。

我们注意到

(施工中。。。)