大家好,今天,我们将深入探讨 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
}
map 哈希冲突
当有两个或以上数量的键被哈希到了同一个 bucket 时,我们称这些键发生了冲突。Go 使用链地址法来解决键冲突。由 于每个 bucket 可以存放 8 个键值对,所以同一个 bucket 存放超过 8 个键值对时就会再创建一个键值对,用类似链表的 方式将 bucket 连接起来。
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 时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。触发扩容的条件有二个:
- 负载因子 > 6.5 时,也即平均每个 bucket 存储的键值对达到 6.5 个。
- 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。
当第 8 个键值对插入时,将会触发扩容,扩容后示意图如下:
hmap 数据结构中 oldbuckets 成员指身原 bucket,而 buckets 指向了新申请的 bucket。新的键值对被插入新的 bucket 中。后续对 map 的访问操作会触发迁移,将 oldbuckets 中的键值对逐步的搬迁过来。当 oldbuckets中的键值对全部搬迁完毕后,删除 oldbuckets。
搬迁完成后的示意图如下:
等量扩容
所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对 重新排列一次,以使 bucket的使用率更高,进而保证更快的存取。在极端场景下,比如不断的增删,而键值对正好集 中在一小部分的 bucket,这样会造成 overflow 的 bucket 数量增多,但负载因子又不高,从而无法执行增量搬迁的情况。
overflow 的 buckt 中大部分是空的,访问效率会很差。此时进行一次等量扩容,即 buckets 数量不变, 经过重新组织后 overflow 的 bucket 数量会减少,即节省了空间又会提高访问效率。如下图所示:
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千练】,更多干货文章等你来看!