介绍一下 map!
在 Go 语言中,map 是一种引用类型并且性能非常高。在进行单个键值对的查找、插入、删除操作时,map的时间复杂度为 O(1)。
-
map 在内存使用方面比较高效。因为 map 的底层实现是哈希表,它可以动态地调整桶的数量,以保证哈希表的效率。
-
map 的值可以是任意类型,包括基本类型和结构体类型,十分灵活。
但由于 map 需要维护哈希表的结构和每个桶中的键值对列表,当 map 中存储的数据量很大时,可能会对内存造成较大的压力,内存使用量比较高。
并且 map 中的键必须是可以比较的类型,例如整数、浮点数、字符串、指针等。如果键的类型不支持比较操作,那么就不能用作 map 的键。
map 的基本用法
make函数的语法如下:
make(map[KeyType]ValueType, initialCapacity)
其中,KeyType 是键的类型,ValueType 是值的类型,initialCapacity 是 map 的初始容量。如果不指定 initialCapacity,那么 map 会根据需要自动扩容。
可以使用以下方式对 map 进行操作:
- 插入键值对:m[key] = value
- 获取键对应的值:value = m[key]
- 删除键值对:delete(m, key)
- 判断键是否存在:value, ok := m[key]
map 的遍历
在 Go 语言中,可以使用 for range 语句来遍历map中的所有键值对,例如:
m := map[string]int{"apple": 1, "banana": 2, "orange": 3}
for key, value := range m {
fmt.Println(key, value)
}
上面的代码会遍历 map 中所有的键值对,并将键和值分别赋值给 key 和 value 变量。
需要注意的是,在遍历 map 时,键值对的顺序是随机的,因为 map 中的键值对是无序存储的。
map 的底层实现
Go 语言中,map 的底层实现是散列表(hash table)。
散列表是一种常用的数据结构,可以在 O(1) 的时间内进行插入、查找和删除操作。
在 map 中,每个键都会被哈希为一个唯一的哈希值,然后将哈希值与桶(bucket)的数量取模来得到桶的索引,最后将键值对存储在对应的桶中。如果多个键被哈希为相同的哈希值,那么它们会被存储在同一个桶中,形成一个链表。
当 map 的容量不足时,Go 语言会自动扩容 map。扩容时会创建一个新的桶数组,然后将所有的键值对重新哈希到新的桶中。在重新哈希的过程中,如果多个键被哈希为相同的哈希值,那么它们仍然会被存储在同一个桶中,但是它们在桶中的相对顺序可能会发生改变。
哈希冲突
Go 语言中的哈希表实现使用了开放寻址法来解决哈希冲突。哈希表的实现是由 runtime 包提供的,主要包括以下几个结构体:
- hmap:哈希表的主要结构体,包括哈希表的元素数量、负载因子、桶的数量、哈希种子等信息。
- bmap:桶的结构体,用于存储哈希表中的键值对,包括哈希值和键值对数组。
- bucket:键值对的数组,用于存储键值对。
当某个桶已经有键值对时,会从当前桶开始往后查找空闲的桶,直到找到一个空闲桶或者遍历到哈希表的末尾为止。如果找到了空闲桶,则将键值对插入到该桶中;否则,会扩容哈希表,以增加桶的数量。
代码如下:
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 计算哈希值
hash := t.hasher(key, uintptr(h.hash0))
// 计算哈希表中桶的数量
bucket := hash & bucketMask(h.B)
// 获取桶的指针
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucket.size)))
// 遍历桶中的所有键值对
for i := uintptr(0); i < bucketCnt; i++ {
// 获取键值对的指针
x := add(unsafe.Pointer(b), i*t.bucket.stride)
// 判断是否为空闲桶
if t.indirectkey {
if *(*unsafe.Pointer)(x) == nil {
// 将键插入到空闲桶中
*(*unsafe.Pointer)(x) = key
// 返回值的指针
return add(x, t.keysize)
}
} else {
if t.key.equal(key, *(*unsafe.Pointer)(x)) {
// 键已经存在,更新值
return add(x, t.keysize)
}
if isEmpty((*unsafe.Pointer)(x)) {
// 将键插入到空闲桶中
*(*unsafe.Pointer)(x) = key
// 返回值的指针
return add(x, t.keysize)
}
}
}
// 没有空闲桶,进行扩容操作
growWork(t, h, bucket)
// 重新计算桶的指针
b = (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucket.size)))
// 将键插入到空闲桶中
x := unsafe.Pointer(uintptr(add(unsafe.Pointer(b), 0)) + bucketCnt*t.bucket.stride)
*(*unsafe.Pointer)(x) = key
// 返回值的指针
return add(x, t.keysize)
}
并发安全性
map 在并发读写时可能会出现竞态条件(race condition),需要注意并发安全的问题。
常见的做法是使用 sync 包中的 Mutex 类型来进行同步。在对 map 进行读写操作时,需要先获取互斥锁,然后再释放锁。
以下是一个使用互斥锁保证 map 并发安全的示例:
var m = make(map[string]int)
var mu sync.Mutex
func set(key string, value int) {
mu.Lock()
defer mu.Unlock()
m[key] = value
}
func get(key string) int {
mu.Lock()
defer mu.Unlock()
return m[key]
}
set 函数和 get 函数都使用了互斥锁进行同步。
另一种保证 map 并发安全的做法是使用 Go 语言中的 sync 包中的 RWMutex 类型,它支持多个 goroutine 同时读取map,但是只允许一个 goroutine 写入map。
扩容
回到开头我们说:map 会根据需要自动扩容
和 Java 一样,当负载因子达到 0.75 之后就会触发扩容:
-
创建一个新的哈希表,将其桶的数量翻倍,并将元素数量设置为 0。
-
遍历旧哈希表中的所有桶,将每个桶中的键值对插入到新哈希表中。在插入过程中,如果新哈希表中的桶已经有键值对,则会进行开放寻址的操作来寻找空闲的桶。
-
释放旧哈希表中的所有桶,并将旧哈希表的指针指向新哈希表。此时,扩容操作完成