版本:Go SDK 1.20.6
map分别支持字面量初始化和内置函数make()初始化。
字面量初始化:
m := map[string] int {
"apple": 2,
"banana": 3,
}
使用内置函数make()初始化:
m := make(map[string]int,10) // 指定容量可以有效减少内存分配次数,有利于提升程序性能
m["apple"] = 2
m["banana"] = 3
注意
:未初始化的map变量的默认值为nil,向值为nil的map添加元素时会触发panic:assignment to entry in nil map(赋值给空的map),如:
var m map[string]int
m["apple"] = 2 // 触发panic
map的增删改查比较随意…
m := make(map[string]int,10)
m["apple"] = 2 // 添加
m["apple"] = 3 // 修改
delete(m,"apple") // 删除
v := m["apple"] // 查询
v,exist := m["apple"] // 查询
if exist {
fmt.Println(v)
}
这里有几个需要注意的地方:
内置函数len()可以查询map的长度,该长度反应map中存储的键值对数。
Go语言的map使用Hash表作为底层实现,一个Hash表里可以有多个bucket,而每个bucket保存了map中的一个或一组键值对。
map的数据结构由 runtime/map.go:hmap 定义:
type hmap struct {
count int // 当前保存的元素个数
flags uint8 // 状态标志
B uint8 // bucket 数组的大小
noverflow uint16 // 溢出桶的大概数量
hash0 uint32 // 哈希种子
buckets unsafe.Pointer // bucket 数组,数组的长度为2^B
oldbuckets unsafe.Pointer // 老旧bucket数组,用于扩容
nevacuate uintptr // 表示扩容进度,小于此地址的buckets代表已搬迁完成
extra *mapextra // optional fields
}
下图展示了一个hmap.B=2t的map。
bucket(桶)数据结构由runtime/map.go:bmap定义
type bmap struct {
tophash [bucketCnt]uint8 // 长度为8的数组
}
// 底层定义的常量
const (
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits // 一个桶最多有8个位置
)
这是我在书上看到的bucket数据结构,并做出了如下解释:
bucket数据结构中的data和overflow成员并没有显示地在结构体中声明,运行时在访问bucket时直接通过指针的偏移量来访问这些虚拟成员
type bmap struct {
tophash [8]uint8 // 存储Hash值的高8位
data []byte // key value 数据:key/key/key/.../value/value/value...
overflow *bmap // 溢出bucket的地址
}
每个bucket可以存储8个键值对
所以tophash到底有什么用?
具体来说,如果两个键的哈希值的低位相同,但高位不同,它们可能会被映射到同一个桶位置。为了区分它们,可以将高位存储在 tophash[i] 数组中。这样,在查找时,可以首先比较低位哈希值,如果相等,再比较高位,以确保正确地匹配到相应的键。
在这种情况下,当添加元素时,如果 tophash[i] 中存储的哈希值与当前 key 的哈希值不相等,可能表示哈希冲突。这时,可能需要通过线性搜索或其他冲突解决方法在当前桶中查找匹配的键。在查找的过程中,可以利用 tophash[i] 数组中的高位信息来进一步确保正确匹配。
总体而言,这种做法是一种提高哈希表性能的优化策略,通过更多的信息来区分相同低位哈希值的键,以减少哈希冲突的影响。在实现哈希表时,具体的优化方法可能会因语言或库的不同而有所不同。
当有两个或以上数量的键被“Hash”到同一个bucket时,我们称这些键发生了冲突。Go使用链地址法来解决冲突。
关于哈希冲突的详细解释可以移步我的这篇博客哈希表是什么
负载因子用于衡量一个Hash表冲突情况,公式为:
负载因子 = 键数量/bucket数量
负载因子过小或过大都不理想:
当Hash表的负载因子过大时,需要申请更多的bucket,并对所有的键值对重新组织,使其均匀地分布到这些bucket中,这个过程称为rehash。
(1)扩容条件
为了保证访问效率,降低负载因子,常用的手段是扩容,当新元素将要添加进map时,会判断是否需要扩容。
触发扩容需要满足以下任一条件:
(2)增量扩容
当负载因子过大时,就新建一个bucket数组,新的bucket数组的长度为原来的2倍,然后旧bucket数组中的数据逐步搬迁到新的bucket数组中。
增量扩容的具体过程是这样的:
1、新建桶数组: 当触发增量扩容时,Go 会创建一个新的、更大的桶数组。
2、元素迁移: 然后,它会逐步将旧桶中的元素重新分配到新的桶数组中,避免一次性大规模的重新哈希。
3、渐进迁移: 在元素逐步迁移的过程中,新添加的元素会直接被放入新的桶数组中,而不会立即迁移。这保证了新元素的添加不会在迁移期间导致性能下降。
4、逐步替换: 最终,当所有元素都成功迁移到新的桶数组后,旧的桶数组会被废弃,新桶数组取而代之,完成了增量扩容的过程。
5、这种增量方式的扩容避免了在添加元素时出现大规模的哈希冲突或性能下降,因为它避免了在一次性扩容中发生的大量元素重新哈希的操作。这种方法相对于整体性地重新哈希整个 map 来说,更加有效和高效。
扩容后示意图:
搬迁完成后示意图:
无论是元素的添加还是查询操作,都需要现根据键的Hash值确定一个bucket,并查询该bucket中是否存在指定的键。
(1)查找过程
查找过程简述如下:
如果当前map处于搬迁过程中,则优先从oldbuckets数组中查找,查找到不再从新的buckets数组中查找。
(2)添加过程
新元素的添加过程简书如下:
如果当前map出于搬迁过程中,则新元素会直接添加到新的buckets数组中,但查找过程仍从oldbuckets数组中开始
(3)删除操作
删除元素实际上是先查找元素,如果元素存在则把元素从相应的bucket中清除,如果不存在则什么也不做