开始前,会这些就够了
- 知道
Map的 put、get、containsKey分别做什么。 - 能读懂接受两个参数的 Lambda。
- 先读
List、Set、Map:先看业务,再选集合:先区分键是否存在与读取到什么值,再理解 merge 的各个分支。
你想合并一份计数表,遇到不想更新的条目,就让 merge 的回调返回 null。运行后发现,原本的键也不见了。明明没有直接调用 remove,为什么会删掉数据?
原因在于 merge 不是简单地把回调结果原样存进去。这个方法给 null 约定了特殊含义,而且“旧值为 null”“新增值为 null”“计算结果为 null”走的是不同路径。
把例子带到自己的电脑上
下载完整练习包
需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- Main.java:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main解压后,在含 Main.java 的目录打开终端。命令分别回车执行。固定输入的独立 Java 17 程序,双 JDK 实测;互动区只回放记录。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
六组状态对照:null 放在哪个位置
正在准备实验控件…
回放完整 Java 程序的固定输出,Microsoft OpenJDK 17.0.20.1 与 21.0.12 一致。浏览器没有运行 HashMap;每组都是新表、单线程。
先把三个位置的 null 分开观察
本例只有一个键 item,每组都重新创建 HashMap。已有 item=2,传入 1,回调返回 null:回调执行一次,merge 返回 null,containsKey 变成 false。它删除了映射,并没有留下 item=null。
同样的旧值与新增值,回调返回旧值时保留 2;返回 old + added 时变成 3。于是,“跳过这次更新”的表达可以是返回旧值,而不是把 null 当成“什么都不做”。
再看两个容易混淆的初始状态:从未放入 item,以及已经 put(item, null)。这两组的 containsKey 起点不同,但传入 1 后都直接得到 item=1,回调执行次数都是 0。即使这个回调一旦执行就会返回 null,此时也不会触发删除。
把 merge 看成一个小分岔,而不是固定调用回调
对这里的非空新增值,第一步检查有没有可供合并的非空旧值。没有,就直接采用新增值;有,才把旧值与新增值交给回调。回调交回普通值,就保存它;交回 null,就移除这一项。
可以把它想成仓库里的合并单:没有可合并的旧数量时,先把新数量记下来;已有旧数量时,才请规则决定新数量。规则返回的 null 是这张单据约定的“撤销这一项”,不是空白备注。
因此,写回调之前先明确三个问题:新增值是否合法?哪些状态会执行回调?回调的每一种返回值会改变什么?只盯着 Lambda 内部,很容易漏掉 Lambda 根本没有被调用的分支。
HashMap 能存 null,不代表 merge 能接收 null 新值
第六组保留 item=2,却把 merge 的新增值参数设为 null。本次立即得到 NullPointerException,回调次数为 0,旧映射仍是 item=2。这个结果与 HashMap.put 允许空值并不冲突:两个方法的参数约定不同。
这也是排查空指针时的一个线索。若回调里第一行的断点都没有命中,应检查调用参数和入口检查,而不是先认定回调内部访问了空对象。完整程序把异常类型、回调次数、调用前后状态放在同一条输出中。
不要用“既然这个集合允许 null”推导它所有便捷方法都接受任意位置的 null。方法名相近,也可能给空值安排不同语义。本文只验证 HashMap 上的这些 merge 路径,不把结果扩张到所有 Map 实现的全部方法。
区分“键没有了”和“键还在但没有值”
对 HashMap 来说,get(item) 返回 null 可能表示键不存在,也可能表示键存在且值就是 null。检查状态时,本例同时记录 containsKey 与 get,才能看出 absent 和 present-null 的区别。
例如,一份状态表可能用“键不存在”表示从未登记,用“键存在、值为空”表示已登记但尚未填写。如果你把这种表交给 merge,它会把空旧值按无需合并处理;这是否符合业务,需要在调用前决定。
若空值背后承载明确业务状态,优先把状态表示清楚,例如使用具有明确字段的对象。不要让同一个 null 同时承担“未知”“忽略”“删除”三种含义,再依靠调用者猜测。
保存完整程序,再核对输出
输出中的 returned 是 merge 的返回值;发生异常的组没有正常返回,程序保留 null 占位,应结合 exception 列阅读。calls 是本次单线程观察计数器。六组场景包含内部断言,任何关键状态不符合预期都会直接失败。
2026-10-26,分别在 Microsoft OpenJDK 17.0.20.1 与 21.0.12 上使用 --release 17 编译并运行,两个版本输出一致。互动区回放这些固定记录;浏览器没有启动 JVM。程序没有联网、连接数据库或依赖第三方库。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.HashMap;
import java.util.Map;
public class Main {
static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static void run(String mode, boolean hasKey, Integer oldValue,
Integer incoming, String action) {
Map<String, Integer> map = new HashMap<>();
if (hasKey) map.put("item", oldValue);
int[] calls = {0};
Integer returned = null;
String exception = "none";
try {
returned = map.merge("item", incoming, (old, added) -> {
calls[0]++;
return switch (action) {
case "remove" -> null;
case "keep" -> old;
default -> old + added;
};
});
} catch (NullPointerException error) {
exception = error.getClass().getSimpleName();
}
if (incoming == null) {
check(exception.equals("NullPointerException") && calls[0] == 0,
"null incoming value must fail before callback");
check(map.get("item").equals(oldValue), "old value must remain");
} else if (oldValue == null) {
check(calls[0] == 0 && map.get("item").equals(incoming),
"absent or null old value must use incoming directly");
} else if (action.equals("remove")) {
check(calls[0] == 1 && returned == null && !map.containsKey("item"),
"null callback result must remove mapping");
} else {
int expected = action.equals("keep") ? oldValue : oldValue + incoming;
check(calls[0] == 1 && returned == expected && map.get("item") == expected,
"non-null callback result must become the value");
}
System.out.printf("{\"mode\":\"%s\",\"beforeHas\":%s,\"beforeValue\":%s,"
+ "\"incoming\":%s,\"action\":\"%s\",\"calls\":%d,\"returned\":%s,"
+ "\"afterHas\":%s,\"afterValue\":%s,\"exception\":\"%s\"}%n",
mode, hasKey, oldValue, incoming, action, calls[0], returned,
map.containsKey("item"), map.get("item"), exception);
}
public static void main(String[] args) {
run("absent", false, null, 1, "remove");
run("present-null", true, null, 1, "remove");
run("remove", true, 2, 1, "remove");
run("keep", true, 2, 1, "keep");
run("add", true, 2, 1, "add");
run("incoming-null", true, 2, null, "add");
}
}
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。固定输入的独立 Java 17 程序,双 JDK 实测;互动区只回放记录。
运行后对照结果
{"mode":"absent","beforeHas":false,"beforeValue":null,"incoming":1,"action":"remove","calls":0,"returned":1,"afterHas":true,"afterValue":1,"exception":"none"}
{"mode":"present-null","beforeHas":true,"beforeValue":null,"incoming":1,"action":"remove","calls":0,"returned":1,"afterHas":true,"afterValue":1,"exception":"none"}
{"mode":"remove","beforeHas":true,"beforeValue":2,"incoming":1,"action":"remove","calls":1,"returned":null,"afterHas":false,"afterValue":null,"exception":"none"}
{"mode":"keep","beforeHas":true,"beforeValue":2,"incoming":1,"action":"keep","calls":1,"returned":2,"afterHas":true,"afterValue":2,"exception":"none"}
{"mode":"add","beforeHas":true,"beforeValue":2,"incoming":1,"action":"add","calls":1,"returned":3,"afterHas":true,"afterValue":3,"exception":"none"}
{"mode":"incoming-null","beforeHas":true,"beforeValue":2,"incoming":null,"action":"add","calls":0,"returned":null,"afterHas":true,"afterValue":2,"exception":"NullPointerException"}官方约定、参考实现与并发边界
Java 17 的 Map.merge 方法说明给出上述分支约定。阅读 OpenJDK 17u 的固定标签源码,还能看到 HashMap.merge 在入口拒绝空新增值、仅在旧值非空时调用回调,并在空计算结果时移除节点。
源码参考标签是 jdk-17.0.17+10,与本机 Microsoft 发行版的版本号分开记录。本文只做单线程实验;HashMap 的 merge 不会因为名字叫“合并”就自动提供线程安全。回调也不应在计算途中修改同一张表。
记住这些要点
- merge 回调返回
null表示移除映射,不是忽略更新。 - 键缺失或旧值为
null时,直接采用非空新增值,回调不执行。 HashMap.put允许null,不等于 merge 的新增值参数允许null。- 用
containsKey与 get 一起区分缺失键和空值映射。
别把例子用过头
- 把返回
null当成保留原值。 - 假定 merge 每调用一次,回调必执行一次。
- 只看 get 的结果,分不清删除和保存空值。
- 因为一行 merge 替代了几行判断,就认为它在
HashMap上具备并发原子性。
自己试一小步
一张商品计数表需要“新增、累加、保留、删除”四种动作,你怎样让每一种动作的意图都能被测试?
- 给程序加入回调返回 0 的场景,先预测键是否仍然存在。
- 新增一个尚不存在的键,给它同样的“返回
null”回调,记录回调是否真的执行。 - 写出你希望“跳过更新”时采用的返回值,同时检查
containsKey与最终值。
做到这些,就算完成
- 每个场景新建
Map,不让前一轮的删除影响后一轮。 - 同时核对回调次数、异常、返回值和键状态。
- 把新加入的输入实际运行后再记录,不能把预测当成实测。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17 Map.merge:空值与移除约定 (新标签页)↗
缺失或空旧值直接采用新值,空回调结果移除映射,新值参数必须非空。
- OpenJDK 17u 固定源码:HashMap.merge (新标签页)↗
入口非空检查、旧值分支、
remappingFunction调用与removeNode路径。