深入理解 Go 语言的 map 结构

大家好,今天,我们将深入探讨 Go 语言中的一种非常重要的数据结构:map。map 在我们的日常编程中起着至关重要的作用,无论是缓存数据、搜索、查找还是数据转换,它都是我们的得力助手。

什么是 map?

在 Go 语言中,map 是一种内建的数据类型,它可以存储无序的键值对,每一个键都是唯一的,每个键都对应一个值。map 的键和值都可以是任何类型的数据,包括基本类型(如:int,string)和复杂类型(如:结构,切片)等。例如:

m1 := map[string]int{"one": 1, "two": 2, "three": 3}

map 的操作

Go 语言为 map 提供了丰富的操作,包括增加、删除、查找和修改等。这些操作都可以在常数时间内完成,这也是 map 受欢迎的一个重要原因。

map 的创建和初始化

在 Go 语言中,我们可以使用内建的 make 函数来创建一个 map:

m := make(map[string]int)

或者也可以在声明的同时进行初始化:

m := map[string]int{
  "one":   1,
  "two":   2,
  "three": 3,
}
map 的插入和修改

在 Go 语言中,我们使用同样的语法来插入和修改 map 中的元素。例如:

m := make(map[string]int)
m["one"] = 1  // 插入
m["one"] = 2  // 修改
map 的查找

在 Go 语言中,我们可以使用以下语法来查找 map 中的元素:

m := make(map[string]int)
m["one"] = 1
value, exist := m["one"]    // 查找
if exist {
  fmt.Println(value)
} else {
  fmt.Println("Key does not exist")
}
map 的删除

在 Go 语言中,我们使用内建的 delete 函数来删除 map 中的元素:

m := make(map[string]int)
m["one"] = 1
delete(m, "one")  // 删除

map 的内部结构

Go 语言的 map 的内部实现是一个哈希表。一个哈希表里可以有多个哈希表节点,也即 bucket (翻译为:哈希桶),而每个 bucket 就保存了 map 中的一个或一组键值对。

在 Go 语言的 map 中,每个键值对都存储在一个叫做 cell 的结构当中,多个 cell 会构成一个 bucket。每个 bucket 可以存储 8 个 cell。bucket 是哈希表的基本组成单位,所有的 bucket 会构成一个数组,也就是我们的 map。

map 数据结构由 runtime/map.go/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
}

// mapextra holds fields that are not present on all maps.
type mapextra struct {
	// If both key and elem do not contain pointers and are inline, then we mark bucket
	// type as containing no pointers. This avoids scanning such maps.
	// However, bmap.overflow is a pointer. In order to keep overflow buckets
	// alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
	// overflow and oldoverflow are only used if key and elem do not contain pointers.
	// overflow contains overflow buckets for hmap.buckets.
	// oldoverflow contains overflow buckets for hmap.oldbuckets.
	// The indirection allows to store a pointer to the slice in hiter.
	overflow    *[]*bmap
	oldoverflow *[]*bmap

	// nextOverflow holds a pointer to a free overflow bucket.
	nextOverflow *bmap
}

  • count: 表示当前哈希表中的元素数量;
  • flags: 代表当前 map 的状态(是否处于正在写入的状态等);
  • B: 表示当前哈希表持有的 buckets 数量,但是因为哈希表中桶的数量都 2 的倍数,所以该字段会存储对数,也就是 len(buckets) == 2^B
  • noverflow: 代表当前map中溢出桶的数量。当溢出的桶太多时,map会进行扩容,其实质是避免溢出桶过大导致内存泄露。
  • hash0: 是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入;
  • buckets: 指向当前 map 对应的桶的指针;
  • oldbuckets: 哈希在扩容时用于保存之前 buckets 的字段,它的大小是当前 buckets 的一半,当所有旧桶中的数据都已经转移到了新桶中时,则清空。
  • nevacuate: 在扩容时使用,用于标记当前旧桶中小于 nevacuate 的数据都已经转移到了新桶中。
  • extra: 存储 map 中的溢出桶。

如上图所示,哈希表 runtime.hmap 的桶是 runtime.bmap。每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。

上述两种不同的桶在内存中是连续存储的,我们在这里将它们分别称为正常桶和溢出桶,上图中黄色的 runtime.bmap 就是正常桶,绿色的 runtime.bmap 是溢出桶,溢出桶是在 Go 语言还使用 C 语言实现时使用的设计,由于它能够减少扩容的频率所以一直使用至今。

bucket 数据结构由 runtime/map.go/bmap 定义:

代表桶的 bmap 结构在运行时只列出了首个字段,即一个固定长度为8的数组,此字段顺序存储 key 的哈希值的前 8 位。

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

讲到这,脑中出现一个疑问:桶中存储的key和value到哪里去了?
在运行期间,runtime.bmap 结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对,而且 Go 语言也不支持泛型(当时不支持),所以键值对占据的内存空间大小只能在编译时进行推导。runtime.bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段,不过我们能根据编译期间的 cmd/compile/internal/gc.bmap 函数重建它的结构:

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

image.png

map 哈希冲突

当有两个或以上数量的键被哈希到了同一个 bucket 时,我们称这些键发生了冲突。Go 使用链地址法来解决键冲突。由 于每个 bucket 可以存放 8 个键值对,所以同一个 bucket 存放超过 8 个键值对时就会再创建一个键值对,用类似链表的 方式将 bucket 连接起来。
image.png

bucket 数据结构指示下一个bucket的指针称为 overflow bucket,意为当前 bucket 盛不下而溢出的部分。事实上 哈希冲突并不是好事情,它降低了存取效率,好的哈希算法可以保证哈希值的随机性。

负载因子

负载因子用于衡量一个哈希表冲突情况,公式为:负载因子 = 键数量 / bucket 数量

例如,对于一个 bucket 数量为 4,包含 4 个键值对的哈希表来说,这个哈希表的负载因子为 1.

哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行 rehash,也即键值对重新组织:

  • 哈希因子过小,说明空间利用率低
  • 哈希因子过大,说明冲突严重,存取效率低

每个哈希表的实现对负载因子容忍程度不同,比如 Redis 实现中负载因子大于1时就会触发 rehash,而 Go 则在在负载 因子达到 6.5 时才会触发 rehash,因为 Redis 的每个 bucket 只能存1个键值对,而 Go 的 bucket 可能存 8 个键值对, 所以 Go 可以容忍更高的负载因子。

渐进式扩容

扩容的前提条件

为了保证访问效率,当新元素将要添加进 map 时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。触发扩容的条件有二个:

  1. 负载因子 > 6.5 时,也即平均每个 bucket 存储的键值对达到 6.5 个。
  2. overflow数量 > 2^15 时,也即 overflow 数量超过 32768 时。
增量扩容

当负载因子过大时,就新建一个 bucket,新的 bucket 长度是原来的 2 倍,然后旧 bucket 数据搬迁到新的bucket。 考虑到如果 map 存储了数以亿计的 key-value,一次性搬迁将会造成比较大的延时,Go 采用逐步搬迁策略,即每次访问 map 时都会触发一次搬迁,每次搬迁 2 个键值对。

下图展示了包含一个 bucket 满载的 map (为了描述方便,图中 bucket 省略了 value 区域)。当前 map 存储了7个键值对,只有 1 个 bucket。此地负载因子为 7。再次插入数据时将会触发扩容操作,扩容之后再将 新插入键写入新的 bucket。
image.png

当第 8 个键值对插入时,将会触发扩容,扩容后示意图如下:
image.png

hmap 数据结构中 oldbuckets 成员指身原 bucket,而 buckets 指向了新申请的 bucket。新的键值对被插入新的 bucket 中。后续对 map 的访问操作会触发迁移,将 oldbuckets 中的键值对逐步的搬迁过来。当 oldbuckets中的键值对全部搬迁完毕后,删除 oldbuckets。

搬迁完成后的示意图如下:
image.png

等量扩容

所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对 重新排列一次,以使 bucket的使用率更高,进而保证更快的存取。在极端场景下,比如不断的增删,而键值对正好集 中在一小部分的 bucket,这样会造成 overflow 的 bucket 数量增多,但负载因子又不高,从而无法执行增量搬迁的情况。

overflow 的 buckt 中大部分是空的,访问效率会很差。此时进行一次等量扩容,即 buckets 数量不变, 经过重新组织后 overflow 的 bucket 数量会减少,即节省了空间又会提高访问效率。如下图所示:

image.png

map 的性能特点

由于 map 的内部结构是哈希表,因此它的查找、插入和删除操作的时间复杂度都是 O(1),这是 map 的一个重要优点。
但是,map 也有它的缺点。首先,map 是无序的,我们不能期待在 map 中插入元素的顺序和迭代 map 元素的顺序一致。其次,map 的空间复杂度相对较高,特别是当 map 的键和值的类型都比较复杂时,map 的内存占用会变得很大。

map 的并发安全性

在 Go 语言中,map 不是并发安全的。如果我们在多个 goroutine 中同时操作同一个 map,可能会导致程序崩溃。对于这种情况,我们通常会使用 sync.RWMutex 或 sync.Map 来保证 map 的并发安全性。

var m = make(map[string]int)
var mutex = &sync.RWMutex{}

func get(key string) int {
    mutex.RLock()
    defer mutex.RUnlock()
    return m[key]
}

func set(key string, value int) {
    mutex.Lock()
    defer mutex.Unlock()
    m[key] = value
}

这就是 Go 语言中的 map 结构的基本介绍。希望你能通过这篇文章更深入地理解 map,并能在你的编程实践中更好地应用它。

总结

Go 语言的 map 结构是一种非常强大且灵活的数据结构,它在我们的编程实践中起着极其重要的作用。通过深入理解 map 的内部结构和工作原理,我们可以更好地利用它来解决我们的编程问题。
对于任何编程语言来说,深入理解其内建数据结构和类型系统是非常重要的,因为这将直接影响我们代码的性能和正确性。希望这篇文章能帮助你更好地理解和使用 Go 语言的 map 结构。
如果您对这篇文章有任何问题或建议,欢迎在下面留言。我们会尽快回复您,感谢阅读!

请关注公众号【Java千练】,更多干货文章等你来看!
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值