Goland速通:数据结构:Map

笔者使用的Golang版本为1.18.10

基础用法

初始化

Map在使用前需要初始化,否则会出现Panic,如下创建了一个Key和Value类型都为string的map对象,但是没有初始化:

go 复制代码
var m map[string]string
m["name"] = "kinghno3" // out: panic: assignment to entry in nil map

多种初始化方法:

go 复制代码
m1 := make(map[string]string, 10) // 使用make初始化,并设置Map容量为10
m2 := map[string]string{} // 使用大括号初始化
m3 := map[string]string{"name":"kinghno3","age":"18"} // 赋初始值

读写

写入Map:

lua 复制代码
m["key"] = "value" // m类型为map[string]string

读取Map:

go 复制代码
value1 := m1["key"] // 一个接收参数,取m1中键为"key"的值,如果没有该值,则value为map值类型的零值
value2,ok := m2["key"] // 两个接收参数,value2为取到的值,ok为bool类型,true代表取到该值,false代表m2中无该值

拷贝

浅拷贝

浅拷贝则是拷贝对同一个底层Map数据结构体的引用,比如直接赋值:

go 复制代码
m1 := map[string]interface{}{"num": 1}
m2 := m1
m2["num"] = 2 // 改变m2的值同时也相当于改变了m1的值
log.Println(m1["num"]) // out: 2

深拷贝

深拷贝则是将底层数据也拷贝一份:

go 复制代码
m1 := map[string]interface{}{"one": 1, "two": 2, "three": 3}
m2 := make(map[string]interface{})
for k, v := range m1 {
    m2[k] = v
}
log.Println(m2) // out: map[one:1 three:3 two:2]
m2["one"] = "one" // 即便改变了m2的键值对,也不会对m1有影响
log.Println(m1) // out: map[one:1 three:3 two:2] 
log.Println(m2) // out: map[one:one three:3 two:2]

如果Map中的值本身就是指针,那么这种方式也只能拷贝指针,而不是拷贝最终指向的底层数据。

遍历

Map可以通过for range对每个键值对都进行遍历:

go 复制代码
m1 := map[string]interface{}{"one": 1, "two": 2, "three": 3}
for k, v := range m1 {
    log.Printf("key: %s, value: %+v", k, v)
}
// out:
// key: three, value: 3
// key: one, value: 1
// key: two, value: 2

需要注意map的遍历并不是固定顺序

删除

go 复制代码
m := map[string]string{"name": "kinghno3","age": "18"}
delete(m, "name")
log.Printf("m: %+v",m) // out: m: map[age:18]

数据结构

Map实际上为一个hmap,hmap的结构如下:

go 复制代码
// A header for a Go map.
type hmap struct {
	count     int // Map中元素数量
	flags     uint8
	B         uint8  // 代表桶的数量,数量为2^B
	noverflow uint16 // 溢出桶的数量,当桶较多时为一个估算值
	hash0     uint32 // 用于计算哈希值

	buckets    unsafe.Pointer // 指向桶空间的指针
	oldbuckets unsafe.Pointer // 指向旧桶的指针,当为nil时代表扩容结束
	nevacuate  uintptr        // 扩容进度,下一个要扩容的桶

	extra *mapextra // 存储溢出桶的信息
}

存储桶的结构如下:

go 复制代码
// 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.
}

tophash为一个uint8数组,存储该桶每个Key的哈希值的高八位,用于快速匹配。

后面的存储空间则跟着bucketCnt个key和bucketCnt个value。key和value各自放在一起,而不是交错放置,这样就避免了key和value占用存储空间不同导致的内存对齐。内存对齐会导致空间的浪费。

最后的位置则放了一个bmap指针,指向一个溢出桶。溢出桶的结构和存储桶相同。

示意图:

溢出桶

溢出桶的存在是为了减少Map扩容的次数,因为每次扩容都意味着要迁移大量的数据。

当存储桶中元素存满,但是新存入的元素依然需要存到该存储桶时,就会存入该存储桶指向的溢出桶中。

扩容

Map用于存储键值对的结构为桶,当当前桶存放量达到阈值后,将会开辟新的空间作为桶空间存储键值对。Map的扩容为渐进式扩容,也就是扩容动作分多次完成,当旧桶不再使用时,代表迁移完成。

扩容时机

第一种情况 :当存储的键值对数量count与桶数2^B之比大于loadFactor时,就会发生扩容。loadFactor目前为6.5,此时的扩容策略为翻倍扩容

相关代码:

go 复制代码
func overLoadFactor(count int, B uint8) bool {
	return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

第二种情况 :当溢出桶数量>=存储桶数量时,进行扩容。此时扩容策略为等量扩容。 这种情况会出现在每个桶的元素非常分散的时候,比如插入大量元素再删除大量元素,会导致大量的空间浪费,而且查询元素时耗时增加,因此通过等量扩容将分散的元素重新密排,提高空间利用率和查询效率。

go 复制代码
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
	// 调整B值,如果大于15,则桶数大于2^15,超出了uint8的范围
	if B > 15 {
		B = 15
	}
	// uint16(1)<<(B&15)为桶数
	return noverflow >= uint16(1)<<(B&15)
}

翻倍扩容

翻倍扩容时,当前的Bucket会挂载到hmap.oldBucket上,而新开辟的Bucket会挂载到hmap.bucket上。开辟和挂载并没有执行将数据从oldBucket迁移到bucket的过程,真正的迁移过程发生在写入、删除Map元素的时候。

写入和删除的元素(记作元素E)如果落在迁移过程的桶,那么会先进行迁移过程,迁移完该桶后再执行一次迁移桶操作:

scss 复制代码
func growWork(t *maptype, h *hmap, bucket uintptr) {
	// bucket为通过元素E.key对应的hash值算出的桶编号idx
  // bucket & h.oldbucketmask() 意为找到idx所对应的旧桶编号
  // 完成该旧桶的迁移工作
	evacuate(t, h, bucket&h.oldbucketmask())

	// 再主动迁移一个桶
	if h.growing() {
		evacuate(t, h, h.nevacuate)
	}
}

等待nevacuate达到新桶的数量时,代表所有桶迁移完毕,此时将hmap.bucket置为nil,清除掉老的溢出桶。

迁移规则

桶数恒为2的倍数,桶编号为0 ~ 2^B-12^B-1在二进制下为111...1111的形式。

假如现在桶数为8,那么一个key的hash值末尾三位为110的元素,应该落在110 & 111 = 110也就是编号为6的桶中。

翻倍扩容时,桶6需要分流到新创建的两个桶中,如何确定这两个桶的编号呢?

桶数翻倍后,最大的桶编号为15,也就是1111。那么在决定桶编号时就需要看末四位。

桶6的二进制为110,那么需要增加一位到110之前,桶中不同的key对应不同的hash值,因此倒数第四位可以为0也可以为1,最终末四位得到的数就为要分流到的目标桶编号(01101110),也就是桶6和桶14.

等量扩容

等量扩容发生在溢出桶太多的时候。

旧桶迁移到新桶时,桶编号不会变动,但是元素迁移后会更加紧凑。

并发

Go原生Map不支持并发读写,需要搭配锁等并发安全机制使用。

或者可以使用sync.Map代替。

后续

接下来会更新Channel、Gouroutine这些在Golang中最核心的概念和结构体。

对Map部分有疑问、补充、修正都欢迎提出,共同学习

相关推荐
安的列斯凯奇3 小时前
SpringBoot篇 单元测试 理论篇
spring boot·后端·单元测试
架构文摘JGWZ4 小时前
FastJson很快,有什么用?
后端·学习
BinaryBardC4 小时前
Swift语言的网络编程
开发语言·后端·golang
邓熙榆4 小时前
Haskell语言的正则表达式
开发语言·后端·golang
专职7 小时前
spring boot中实现手动分页
java·spring boot·后端
Ciderw7 小时前
Go中的三种锁
开发语言·c++·后端·golang·互斥锁·
m0_748246358 小时前
SpringBoot返回文件让前端下载的几种方式
前端·spring boot·后端
m0_748230448 小时前
创建一个Spring Boot项目
java·spring boot·后端
卿着飞翔8 小时前
Java面试题2025-Mysql
java·spring boot·后端
C++小厨神8 小时前
C#语言的学习路线
开发语言·后端·golang