Golang 语言map底层实现原理解析

在开发过程中,map是必不可少的数据结构,在Golang中,使用map或多或少会遇到与其他语言不一样的体验,比如访问不存在的元素会返回其类型的空值、map的大小究竟是多少,为什么会报"cannot take the address of"错误,遍历map的随机性等等。
本文希望通过研究map的底层实现,以解答这些疑惑。
基于Golang 1.8.3

1. 数据结构及内存管理

hashmap的定义位于 src/runtime/hashmap.go 中,首先我们看下hashmap和bucket的定义:

type hmap struct {
 count  int // 元素的个数
 flags  uint8 // 状态标志
 B   uint8 // 可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子
 noverflow uint16 // 溢出的个数
 hash0  uint32 // 哈希种子

 buckets unsafe.Pointer // 桶的地址
 oldbuckets unsafe.Pointer // 旧桶的地址,用于扩容
 nevacuate uintptr  // 搬迁进度,小于nevacuate的已经搬迁
 overflow *[2]*[]*bmap
}

其中,overflow是一个指针,指向一个元素个数为2的数组,数组的类型是一个指针,指向一个slice,slice的元素是桶(bmap)的地址,这些桶都是溢出桶;为什么有两个?因为Go map在hash冲突过多时,会发生扩容操作,为了不全量搬迁数据,使用了增量搬迁,[0]表示当前使用的溢出桶集合,[1]是在发生扩容时,保存了旧的溢出桶集合;overflow存在的意义在于防止溢出桶被gc。

// A bucket for a Go map.
type bmap struct {
 // 每个元素hash值的高8位,如果tophash[0] < minTopHash,表示这个桶的搬迁状态
 tophash [bucketCnt]uint8
 // 接下来是8个key、8个value,但是我们不能直接看到;为了优化对齐,go采用了key放在一起,value放在一起的存储方式,
 // 再接下来是hash冲突发生时,下一个溢出桶的地址
}

tophash的存在是为了快速试错,毕竟只有8位,比较起来会快一点。

从定义可以看出,不同于STL中map以红黑树实现的方式,Golang采用了HashTable的实现,解决冲突采用的是链地址法。也就是说,使用数组+链表来实现map。特别的,对于一个key,几个比较重要的计算公式为:

key hash hashtop bucket index
key hash := alg.hash(key, uintptr(h.hash0)) top := uint8(hash >> (sys.PtrSize*8 - 8)) bucket := hash & (uintptr(1)<<h.B - 1),即 hash % 2^B

例如,对于B = 3,当hash(key) = 4时, hashtop = 0, bucket = 4,当hash(key) = 20时,hashtop = 0, bucket = 4;这个例子我们在搬迁过程还会用到。

内存布局类似于这样:

hashmap-buckets

2. 创建 - makemap

map的创建比较简单,在参数校验之后,需要找到合适的B来申请桶的内存空间,接着便是穿件hmap这个结构,以及对它的初始化。

makemap

3. 访问 - mapaccess

对于给定的一个key,可以通过下面的操作找到它是否存在

image.png

方法定义为

// returns key, if not find, returns nil
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer 

// returns key and exist. if not find, returns nil, false
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)

// returns both key and value. if not find, returns nil, nil
func mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer)

可见在找不到对应key的情况下,会返回nil

4. 分配 - mapassign

为一个key分配空间的逻辑,大致与查找类似;但增加了写保护和扩容的操作;注意,分配过程和删除过程都没有在oldbuckets中查找,这是因为首先要进行扩容判断和操作;如下:

assign

扩容是整个hashmap的核心算法,我们放在第6部分重点研究。

新建一个溢出桶,并将其拼接在当前桶的尾部,实现了类似链表的操作:

// 获取当前桶的溢出桶
func (b *bmap) overflow(t *maptype) *bmap {
 return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize))
}

// 设置当前桶的溢出桶
func (h *hmap) setoverflow(t *maptype, b, ovf *bmap) {
 h.incrnoverflow()
 if t.bucket.kind&kindNoPointers != 0 {
  h.createOverflow()
  //重点,这里讲溢出桶append到overflow[0]的后面
  *h.overflow[0] = append(*h.overflow[0], ovf)
 }
 *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize)) = ovf
}

5. 删除 - mapdelete

删除某个key的操作与分配类似,由于hashmap的存储结构是数组+链表,所以真正删除key仅仅是将对应的slot设置为empty,并没有减少内存;如下:

mapdelete

6. 扩容 - growWork

首先,判断是否需要扩容的逻辑是

func (h *hmap) growing() bool {
 return h.oldbuckets != nil
}

何时h.oldbuckets不为nil呢?在分配assign逻辑中,当没有位置给key使用,而且满足测试条件(装载因子>6.5或有太多溢出通)时,会触发hashGrow逻辑:

func hashGrow(t *maptype, h *hmap) {
 //判断是否需要sameSizeGrow,否则"真"扩
 bigger := uint8(1)
 if !overLoadFactor(int64(h.count), h.B) {
  bigger = 0
  h.flags |= sameSizeGrow
 }
  // 下面将buckets复制给oldbuckets
 oldbuckets := h.buckets
 newbuckets := newarray(t.bucket, 1<<(h.B+bigger))
 flags := h.flags &^ (iterator | oldIterator)
 if h.flags&iterator != 0 {
  flags |= oldIterator
 }
 // 更新hmap的变量
 h.B += bigger
 h.flags = flags
 h.oldbuckets = oldbuckets
 h.buckets = newbuckets
 h.nevacuate = 0
 h.noverflow = 0
  // 设置溢出桶
 if h.overflow != nil {
  if h.overflow[1] != nil {
   throw("overflow is not nil")
  }
// 交换溢出桶
  h.overflow[1] = h.overflow[0]
  h.overflow[0] = nil
 }
}

OK,下面正式进入重点,扩容阶段;在assign和delete操作中,都会触发扩容growWork:

func growWork(t *maptype, h *hmap, bucket uintptr) {
 // 搬迁旧桶,这样assign和delete都直接在新桶集合中进行
 evacuate(t, h, bucket&h.oldbucketmask())
  //再搬迁一次搬迁过程中的桶
 if h.growing() {
  evacuate(t, h, h.nevacuate)
 }
}

6.1 搬迁过程

一般来说,新桶数组大小是原来的2倍(在!sameSizeGrow()条件下),新桶数组前半段可以"类比"为旧桶,对于一个key,搬迁后落入哪一个索引中呢?

假设旧桶数组大小为2^B, 新桶数组大小为2*2^B,对于某个hash值X
若 X & (2^B) == 0,说明 X < 2^B,那么它将落入与旧桶集合相同的索引xi中;
否则,它将落入xi + 2^B中。

例如,对于旧B = 3时,hash1 = 4,hash2 = 20,其搬迁结果类似这样。

example.png

源码中有些变量的命名比较简单,容易扰乱思路,我们注明一下便于理解。

变量 释义
x *bmap 桶x表示与在旧桶时相同的位置,即位于新桶前半段
y *bmap 桶y表示与在旧桶时相同的位置+旧桶数组大小,即位于新桶后半段
xi int 桶x的slot索引
yi int 桶y的slot索引
xk unsafe.Pointer 索引xi对应的key地址
yk unsafe.Pointer 索引yi对应的key地址
xv unsafe.Pointer 索引xi对应的value地址
yv unsafe.Pointer 索引yi对应的value地址

搬迁过程如下:

evacuate

总结

到目前为止,Golang的map实现细节已经分析完毕,但不包含迭代器相关操作。通过分析,我们了解了map是由数组+链表实现的HashTable,其大小和B息息相关,同时也了解了map的创建、查询、分配、删除以及扩容搬迁原理。总的来说,Golang通过hashtop快速试错加快了查找过程,利用空间换时间的思想解决了扩容的问题,利用将8个key(8个value)依次放置减少了padding空间等等。

到此这篇关于Golang 语言map底层实现原理解析的文章就介绍到这了,更多相关Golang map底层实现原理内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • golang针对map的判断,删除操作示例

    本文实例讲述了golang针对map的判断,删除操作.分享给大家供大家参考,具体如下: map是一种key-value的关系,一般都会使用make来初始化内存,有助于减少后续新增操作的内存分配次数.假如一开始定义了话,但没有用make来初始化,会报错的. 复制代码 代码如下: package main import ( "fmt" ) func main(){ var test =  map[string]string{"姓名":"李四",&qu

  • golang中sync.Map并发创建、读取问题实战记录

    背景: 我们有一个用go做的项目,其中用到了zmq4进行通信,一个简单的rpc过程,早期远端是使用一个map去做ip和具体socket的映射. 问题 大概是这样 struct SocketMap { sync.Mutex sockets map[string]*zmq4.Socket } 然后调用的时候的代码大概就是这样的: func (pushList *SocketMap) push(ip string, data []byte) { pushList.Lock() defer pushLi

  • Golang学习笔记(四):array、slice、map

    一.Array 在Go语言中,数组是一个值类型(value type) 所有的值类型变量在赋值和作为参数传递时都将产生一个复制动作 如果作为函数的参数类型,则在函数调用时参数发生数据复制,在函数体中无法修改传入数组的内容 数组相等用 = != 比较,不能用 < > 1.声明&赋值 初始化 语法 复制代码 代码如下: var VarName [n]type     // n>=0 e.g. var a [5]int //[0 0 0 0 0] var c [2][3]int //二

  • Golang map如何生成有序的json数据详解

    前言 本文主要给大家介绍了关于Golang map生成有序json数据的相关内容,分享出来供大家参考学习,下面来一起看看详细的介绍: 先来看一段 Golang 生成 json 的代码,首先定义了一个 map[string]interface{}  的变量,然后存一些值,这里要注意的是 previews 字段,为了浏览器获取到的 json 数据是有序的,所以定义了一个 map[int]map[string]string 的类型,加上了一个表示顺序的键: list := make(map[strin

  • 理解Golang中的数组(array)、切片(slice)和map

    我比较喜欢先给出代码,然后得出结论 数组 复制代码 代码如下: package main import (     "fmt" ) func main() {     arr := [...]int{1, 2, 3}     //打印初始的指针     fmt.Printf("the pointer is : %p \n", &arr)     printPointer(arr) } func printPointer(any interface{}) {

  • Golang 语言map底层实现原理解析

    在开发过程中,map是必不可少的数据结构,在Golang中,使用map或多或少会遇到与其他语言不一样的体验,比如访问不存在的元素会返回其类型的空值.map的大小究竟是多少,为什么会报"cannot take the address of"错误,遍历map的随机性等等. 本文希望通过研究map的底层实现,以解答这些疑惑. 基于Golang 1.8.3 1. 数据结构及内存管理 hashmap的定义位于 src/runtime/hashmap.go 中,首先我们看下hashmap和buck

  • Golang errgroup 设计及实现原理解析

    目录 开篇 errgroup 源码拆解 Group WithContext Wait Go SetLimit TryGo 使用方法 结束语 开篇 继上次学习了信号量 semaphore 扩展库的设计思路和实现之后,今天我们继续来看 golang.org/x/sync 包下的另一个经常被 Golang 开发者使用的大杀器:errgroup. 业务研发中我们经常会遇到需要调用多个下游的场景,比如加载一个商品的详情页,你可能需要访问商品服务,库存服务,券服务,用户服务等,才能从各个数据源获取到所需要的

  • Redis主从配置和底层实现原理解析(实战记录)

    我们使用Redis的时候往往都是主从模式或者集群架构,不会使用单台Redis服务. 一.Redis主从配置实战 我们使用master节点写输入,然后将数据同步到slave节点,从节点可以提供读取或者备份的功能,分担master节点压力. redis主从架构搭建,配置从节点步骤 1. 复制一份redis.conf文件为redis-6380.conf cp ./redis.conf ./conf/redis-6380.conf 2.打开redis-6380.conf配置文件,将相关配置修改为如下值:

  • Java 8 动态类型语言Lambda表达式实现原理解析

    Java 8支持动态语言,看到了很酷的Lambda表达式,对一直以静态类型语言自居的Java,让人看到了Java虚拟机可以支持动态语言的目标. import java.util.function.Consumer; public class Lambda { public static void main(String[] args) { Consumer<String> c = s -> System.out.println(s); c.accept("hello lambd

  • Java同步关键字synchronize底层实现原理解析

    目录 1 字节码层实现 1.1 InterpreterRuntime::monitorenter 1.1.1 函数参数 JavaThread *thread 1.1.2 函数体 2 偏向锁 2.1 偏向锁的意义 2.2 偏向锁的获取 2.2.1 markOop mark = obj->mark() 2.2.2 判断mark是否为可偏向状态 2.2.3 判断mark中JavaThread的状态 2.2.4 通过CAS原子指令 2.2.5 如果执行CAS失败 2.3 偏向锁的撤销 2.4 轻量级锁

  • golang语言map全方位介绍

    目录 一.map 1.基本介绍 2.声明基本语法 二.map 的使用 2.map[string]map[string]string使用案例 三.map 的增删改查操作 1.map 增加和更新 2.map 删除 3.map 查找 四.map的其他操作 1.map 遍历: 2.map 的长度 3.map 切片 4.map 排序 五.map 使用细节 总结 一.map 1.基本介绍 map 是 key-value 数据结构,又称为字段或者关联数组.类似其它编程语言的集合, 在编程中是经常使用到 2.声

  • Spring底层事务原理解析

    目录 一.@EnableTransactionManagement工作原理 二.Spring事务基本执行原理 四.Spring事务传播机制 五.Spring事务传播机制分类 六.Spring事务强制回滚 七.TransactionSynchronization 一.@EnableTransactionManagement工作原理 开启Spring事务本质上就是增加了一个Advisor,但我们使用 @EnableTransactionManagement注解来开启Spring事务是,该注解代理的功

  • JAVA序列化和反序列化的底层实现原理解析

    一.基本概念 1.什么是序列化和反序列化 (1)Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程: (2)**序列化:**对象序列化的最主要的用处就是在传递和保存对象的时候,保证对象的完整性和可传递性.序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中.序列化后的字节流保存了Java对象的状态以及相关的描述信息.序列化机制的核心作用就是对象状态的保存与重建. (3)**反序列化:**客户端从文件中或网络上获得序列化后

  • Golang map实践及实现原理解析

    目录 Map实践以及实现原理 使用实例 内存模型 创建map hash函数 key定位和碰撞解决 扩容 元素访问 删除 迭代 Map实践以及实现原理 使用实例内存模型创建maphash函数key定位和碰撞解决扩容元素访问删除迭代核心点: 使用实例 测试的主要目的是对于map,当作为函数传参时候,函数内部的改变会不会透传到外部,以及函数传参内外是不是一个map,也就是传递的是实例还是指针.(golang里面的传参都是值传递). Test Case1:传参为map. func main(){ fmt

  • Golang底层原理解析String使用实例

    目录 引言 String底层 stringStruct结构 引言 本人因为种种原因(说来听听),放弃大学学的java,走上了golang这条路,本着干一行爱一行的情怀,做开发嘛,不能只会使用这门语言,所以打算开一个底层原理系列,深挖一下,狠狠的掌握一下这门语言 废话不多说,上货 String底层 既然研究底层,那就得全方面覆盖,必须先搞一下基础的东西,那必须直接基本数据类型走起啊, 字符串String的底层我看就很基础 string大家应该都不陌生,go中的string是所有8位字节字符串的集合

随机推荐