一、Map的扩容过程
因为map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为渐进式迁移,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。
(如果还是没了解的话,可以先理解完Bucket的bmap的伪结构体内容) 但不会影响下面内容的理解。
二、Map的扩容机制:
双倍扩容
触发条件:装载因子(元素/桶)>6.5 ;新的bucket的数量就会翻倍。
原理就是旧的bucket 扩容到俩个新的桶上。所以采用渐进式迁移,避免一次性搬迁大量 key/value 导致的性能抖动。
渐进式迁移的触发时机是每次对 map 执行写操作(增 / 删 / 改)或部分读操作时,每次仅会搬迁1 个旧 bucket(调用evacuate函数)
等量扩容
触发条件:
情况一:当主桶指数B>15时,溢出桶数量≥2^15时——溢出桶数量 ≥ 桶总数(2^B),导致遍历需要频繁遍历空的溢出桶,效率低下;
情况二:当主桶指数B≤15时,溢出桶数量≥主桶总数(2^B)时—— map 经历大量删除操作后,桶内槽位大量空置,溢出桶链过长,遍历成本高。
触发条件的表象是负载因子<1。(元素少桶多)
原理:桶的总数量不变,重新整理数据。把溢出桶的数据整理到主桶的原序号的位置上,提升遍历和查询效率。
三、Map为什么有扩容?
双倍扩容:核心目的是降低装载因子,缓解哈希冲突。装载因子过高时。
等量扩容:核心目的是清理空桶 / 溢出桶,优化遍历效率。减少无效遍历。
四、Map遍历
首先 map是用hash表实现的,数据存bucket,而bucket可存8个键值对,bucket满了会创建溢出桶。
当触发 扩容,会把旧桶数据迁移到新桶。(渐进式迁移,遍历时新旧桶同时存在)
五、遍历顺序
1. Map的遍历是具有 随机性。
在每次Map遍历会用fastrand()生成随机数,决定两个关键值:
startbucket(从哪个bucket开始)
offset(从bucket的哪个槽位开始)
2. 扩容时遍历怎么同时处理新旧桶的数据读取
因为渐进式迁移,旧桶数据没完全迁到新桶,一个旧桶的数据 迁移到 新桶0,和新桶1。所以需要在遍历时同时结合新旧桶读取数据:
遍历先查新桶0、1的数据
情况一:在新桶0、1有数据时说明旧桶数据已经完成数据迁移,直接对新桶读取,不用读取旧桶。
情况二:查看新桶0已经完成数据迁移,直接读取;查看新桶1没有数据迁移,返回旧桶读取数据b。
3. 遍历完整流程
旧桶 0 → 对应新桶 0、新桶 2;
旧桶 1 → 对应新桶 1、新桶 3;
...
- fastrand()生成startbucket和offset来确定bucket的遍历顺序。
- 从新桶
startbucket的offset槽位开始。
- 遍历新桶0,检查该桶对应的旧桶已经完成迁移,直接读取数据。
- 继续遍历。。。
- 遍历新桶1,检查该桶对应的旧桶的迁移情况,没完成数据迁移。需同时读取旧桶中未迁移的元素 + 新桶中已迁移的元素。
- 继续遍历其他新桶。。。
- 直到下一个遍历是sartbucket时,遍历结束。
(注意:遍历过程中会跳过所有空槽位和空溢出桶)