golang中的map

0.1、索引

https://waterflow.link/articles/1666339004798

1、map的结构

map提供了键值对的无序集合,所有的键都是不重复的。在go中map是基于bmap数据结构的。在内部hash表是一个桶数组,每个桶是一个指向键值对数组的指针。每个桶里面可以保存8个元素。我们可以简化成下面的结构。

如果我们继续插入一个元素,hash键返回相同的索引,则另一个元素也会插入相同的桶中。
http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1666339085.png

如果正常桶中的元素已满,还有元素继续往相同的索引插入的话,go会创建另一个包含8个元素的桶并将前一个桶指向他。
http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1666339098.png

所以当我们读取、更新和删除map元素时,Go 必须计算相应的数组索引。 然后 Go 依次遍历所有键,直到找到提供的键。 因此,这三个操作的最坏情况时间复杂度为 O§,其中 p 是桶中元素的总数(默认为一个桶,溢出时为多个桶)。

2、map初始化

首先我们先初始化一个包含3个元素的map:

m := map[string]int{
		"haha": 3,
		"hehe": 5,
		"hoho": 7,
	}

我们可能只需要遍历2个桶就可以找到上面的所有元素。

但是当我们添加100万个元素的时候,我们可能需要遍历上千个桶去找到指定的元素。

为了应对元素的增长,map会选择扩容,一般是当前桶数量增加一倍。那什么时候会扩容呢?

  • 负载因子大于6.5
  • 溢出桶太多

当map扩容的时候,所有的键都会重新分配到新的桶。所以最坏情况下,插入元素有可能是O(n)。

我们知道,在使用切片时,如果我们预先知道要添加到切片的元素数量,我们可以用给定的大小或容量对其进行初始化。这避免了不断应对切片增长导致底层数组频繁复制的问题。map与此类似。实际上,我们可以使用 make 内置函数在创建地图时提供初始大小。例如,如果我们要初始化一个包含 100 万个元素的map,可以这样写:

m := make(map[string]int, 1000000)

通过指定大小,go使用适当数量的桶创建map以存储 100 万个元素。 这节省了大量计算时间,因为map不用动态创建桶并处理桶溢出后rehash的问题。

指定大小 n 并不是说创建最多有100万个元素的map。 我们可以继续往map添加元素。 这实际代表着 Go 运行时至少 需要为n 个元素分配内存。

我们可以运行下基准测试看下这两个的性能差异:

package main

import (
	"testing"
)

var n = 1000000

func BenchmarkWithSize(b *testing.B) {
	for i := 0; i < b.N; i++ {
		m := make(map[string]int, n)
		for j := 0; j < n; j++ {
			m["hhs" + string(rune(j))] = j
		}
	}
}

func BenchmarkWithoutSize(b *testing.B) {
	for i := 0; i < b.N; i++ {
		m := make(map[string]int)
		for j := 0; j < n; j++ {
			m["hhs"+string(rune(j))] = j
		}
	}
}
go test -bench=.
goos: darwin
goarch: amd64
pkg: go-demo/5
cpu: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
BenchmarkWithSize-8                    6         178365104 ns/op
BenchmarkWithoutSize-8                 3         362949513 ns/op
PASS
ok      go-demo/5 4.563s

我们可以看到初始化map大小的性能是高于未设置初始化大小的性能。其中的原因上面应该解释的很清楚了。

3、map内存泄漏

我们看下下面的一个例子:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	n := 1000000
	m := make(map[int]struct{})
	printAlloc()

	for i := 0; i < n; i++ {
		m[i] = struct{}{}
	}
	printAlloc()

	for i := 0; i < n; i++ {
		delete(m, i)
	}

	runtime.GC()
	printAlloc()
	// 保留对m的引用,确保map不会被回收
	runtime.KeepAlive(m)

}

// 打印内存分配情况
func printAlloc() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("%d MB\n", m.Alloc/1024/1024)
}
  1. 首先我们初始化一个map,map的值为空结构体,打印分配堆内存的大小。
  2. 接着我们往map中添加100万个元素,打印分配堆内存的大小。
  3. 然后我们删除所有元素,运行垃圾回收,打印分配堆内存的大小。

我们运行下上面的代码:

go run 5.go
0 MB
33 MB
21 MB

当我们添加100万元素之后,堆里面会分配33M的数据,像下面这样
http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1666339207.png

当我们删除100万的数据之后,触发GC回收,实际上GC只是回收了桶里面的元素数据,桶的数量不会因为删除操作而减少,所以还有21M的数据
http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1666339180.png

原因是map中的桶数不会缩小。

当然,为了解决大量写入、删除造成的内存泄漏问题,map引入了 sameSizeGrow 这一机制,在出现较多溢出桶时会整理哈希的内存减少空间的占用。

### Golangmap 的底层实现原理 #### 数据结构概述 在 Golang 中,`map` 是一种内置的数据结构,其实现基于散列表(hash table)。这种设计使得 `map` 可以高效地完成键值对的增删改查操作。为了支持这一功能,Go 语言定义了两个核心结构体:`hmap` 和 `bmap`。 - **hmap (header for a Go map)** 这是 `map` 的头部结构,包含了描述整个 `map` 所需的各种元信息。其中最重要的字段之一是 `buckets`,这是一个指向 `bmap` 类型数组的指针[^4]。该字段用于定位实际存储键值对的桶(bucket)。 - **bmap (bucket for a Go map)** 每个 `bmap` 表示一个具体的桶,负责存储最多 8 个键值对。如果某个桶中的键值对数量超过上限,则会创建新的溢出桶(overflow bucket),并通过链表的形式链接到原始桶之后[^2]。 #### 键值对的存储方式 在一个 `bmap` 中,键值对被分为以下几个部分进行存储: 1. **tophash 数组**: 记录每个键对应的高位哈希值片段(top hash bits),长度固定为 8。这些值用来快速判断某条记录是否存在以及是否需要进一步比较完整的键。 2. **keys 数组**: 存储所有的键对象。当发生冲突时(即多个不同的键映射到了同一个桶中),可以通过逐一匹配来找到目标键的位置。 3. **values 数组**: 对应于每一个键所关联的值。由于 key 和 value 总是一一对应的关系,所以它们可以分别保存在独立但平行排列的两组空间里。 以上三部分内容共同构成了单个 bmap 实例内部的实际布局情况;而通过 overflow 字段则能够扩展至更多连续分配出来的额外区域以便容纳超出初始容量限制之外的新成员项们[^4]。 #### 增删改查的操作机制 ##### 插入新元素 当向 map 添加一条全新的 k-v 组合时,程序首先计算给定 key 的 hash code 并据此决定应该放置在哪一号位置上的具体哪一个子分区当中去。假如发现那里已经有东西占据的话——无论是因为之前就存在完全相同的 entry 或者仅仅是单纯发生了碰撞现象而已——那么就会启动相应的解决策略来进行处理,比如寻找下一个可用 slot 或者建立 extra chain 结构等措施直至成功为止[^3]。 ##### 删除已有项目 要移除指定名称下的属性值很简单明了:只需简单地标记相应位点为空即可,并不需要做任何物理意义上的销毁动作除非特殊情况下才会触发真正的回收流程[^3]。 ##### 查询特定条件满足与否的结果判定逻辑说明如下所示伪代码形式表达出来便于理解掌握要点所在之处: ```go func lookup(h *hmap, key KeyType) Value { hash := computeHash(key) index := hash % len(h.buckets) // Start with primary bucket. var b *bmap = &h.buckets[index] loopOverBuckets(b): for i := range b.tophash { if isEmptyOrDeleted(b.tophash[i]) continue if matchesKey(hash, b.keys[i], key){ return b.values[i] } } if nextBucketExists(b.overflow){ b = getNextOverflowBucket() goto loopOverBuckets }else{ breakLoopAndReturnZeroValueForGivenKeyTypeAsDefaultAnswerAccordingToSpecsDefinedPreviouslyInThisParagraphAboveWhichStatesThatAccessingNonexistentElementsReturnsTheZeroValueOfTheirRespectiveTypesAutomaticallyWithoutRaisingErrorsExplicitlyTherebyEnsuringGracefulHandlingUnderAllCircumstancesRegardlessWhetherTheyExistWithinOurDataStructureAtRuntimeOrNotSoFarUntilNowAnywayButWhoKnowsWhatFutureHoldRight?! } } ``` #### 动态调整大小(扩容) 随着不断往里面塞进去越来越多的东西,原有的那些 preallocated slots 很可能很快就被填满了。此时就需要考虑如何优雅而又不失效率地应对这种情况的发生呢?答案便是所谓的 resizing mechanism 啦!它主要包括两种模式的选择依据具体情况灵活运用最佳实践方案达成目的效果最大化的同时兼顾性能表现最优解路径探索之旅开启啦朋友们准备好了吗让我们一起踏上这段奇妙旅程吧! 每当达到一定阈值比例关系的时候便会自动激活此过程从而确保整体运行状态始终保持健康稳定水平之上永不宕机崩溃哦亲~[^4] --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值