先回答根问题:hash("cat", hash0) 在真实机器里到底是什么?
经典 Go Map 查询并不是先得到一串神秘的“高位/低位符号”,而是先得到一个真正的整数。Go 1.23 的 runtime 查询源码可以概括成 hash := t.Hasher(key, uintptr(h.hash0)):key 和这张 map 的随机种子 hash0 交给对应 key 类型的哈希函数,返回值是一个 uintptr 整数。
uintptr 的宽度跟机器指针宽度一致。在常见的 amd64、arm64 服务器和 Apple Silicon Mac 上,它是 64 位;在 32 位机器上则是 32 位。所以下面不再用“简化成 16 位”的玩具模型,而是直接按常见 64 位机器来观察。
先区分“真实类型”和“教学数值”:uintptr 是真实 runtime 类型;下面的 0xA73C91F052D86E05 是为了把位的位置画清楚而构造的一次教学示例值,不是字符串 "cat" 永远固定的 hash。真实运行时,同一个 "cat" 在不同 map 中可能因为 hash0 不同而得到不同 hash。
// 假设某次 64 位机器上的查询得到:
hash = 0xA73C91F052D86E05
// 十六进制按 8 个字节看:
A7 3C 91 F0 52 D8 6E 05
// 展开成 64 个二进制位:
10100111 00111100 10010001 11110000 01010010 11011000 01101110 00000101
^^^^^^^^ ^^^
bit 63~56 bit 2~0
高 8 位 低 3 位
从真实机器角度记:hash 是一个 uintptr 整数。64 位机器的高 8 位就是 bit 63~56;B=3 时低 3 位就是 bit 2~0。源码通过右移取最高字节,通过掩码取最低 B 位。
CPU 实际怎样“取”这两部分
在 64 位机器上,要取得最高 8 位,可以把 64 位整数整体右移 64-8=56 位:uint8(hash >> 56)。经典 runtime 的 tophash 函数本质就是这样做;如果取出的原始值小于 minTopHash=5,还会再加 5,用来避开 0~4 这些内部状态值。本例最高字节是 0xA7,本身大于 5,所以无需调整。
要取得最低 B 位,runtime 构造掩码 (1<<B)-1。当 B=3 时,掩码就是二进制 111;执行 hash & 0b111 后,只保留 bit 2~0。示例 hash 最后一个字节是 05 = 0000 0101,最后 3 位恰好是 101,也就是十进制 5。
对应到源码就是两句:bucketIndex = hash & ((1<<B)-1) 负责决定“去哪一个主桶”;top = uint8(hash >> (机器字长-8)) 负责取得最高字节作为桶内摘要。两者使用的是同一个完整 hash。
现在用一个具体例子查 m["cat"]。假设 B=3,这张 map 的 buckets 是一排 2³=8 个主桶:buckets[0]、buckets[1],一直到 buckets[7]。方括号里的数字叫数组下标,它是这排桶的位置编号,并且从 0 开始。
继续使用上面的 64 位教学 hash 0xA73C91F052D86E05。因为当前 B=3,runtime 只保留这个整数的最低 3 个 bit,也就是 bit 2~0。最后一个字节 05 展开为 0000 0101,最低 3 位就是 101。这三个位置从右向左分别代表 1、2、4,所以 101₂ = 1×4 + 0×2 + 1×1 = 5,最终选择 buckets[5]。这个 5 是数组下标;由于下标从 0 开始,它是一排 8 个主桶中的第 6 个位置。
先把这一跳说完整:B=3 → 一共有 8 个主桶 → 低 3 位 101 等于十进制 5 → 在 0~7 号桶中选中 buckets[5]。进入这个主桶以后,才轮到 tophash 筛选桶内的 8 个槽位。
最应记住:同一个完整 hash 被拆成两条线索,低 B 位选主桶,高位摘要只负责桶内初筛。
完整查询过程:让 "cat" 从入口走到结果
最应记住:查询先从 8 个主桶选中 buckets[5],再由摘要 0xA7 留下下标 1 和 3,最后比较完整 key;最常见误读是把“摘要相同”当成命中,或忽略 overflow 是否为 nil 的条件分支。
常见错误:低 B 位只负责选择 bucket;tophash 只负责桶内初筛。真正命中必须比较完整 key。直接写 v := m[key] 时,未命中得到零值;需要区分“零值”和“不存在”时要用 v, ok := m[key]。