摘要:我手写的
HashMap里有一行把 key 跟Node比较了,按值查找整个废掉------而 demo 跑得干干净净,9 个 key 一个不落地全get到了。这篇拆的是"为什么没被发现":四个掩护源、一次执行顺序的复现,以及一个连判据本身都是近似的坑。
先说结论
-
绿灯只能证明"跑过的那条路是对的" ,不能证明代码是对的。一条
if里如果有||,而||右边那半个条件没有任何用例能单独触发它,那这半个条件等于没写。 -
我的 bug 是:
key.equals(e)------e是Node,不是 key。equals永远返回false,按值查找整个失效 ,只剩引用比较key == e.getKey()在撑。 -
它没被发现,是因为四个掩护源叠在一起 ,让整条
equals路径从未被执行。绿灯不是"通过了",是"没测到"。 -
后来同类问题升级了一次形态 :从"分支没覆盖"变成"执行顺序没覆盖"------把一个方法里两行的先后顺序调换,36 个用例里只有 1 个变红。
-
更麻烦的是,判据本身也可能是近似。我当时用"元素数量变没变"判断结构性修改,用它写用例,会把两类真实修改判成"没改"。
-
连"拿 JDK 当标准答案"的对照测试都可能空转 :两边都走不到差异分支时,对照成立、测试全绿、信息量为零。救命的一招是把代码故意改坏,看谁红。
-
落到规则上是五条(第六节),但核心只有一句:能被"没测到"骗过的测试,等于没有。
一、那行错代码
手写 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 逼出来
要同时打穿三层掩护,用例得满足三个条件:
-
用新建的等值对象去查 ,不能复用
put时的引用 → 破第一层 -
key 的
& 15相等,落同一个桶 → 破第二层 -
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 当标准答案"的对照测试------逻辑上最可信的那种。它绿,是因为:
-
循环用的是
0..100的小整数,撞进Integer缓存,==恒真; -
更根本的是,它查的那些 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 源码,欢迎交流。尤其想知道:你有没有过一段从写下那天起就从没被执行过的代码?