golang map实现原理浅析

总体来说golang的maphashmap,是使用数组+链表的形式实现的,使用拉链法消除hash冲突

map的内存模型

我的go源码版本是:go1.17.2

map的源码在Go_SDK\go1.17.2\src\runtime\map.go中。

首先我们来看一下map最重要的两个结构:

hmap:

// 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
}

bmap:(bucket桶)

// A bucket for a Go map.
type bmap struct {
	// tophash generally contains the top byte of the hash value
	// for each key in this bucket. If tophash[0] < minTopHash,
	// tophash[0] is a bucket evacuation state instead.
	tophash [bucketCnt]uint8
	// Followed by bucketCnt keys and then bucketCnt elems.
	// NOTE: packing all the keys together and then all the elems together makes the
	// code a bit more complicated than alternating key/elem/key/elem/... but it allows
	// us to eliminate padding which would be needed for, e.g., map[int64]int8.
	// Followed by an overflow pointer.
}

实际上在golang runtime时,编译器会动态为bmap创建一个新结构:

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

Golang中map的底层实现是一个哈希表,因此实现map的过程实际上就是实现哈希表的过程。在这个哈希表中,主要出现的结构体有两个,一个叫hmap(a header for a go map),一个叫bmap(a bucket for a Go map,通常叫其bucket)。这两种结构的样子分别如下所示:

hmap:

在这里插入图片描述

  • count:键值对数目。
  • flags:状态标志。
  • B:当前桶个数的二次幂。
  • noverflow:溢出桶的个数。
  • hash0:哈希种子。
  • buckets:哈希桶的地址。
  • old buckets:旧桶的地址,用于扩容。
  • nevacuate:即将迁移的旧桶编号,可以作为搬迁进度,小于nevacuate的已经搬迁。
  • overflow:溢出桶的信息。

bucket:

其中键值对被保存在溢出桶中,溢出桶的结构是这样的:

“高位哈希值”数组存储的是通过哈希函数计算后的key再经取模后的高八位,而储存键值对的数组一共可以储存8个键值对,其中key都存在数组的前面,而value都存在后面。最后一个字段是一个指向溢出桶的指针。

在这里插入图片描述

由此看出hmapbucket的关系是这样的:

在这里插入图片描述

对key求哈希

哈希表的特点是会有一个哈希函数,对传进来的key进行哈希运算,得到唯一的值,一般情况下都是一个数值。Golang的map中也有这么一个哈希函数,也会算出唯一的值,对于这个值的使用:

Golang把求得的值按照用途一分为二:高位和低位。

在这里插入图片描述
如图所示,蓝色为高位,红色为低位。 然后低位用于寻找当前key属于hmap中的哪个bucket,而高位用于寻找bucket中的哪个key。上文中提到:bucket中有个属性字段是“高位哈希值”数组,这里存的就是蓝色的高位值,用来声明当前bucket中有哪些“key”,便于搜索查找。 需要特别指出的一点是:我们map中的key/value值都是存到同一个数组中的。数组中的顺序是这样的:
在这里插入图片描述
并不是key0/value0/key1/value1的形式,这样做的好处是:在key和value的长度不同的时候,可以消除padding(内存对齐)带来的空间浪费。

map的扩容

渐进式扩容

需要扩容时就要分配更多的桶(Bucket),它们就是新桶。需要把旧桶里储存的键值对都迁移到新桶里。如果哈希表存储的键值对较多,一次性迁移所有桶所花费的时间就比较显著。
所以通常会在哈希表扩容时,先分配足够多的新桶,然后用一个字段(oldbuckets)记录旧桶的位置。
再增加一个字段(nevacuate),记录旧桶迁移的进度。例如记录下一个要迁移的旧桶编号。
在哈希表每次进行读写操作时,如果检测到当前处于扩容阶段,就完成一部分键值对迁移任务,直到所有的旧桶迁移完成,旧桶不再使用,才算真正完成一次哈希表的扩容。
像这样把键值对迁移的时间分摊到多次哈希表操作中的方式,就是渐进式扩容,可以避免一次性扩容带来的性能瞬时抖动。

在这里插入图片描述

扩容规则

bmap结构体的最后一个字段是一个bmap型指针,指向一个溢出桶。溢出桶的内存布局与常规桶相同,是为了减少扩容次数而引入的。
当一个桶存满了,还有可用的溢出桶时,就会在后面链一个溢出桶,继续往这里面存。

在这里插入图片描述
实际上如果哈希表要分配的桶数目大于2 ^ 4,就认为要使用到溢出桶的几率较大,就会预分配2 ^ (B - 4)个溢出桶备用。
这些溢出桶与常规桶在内存中是连续的,只是前2 ^ B个用做常规桶,后面的用作溢出桶。

hmap结构体最后有一个extra字段,指向一个mapextra结构体。里面记录的都是溢出桶相关的信息。nextoverflow指向下一个空闲溢出桶。
overflow是一个slice,记录目前已经被使用的溢出桶的地址。noverflower记录使用的溢出桶数量。oldoverflower用于在扩容阶段储存旧桶用到的那些溢出桶的地址。

在这里插入图片描述

翻倍扩容

负载因子

判断翻倍扩容的条件,就是哈希表中的负载因子(键值对个数 / 桶个数)。
负载因子的计算公式是:map长度 / 2^B,阈值是6.5。其中B是bucket个数的二次幂,存在hmap表头结构中。

当负载因子 count / (2 ^ B) > 6.5 ,就会发生翻倍扩容(hmap.B++),分配新桶的数量是旧桶的两倍。
buckets指向新分配的两个桶,oldbuckets指向旧桶。nevacuate为0,表示接下来要迁移编号为0的旧桶。
每个旧桶的键值对都会分流到两个新桶中。

在这里插入图片描述

等量扩容

如果负载因子没有超标,但是使用的溢出桶较多,也会出发扩容,不过这一次是等量扩容

那么用多少溢出桶算多了呢?

  • 如果常规桶的数目不大于 2 ^ 15 ,那么使用溢出桶的数目超过常规桶就算是多了。
  • 如果常规桶的数目大于 2 ^ 15 ,那么使用溢出桶的数目一旦超过 2 ^ 15 ,就算是多了。

所谓等量扩容,就是创建和旧桶数目一样多的新桶。然后把原来的键值对迁移到新桶中,但是既然是等量,那来回迁移的又有什么用呢?
什么情况下,桶的负载因子没有超过上限值,却偏偏使用了很多溢出桶呢?自然是有很多键值对被删除的情况。同样数目的键值对,迁移到新桶中,能够排列的更加紧凑,从而减少溢出桶的使用。这就是等量扩容的意义所在。

参考博客:
https://www.cnblogs.com/maji233/p/11070853.html

https://www.bilibili.com/video/BV1Sp4y1U7dJ?spm_id_from=333.999.0.0

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Golang 中,中间件是一种常见的设计模式,用于在请求处理过程中添加公共功能、处理请求前后的逻辑等。下面是一种常见的 Golang 中间件实现原理: 1. 定义一个中间件函数类型,它接受一个 `http.Handler` 参数,并返回一个新的 `http.Handler` 对象。例如: ```go type MiddlewareFunc func(http.Handler) http.Handler ``` 2. 编写一个具体的中间件函数,它符合上述定义的中间件函数类型。该函数通常会包装原始的 `http.Handler` 对象,添加额外的逻辑或修改请求/响应。 ```go func LoggerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 添加日志记录逻辑 log.Println("Handling request:", r.URL.Path) // 调用下一个处理器 next.ServeHTTP(w, r) }) } ``` 3. 在路由处理器中使用中间件。通过将中间件函数应用到路由处理器上,可以实现对该路由及其子路由的请求进行拦截和处理。 ```go func main() { // 创建一个路由器 router := mux.NewRouter() // 应用中间件到路由器 router.Use(LoggerMiddleware) // 添加路由处理器 router.HandleFunc("/", handler) // 启动服务器 http.ListenAndServe(":8080", router) } ``` 在上述例子中,`LoggerMiddleware` 是一个简单的日志记录中间件,它会在处理每个请求之前输出请求的路径信息。通过调用 `router.Use(LoggerMiddleware)`,该中间件会应用到所有的路由上。 这是一种常见的中间件实现原理,你可以根据自己的需求编写更复杂的中间件。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值