qinghuan
← 回到知识探索

后端现场 / 动手实验

merge 回调返回 null,为什么整条记录消失了?

HashMap 允许保存 null,但 merge 给 null 安排了不同角色。用六组真实输出分清“没有旧值”“传入空值”和“返回删除信号”。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先把三个位置的 null 分开观察
  4. 把 merge 看成一个小分岔,而不是固定调用回调
  5. HashMap 能存 null,不代表 merge 能接收 null 新值
  6. 区分“键没有了”和“键还在但没有值”
  7. 保存完整程序,再核对输出
  8. 官方约定、参考实现与并发边界
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 知道 Map 的 put、get、containsKey 分别做什么。
  • 能读懂接受两个参数的 Lambda。
  • 先读 List、Set、Map:先看业务,再选集合:先区分键是否存在与读取到什么值,再理解 merge 的各个分支。
遇到不熟悉的词,先看这 2 个解释
重新映射函数
在 Map.merge 中,已有非空旧值时,决定这一项接下来保存什么的函数。
空值映射
键仍在 Map 中,但它对应的值为 null。
打开完整术语小抄 ↗

你想合并一份计数表,遇到不想更新的条目,就让 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 放在哪个位置

正在准备实验控件…

调用前:{ item: 2 };新增值 1。

先预测回调会不会执行、键是否还在,再查看这组实际记录。只看 get 返回 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。程序没有联网、连接数据库或依赖第三方库。

展开收起代码 · 小实验java · 58 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
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 上具备并发原子性。

自己试一小步

一张商品计数表需要“新增、累加、保留、删除”四种动作,你怎样让每一种动作的意图都能被测试?

  1. 给程序加入回调返回 0 的场景,先预测键是否仍然存在。
  2. 新增一个尚不存在的键,给它同样的“返回 null”回调,记录回调是否真的执行。
  3. 写出你希望“跳过更新”时采用的返回值,同时检查 containsKey 与最终值。

做到这些,就算完成

  • 每个场景新建 Map,不让前一轮的删除影响后一轮。
  • 同时核对回调次数、异常、返回值和键状态。
  • 把新加入的输入实际运行后再记录,不能把预测当成实测。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Java 17 Map.merge:空值与移除约定 (新标签页)↗Oracle / OpenJDK · 英文

    缺失或空旧值直接采用新值,空回调结果移除映射,新值参数必须非空。

    实际阅读 merge 的正文、默认实现步骤、参数与异常说明,以及并发保证边界;没有通读 Map 的全部方法。

  • OpenJDK 17u 固定源码:HashMap.merge (新标签页)↗Oracle / OpenJDK · 英文

    入口非空检查、旧值分支、remappingFunction 调用与 removeNode 路径。

    实际阅读 jdk-17.0.17+10 标签中的 merge 方法;这是参考实现版本,不冒充当前 Microsoft JDK 的精确源码。