我的 HashMap 查找全废了,但 36 个测试全绿

摘要:我手写的 HashMap 里有一行把 key 跟 Node 比较了,按值查找整个废掉------而 demo 跑得干干净净,9 个 key 一个不落地全 get 到了。这篇拆的是"为什么没被发现":四个掩护源、一次执行顺序的复现,以及一个连判据本身都是近似的坑。

先说结论

  1. 绿灯只能证明"跑过的那条路是对的" ,不能证明代码是对的。一条 if 里如果有 ||,而 || 右边那半个条件没有任何用例能单独触发它,那这半个条件等于没写。

  2. 我的 bug 是:key.equals(e) ------ e 是 Node,不是 key。equals 永远返回 false,按值查找整个失效 ,只剩引用比较 key == e.getKey() 在撑。

  3. 它没被发现,是因为四个掩护源叠在一起 ,让整条 equals 路径从未被执行。绿灯不是"通过了",是"没测到"。

  4. 后来同类问题升级了一次形态 :从"分支没覆盖"变成"执行顺序没覆盖"------把一个方法里两行的先后顺序调换,36 个用例里只有 1 个变红。

  5. 更麻烦的是,判据本身也可能是近似。我当时用"元素数量变没变"判断结构性修改,用它写用例,会把两类真实修改判成"没改"。

  6. 连"拿 JDK 当标准答案"的对照测试都可能空转 :两边都走不到差异分支时,对照成立、测试全绿、信息量为零。救命的一招是把代码故意改坏,看谁红。

  7. 落到规则上是五条(第六节),但核心只有一句:能被"没测到"骗过的测试,等于没有。


一、那行错代码

手写 MyHashMap,get 里长这样(修复前):

java 复制代码
public V get(Object key) {

    int h = hash(key);

    Node<K, V> e = table[indexFor(h, table.length)];

    while (e != null) {

        if (h == hash(e.getKey())                               // ① 重算 hashCode

                && (key == e.getKey() || (key != null && key.equals(e)))) {  // ② equals(e) ← 错在这

            return e.value;

        }

        e = e.next;

    }

    return null;

}

key 是 Integer,e 是 Node<Integer, String>。Integer.equals(Node) 一定 false,因为类型都不一样。所以整个条件等价于:

java 复制代码
h == hash(e.getKey()) && key == e.getKey()

按值查找被悄悄换成了按引用查找。

而当时的 demo:

java 复制代码
Integer[] intKeys = {65536, 65537, 65538, ...};   // 9 个

for (Integer k : intKeys) map.put(k, "v" + k);

for (Integer k : intKeys) System.out.println(map.get(k));   // 9 行全部命中

一个不落。我当时看到这个输出,判定"get 写完了"。


二、为什么没被发现:四个掩护源

这四个不是并列的"原因",是叠起来才能同时成立的四层掩护:

| 掩护源 | 具体表现 | 后果 |

| --- | --- | --- |

| 同一批引用 | intKeys 数组既拿去 put,又拿去 get | key == e.getKey() 恒真,equals 分支一次都没执行 |

| 每个 key 独占一桶 | 65536*k 扰动后落 0~8,9 个桶各 1 个节点 | while (e != null) 里的 e = e.next 一次都没走 |

| Integer 缓存 | 想用 1/17/33 造冲突?-128~127 走 Integer.valueOf 缓存,同值即同引用 | 就算造出冲突,引用比较照样直接过 |

| 旧实验残留 | 之前用 "a"~"h" 测 hash 分布,hashCode 97~104 全 < 65536 | 干扰判断,让人以为分布"验证过了" |

第三条最阴。你想加一个"冲突链"用例来补第二条,结果被第三条缴了械 ------用 1/17/33 造冲突(& 15 都是 1),put 和 get 用的还是同一批 Integer 引用,equals 依旧不执行。

第四条属于另一类:它在污染我的信心 。我以为 hash 分布测过了,实际上那次实验里扰动函数根本没参与运算(h >>> 16 对 97~104 恒为 0),测的只是取桶。


三、把 bug 逼出来

要同时打穿三层掩护,用例得满足三个条件:

  1. 用新建的等值对象去查 ,不能复用 put 时的引用 → 破第一层

  2. key 的 & 15 相等,落同一个桶 → 破第二层

  3. key > 127 ,绕开 Integer 缓存 → 破第三层

取 1000 / 1016 / 1032:

ini 复制代码
1000 = 0b1111101000   & 15 = 8

1016 = 0b1111111000   & 15 = 8

1032 = 0b10000001000  & 15 = 8

同桶、超缓存、值各不相同。put 之后用 new Integer(...) 之外的新对象去 get(Java 9+ 直接 Integer.valueOf(1000) 也行,因为 1000 不在缓存里,每次都是新对象),结果:

scss 复制代码
修复前:size = 3 ,get(1000)=null  get(1016)=null  get(1032)=null

修复后:size = 3 ,get(1000)=A     get(1016)=B     get(1032)=C

注意 size = 3 是对的------尾插没问题,只有查找全废。

这正是最危险的地方:一半功能正常,掩盖另一半全错。 如果 size 也错了,我早就去查了。偏偏错的那一半,恰好是"最容易用同一批引用糊弄过去"的那一半。


四、第二次复现:这次是执行顺序

两周后给同一个 MyHashMap 加 fail-fast(遍历中改结构抛 ConcurrentModificationException),又撞上同一类问题,但形态变了。

机制不复杂:modCount 记结构性修改次数,迭代器创建时拍个快照 expectedModCount,每次 next() 对账:

java 复制代码
private void checkForComodification() {

    if (modCount != expectedModCount) throw new ConcurrentModificationException();

}

  


public Map.Entry<K, V> next() {

    checkForComodification();              // 先对账

    if (cursor >= size) throw new NoSuchElementException();   // 再判空

    ...

}

四个 fail-fast 用例写完全绿。为了确认"这些用例真的在测 fail-fast",我把 throw 那行注释掉再跑一遍:17/19/20 三个变红,18 保持绿。

18 是"覆盖写"用例(遍历中 put 已有 key 换值),它本来就该绿------覆盖写不改条目集合。红绿对照说明用例测的是目标行为,不是别的东西。

然后我故意把 next() 里两行调换顺序(先判空再对账),再跑:

scss 复制代码
17 (遍历中 put 新 key)  → 绿

19 (遍历中 remove)      → 绿

20 (耗尽后改结构)       → 红

只有 1 个红了。 为什么?17 和 19 都在遍历中途 改结构,此刻 hasNext() 还是 true,判空那一步拦不住,后面的对账照样抛------结果一样,所以用例看不出来。只有 20 是"迭代器已经耗尽、之后才改结构",两个条件同时成立,顺序错了才会暴露:先判空会抛 NoSuchElementException,把"你改了结构"这件事掩盖掉。

所以 20 这个用例的断言不是"抛异常",而是"抛哪个异常":

java 复制代码
assertThrows(NoSuchElementException.class, it::next);   // 耗尽且没改结构

map.put(9999, "新 key");

assertThrows(ConcurrentModificationException.class, it::next);  // 两个都成立时,必须先报 CME

结论:涉及顺序的改动,必须构造"两个条件同时成立"的用例。 单独触发任一条件的用例,无论写多少个,都覆盖不到顺序本身。

顺带一个我很喜欢的设计:17 号用例连"是谁抛的"都断言了。

java 复制代码
assertEquals("checkForComodification", ex.getStackTrace()[0].getMethodName());

assertEquals("next", ex.getStackTrace()[1].getMethodName());

因为这里有个真实的责任归属问题:map.put() 会在遍历中抛异常吗?不会,抛点在 next() 。把这条写进断言,将来谁把记账挪到别处、或者谁在 put 里加一句 throw,这个用例立刻红。这比断言"抛了 CME"强得多------后者在两种实现下都成立。


五、更隐蔽的一层:判据本身也是近似

上面都还只是"用例写得不够"。真正让我后背发凉的是下面这件事。

我问自己一个问题:fail-fast 的触发条件是什么?我第一反应答"元素数量变了"。这个判据好记、好讲、能解释 90% 的现象。

然后我写了两个探针,专门去喂它:

| 探针 | 场景 | 数量 / 集合变化 | 结果 |

| --- | --- | --- | --- |

| A | 遍历中 remove(k),再 put(k, v) 塞回去 | size 3 → 2 → 3,条目集合完全复原 | 照样抛 CME |

| B | LinkedHashMap 开 accessOrder,遍历中只 get(k) 一次 | 条目集合、size 全都不变,只是顺序变了 | 照样抛 CME |

两个探针都让"数量变了"这个判据失效。B 甚至让"集合变了"也失效------什么都没增没减,只是顺序 变了,afterNodeAccess 照样 ++modCount。

精确判据是:条目集合 + 迭代顺序,任何一项变化都算结构性修改。

这件事的性质和第二节的 bug 是同一个:我用一个能解释大多数现象的近似,顶替了精确的判据。 近似判据不会报错,它只会在边界上安静地放过错误。

后来我在验证"某方法是否被调用"时也踩过同源的坑:靠肉眼看日志输出里"有没有出现 get 字样"来判断 equals 有没有被调用------而那段输出里压根没有 get 这个词。改法是让程序自证 :加计数器,直接打 equals 累计被调用次数 = 0。

能数的就别看,能断言的就别描述。


六、四条规则

第 1、2、3 条来自第二节那个 bug,第 4 条来自第四节。

规则 1:查找用例必须用"新对象",不能复用 put 时的引用。

java 复制代码
map.get(new String("k"));   // 对

map.get(k);                 // 错------这条只能测引用比较

C++/Go 里不存在这个陷阱,Java 的引用语义会让人本能地复用变量。这条是我踩坑的直接原因。

规则 2:冲突链用例的 key 必须 > 127。

Integer 缓存在 -128~127。用 1000/1016/1032 这种(同 & 15、超出缓存),才逼得出 equals。

规则 3:每个分支都要有能"看见"的断言。

桶空、表头命中、链中命中、走到尾尾插 ------ 四个分支,四个用例。少一个就是少一条腿。判据是:一条 if 里有 ||,就必须有能单独触发 || 右侧的用例。

规则 4:涉及顺序的改动,必须构造"两个条件同时成立"的用例。

单独触发任一条件的用例再多,也覆盖不到顺序。

规则 5:写完之后把代码改坏一次,看谁红。

这条是写这篇文章时现场踩出来的。我给 containsKey 补了 4 个用例,然后把实现故意改成"只比较引用"(就是本文开头那个 bug 的形态),看哪些会红:

| 用例 | 改坏后 | 为什么 |

| --- | --- | --- |

| 21 用新对象查(Integer.valueOf(1000)) | 红 | 引用不等,命不中 |

| 24 与 JDK HashMap 逐 key 对照 | 绿 | 两个原因叠加,见下 |

| 23 null key | 绿 | null == null 恒真 |

24 号是最扎心的。它是一个"拿 JDK 当标准答案"的对照测试------逻辑上最可信的那种。它绿,是因为:

  1. 循环用的是 0..100 的小整数,撞进 Integer 缓存,== 恒真;

  2. 更根本的是,它查的那些 key 我压根没 put 进表 ,两边都返回 false,对照"成立"但毫无信息量。

我把这条叫空转用例 :跑过了、断言了、绿了,但没有一秒钟接触被测行为。后来改成真的 put 100 个 > 127 的 key 再对照,改坏的实现立刻让它红了。

所以"拿 JDK 当 oracle"不是万能的,它只在输入选对时成立。 对照组与被测组之间必须有能区分实现差异的输入,否则两边一起走错,你什么也发现不了。

改坏之后,那些仍然是绿的用例,每一个都要能说出"它为什么该绿"。说不出来,它就是空转。

补一句限定:这套规则不是万能的。它对付的是"逻辑分支 + 执行顺序"这类错误。如果错的是一个前提 (比如 indexFor 里 (n-1) & h 只对 2 的幂成立,而我传了个 30 进去),任何分支和顺序的用例都抓不到------它不报错、不越界、也不进错分支,只是安静地算出错误的下标。那类问题只能靠"拿 JDK 当 oracle,比终态"来发现。


七、这次真正学到的东西

不是"要写更多用例"。我的用例数量不算少,36 个,mvn clean test 全绿(写这篇时 36 个,现在 40 个------多出来的 4 个正是补这个洞时加的)。

学到的是:全绿这件事本身,不携带任何关于"哪里没测到"的信息。 想知道哪里没测到,只能反过来问------这行代码如果写错了,哪个用例会红?

对那行 if 里的 key.equals(e) 问这个问题,答案是"没有用例会红"。它从写下那天起就是一段死代码,只不过掩盖它的是一个我每天都看到的绿色 BUILD SUCCESS。

现在我的做法是:每加一个 ||、每加一个 throw、每调换两行顺序,都先问一句"哪条用例会因为这一句写反而变红",答不上就先写用例再改代码。


最后

这篇来自我自己的 12 个月 Java 补课计划。不写概念总结,只写亲手跑出来的东西------代码在 java-transition(MyHashMap + 36 个 JUnit 用例,探针在 com/study/probe/)。

上一篇我预告的是《一个 e.next = null 的缺失,如何让 HashMap 死循环》。这篇插了队,因为它的结论更通用------那个 e.next 的故事(同一个漏写通向"死循环"和"查询全对但数据悄悄重复"两个结局)顺延到下一篇。

如果你也在啃 JDK 源码,欢迎交流。尤其想知道:你有没有过一段从写下那天起就从没被执行过的代码?

相关推荐
RAG学徒loading9 分钟前
我把 RAG 召回从 57.6% 做到 78.8%,然后发现 75% 的真实问题它答不了
后端
小番茄程序猿16 分钟前
Redis 编码压测 59 轮:HSET 快 23 倍是假象,SMEMBERS 慢 30 倍是真的
redis·后端
Moment1 小时前
代码都能让 Agent 写了,我们为什么还要学前端?
前端·后端·面试
AI入职记1 小时前
Spring AI 实测:不挂工具,模型真的会瞎编吗?
后端·ai编程
wei_shuo1 小时前
SQL Server数据迁移实战:内置故障诊断工具与根因分析实践
后端
一条Go1 小时前
告警指纹:一次故障为什么只产生一条 FIRING 记录
后端·架构·github
鶴哥只手遮天1 小时前
《Redis 8.10.3 深度剖析》系列 ·第 13 章 Set:三种编码、集合运算与随机采样
redis·后端·开源
王中阳Go1 小时前
你把重试加到 2 次那天,200 条里成功还是 181 条,你回看过吗?
人工智能·后端·架构