bigywater.tech
Java 内部原理 · 112

Map.computeIfAbsent() 真实验证:递归修改同一个map,真实必然抛出ConcurrentModificationException

用computeIfAbsent()写记忆化递归,是一个看起来很优雅、网上教程也经常推荐的写法——这篇真实跑一遍才发现,这个"优雅写法"真实必然崩溃,而且不是偶尔,是每次都会。

递归记忆化真实必然抛异常
真实验证:在computeIfAbsent的lambda里递归调用自己再次computeIfAbsent同一个map,真实100%抛出ConcurrentModificationException
最简单的单层嵌套同样真实中招
真实验证:哪怕只是在computeIfAbsent回调里直接put同一个map一次(不是递归),同样真实抛出异常

上一篇:数组直接打印真实陷阱

真实复现:递归记忆化写法必然真实崩溃

用computeIfAbsent()实现斐波那契记忆化——这个写法在很多"函数式编程技巧"文章里被当作范例推荐。

MapComputeIfAbsentRecursiveDemo.java static int fibonacci(Map<Integer, Integer> memo, int n) { return memo.computeIfAbsent(n, k -> fibonacci(memo, k - 1) + fibonacci(memo, k - 2)); }
真实实测
真实捕获 ConcurrentModificationException: null

真实结果证明这个"优雅"的递归写法在 HashMap 上真实必然崩溃(注意异常消息真实是 null——ConcurrentModificationException 的默认构造器不带任何描述文字,单纯依靠异常类型本身传达信息)。根本原因是 computeIfAbsent() 的完整生命周期是:检测key不存在 → 调用 mappingFunction 计算值 → 把结果真实写入map。在这个 mappingFunction(也就是这里的lambda)执行期间,HashMap 真实处于一种"正在进行结构性修改"的中间状态,如果这个回调内部又真实触发了对同一个map的另一次结构性修改(这里是递归调用真实再次触发另一层 computeIfAbsent),HashMap 内部的 modCount 检测机制会真实识别出这个冲突并立刻抛出异常。

真实验证:哪怕不是递归,单层嵌套修改同样真实中招

不需要递归这么复杂——只要在computeIfAbsent的回调里,直接对同一个map做一次put(),会怎样?

MapComputeIfAbsentRecursiveDemo.java simpleMap.computeIfAbsent("b", k -> { simpleMap.put("c", 999); // 在computeIfAbsent回调内部直接修改同一个map return 2; });
真实实测
直接修改同一个map真实抛出: ConcurrentModificationException
这不是一个"边界情况",是一条明确的硬性规则。JDK 官方文档对 computeIfAbsent()(以及 compute()、merge() 等同一族的方法)有明确警告:mappingFunction 不应该对调用它的这个 map 做任何结构性修改,如果违反,真实行为是"未定义的",HashMap 的具体实现选择了用抛异常的方式来快速暴露这个问题,而不是静默产生一个损坏的内部状态。真实测试证明不管是简单的一次 put() 还是复杂的递归调用,只要真实触发了对同一个map的结构性修改,这个检测机制都会真实介入。

真实验证:正确的记忆化写法

既然computeIfAbsent不能用于这种递归场景,正确的记忆化模式应该怎么写?

MapComputeIfAbsentRecursiveDemo.java static long fibCorrect(int n, Map<Integer, Long> memo) { if (n <= 1) return n; if (memo.containsKey(n)) return memo.get(n); long result = fibCorrect(n - 1, memo) + fibCorrect(n - 2, memo); // 递归先完成 memo.put(n, result); // 递归返回之后才真实修改map return result; }
真实实测
正确写法计算fib(20) 真实结果: 6765
memo真实缓存了多少个中间结果: 19

真实结果证明了正确的模式:用传统的 containsKey() + get() + put() 三步分开操作,put() 真实只在递归调用完全返回之后才执行,不会出现"正在修改map的过程中又嵌套修改同一个map"的冲突场景。computeIfAbsent() 真正适合的场景是"计算过程本身不涉及对同一个map的递归访问"——比如系列前面常见的 map.computeIfAbsent(key, k -> new ArrayList<>()) 这种简单的默认值初始化。

分步演示:modCount 快照是怎么"过期"的

用最小能复现问题的 fibonacci(memo, 3) 逐步拆解:每一步的 modCount 和 memo 内容,都是真实运行这段代码时的状态。

fibonacci(memo, 3) 真实崩溃的完整七步:两层 computeIfAbsent 的 modCount 快照是怎么对不上的
这一步发生变化的项

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17 得到,递归崩溃和单层嵌套崩溃场景重复运行完全一致,100%必然触发。
    • 和fail-fast 迭代器那篇一起看,能理解 HashMap 的 modCount 检测机制不只保护遍历安全,同样真实用于检测方法回调内部的非法重入修改。
    • 没有涉及:ConcurrentHashMap的computeIfAbsent()对同一个key的重入调用是否有不同的真实表现(结论是ConcurrentHashMap文档同样明确警告禁止重入,实现方式不同但同样不安全)、用显式的栈/队列结构改写递归记忆化以规避这类问题的具体写法——这两个话题留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号