map
的底层数据结构是什么样的?Go 语言中的map底层是基于哈希表实现的。其核心数据结构为hmap,在这个结构里包含了一个由桶(bucket)组成的数组。每个桶实际上是一个bmap结构,它能够存储 8 个键值对。
就好比一个巨大的文件柜。这个文件柜被分成了许多小格子,每个小格子被称作 “桶”(bucket)。每个桶最多可以存放 8 个键值对,这就如同每个小格子最多能放 8 份文件一样。当你要存储一个键值对或者查找一个键对应的值时,Go 会先通过哈希函数计算出键的哈希值,然后依据这个哈希值来确定该键值对应该存放在哪个桶或者从哪个桶中查找。
hmap 结构体
├── count // 键值对总数
├── B // 桶数组长度为 2^B
├── buckets // 桶数组(元素类型为 bucket)
│ ├── bucket[0]
│ │ ├── tophash[8] // 存储哈希值的高8位(快速筛选)
│ │ ├── keys[8] // 存储8个键
│ │ ├── values[8] // 存储8个值
│ │ ├── overflow // 溢出桶指针(如果超过8个键值对)
│ ├── bucket[1]
│ ├── ...
└── oldbuckets // 扩容时的旧桶数组
计算 key 的哈希值 → hash 通过 hash 的低 B 位 → 确定桶索引 i 通过 hash 的高 8 位 → 快速筛选桶内可能匹配的键 遍历桶内 keys 数组,用 == 精确比较 key 找到匹配的键 → 返回对应 values 数组中的值 没找到 → 通过 overflow 指针继续遍历溢出桶
key = "apple"
,下面是具体的步骤:假设通过哈希函数计算得到 “apple” 的哈希值为:
hash = 0x123456789ABCDEF0
由于 B = 2,所以取 hash 的低 2 位:
低2位 = 0b10 → 对应十进制的2
因此,桶索引i = 2,也就是要查找bucket[2]
取出 hash 的高 8 位:
高8位 = 0x12
接着查看 bucket [2] 的 tophash 数组:
bucket[2].tophash = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0]
可以看到,tophash [0] 的值为 0x12,和我们计算得到的高 8 位相同,这表明对应的键可能是匹配的,所以需要进一步验证。
查看 bucket [2] 的 keys 数组:
bucket[2].keys = ["apple", "banana", "cherry", "date", "elderberry", "fig", "grape", "honeydew"]
从索引 0 开始遍历 keys 数组,将每个键与 “apple” 进行比较:
查看 bucket [2] 的 values 数组:
bucket[2].values = ["red", "yellow", "red", "brown", "purple", "purple", "green", "green"]
因为匹配的键索引是 0,所以对应的值为:
values[0] = "red"
最终返回结果:"red"
如果在 bucket [2] 的 keys 数组中没有找到匹配的键,就会通过 overflow 指针去检查与之相连的溢出桶,然后重复步骤 3 到步骤 5 的操作,直到找到匹配的键或者遍历完所有相关桶。
哈希冲突指的是不同的键经过哈希函数计算后,得到了相同的哈希值。Go 语言采用链地址法来应对这种情况。你可以把每个桶想象成一个小格子,当这个小格子已经放满了 8 份文件(键值对),而此时又有新的文件要放进来,并且也要放在这个小格子时,Go 会给这个小格子再连接一个新的小格子(溢出桶),用来存放多出来的文件。这样,即使多个键的哈希值相同,它们也能被妥善地存放,只是可能会存放在不同的溢出桶中。
m := make(map[string]string)
m["apple"] = "red" // 哈希值假设为 0x12345678
m["banana"] = "yellow" // 哈希值假设为 0x12ABCDEF(与"apple"冲突)
m["cherry"] = "red" // 哈希值假设为 0x12121212(与"apple"冲突)
bucket[2]
├── tophash: [0x12, 0, 0, 0, 0, 0, 0, 0]
├── keys: ["apple", nil, nil, nil, nil, nil, nil, nil]
├── values: ["red", nil, nil, nil, nil, nil, nil, nil]
└── overflow: nil
bucket[3]
├── tophash: [0x12, 0, 0, 0, 0, 0, 0, 0]
├── keys: ["banana", nil, nil, nil, nil, nil, nil, nil]
├── values: ["yellow", nil, nil, nil, nil, nil, nil, nil]
└── overflow: nil
bucket[2]
├── tophash: [0x12, 0x12, 0, 0, 0, 0, 0, 0]
├── keys: ["apple", "cherry", nil, nil, nil, nil, nil, nil]
├── values: ["red", "red", nil, nil, nil, nil, nil, nil]
└── overflow: nil
假设我们继续插入更多键值对,导致 bucket [2] 已满(8 个键值对),此时再插入 “date”(哈希值 = 0x12567890):
bucket[2]
├── tophash: [0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12]
├── keys: ["apple", "cherry", "date1", "date2", "date3", "date4", "date5", "date6"]
├── values: ["red", "red", "brown1", "brown2", "brown3", "brown4", "brown5", "brown6"]
└── overflow: bucket[2]_overflow
bucket[2]_overflow
├── tophash: [0x12, 0, 0, 0, 0, 0, 0, 0]
├── keys: ["date", nil, nil, nil, nil, nil, nil, nil]
├── values: ["brown", nil, nil, nil, nil, nil, nil, nil]
└── overflow: nil
假设我们要查询 “cherry”:
get
操作是如何进行的?当你使用一个键去map
中获取对应的值时,Go 会按照以下步骤操作:
举个例子,假设你有两个不同类型的键,比如KeyA
和KeyB
,它们经过哈希计算后得到了相同的哈希值。当你用KeyA
去查找时,Go 会在对应的桶中找到存储的KeyA
,并返回其对应的值,而不会把它和KeyB
混淆,因为 Go 会精确比较键的类型和值。
type KeyA struct{ ID int }
type KeyB struct{ ID int }
尽管 KeyA
和 KeyB
结构相同,但由于类型不同,Go 会将它们视为不同的键。
假设它们的哈希值均为:0x123456789ABCDEF0
m := make(map[interface{}]string)
keyA := KeyA{ID: 1} // 哈希值: 0x123456789ABCDEF0
keyB := KeyB{ID: 1} // 哈希值: 0x123456789ABCDEF0
m[keyA] = "Value for KeyA"
m[keyB] = "Value for KeyB"
假设 B = 2
(桶数量为 4),则:
0b10
→ 桶索引 i = 2
0x12
桶结构如下:
bucket[2]
├── tophash: [0x12, 0x12, 0, 0, 0, 0, 0, 0]
├── keys: [KeyA{1}, KeyB{1}, nil, nil, nil, nil, nil, nil]
├── values: ["Value for KeyA", "Value for KeyB", nil, nil, nil, nil, nil, nil]
└── overflow: nil
get
操作:m[keyA]
0x123456789ABCDEF0
0b10
→ 定位到 bucket[2]
0x12
→ 匹配 tophash[0]
和 tophash[1]
keys[0]
: KeyA
(与查询键类型相同)ID=1
(与查询键值相同)values[0]
(“Value for KeyA”)keys[1]
:类型为 KeyB
,与查询键类型不同get
操作:m[keyB]
类似地:
bucket[2]
tophash[0]
和 tophash[1]
keys[0]
:类型为 KeyA
,不匹配keys[1]
: KeyB
(与查询键类型相同)ID=1
(与查询键值相同)values[1]
(“Value for KeyB”)以下是完善后的内容,修正了迁移次数的错误,并补充了关键细节:
当出现以下两种情况时,map会触发扩容:
2^15
时,若溢出桶数量 ≥ 桶数量;2^15
时,若溢出桶数量 ≥ 2^15
。
此时扩容会创建 相同容量 的新桶数组,主要目的是 整理碎片化内存(减少溢出桶)。oldbuckets
) 和 新桶数组(buckets
)。oldbuckets
被置为 nil
,扩容完成。操作类型 | 行为逻辑 |
---|---|
查询 | 1. 先查新桶(高效)。 2. 若未找到且迁移未完成,再查旧桶对应位置。 |
插入 | 直接将新键值对插入新桶,不影响旧桶数据。 |
删除 | 1. 优先在新桶中删除。 2. 若在旧桶中找到,直接删除并可能触发迁移。 |
更新 | 1. 先查新桶,找到则直接更新。 2. 未找到则查旧桶,找到后更新并迁移该键值对到新桶。 |