Java Map.computeIfAbsent 递归更新同一个键为什么会失败
来源:17golang原创
时间:2026-09-14 10:21:58 184浏览 收藏
我第一次把递归缓存写进 Map.computeIfAbsent 时,直觉是“缺什么键就继续算什么键”。但如果回调再次更新当前 Map,尤其是更新同一个键,代码就可能得到 ConcurrentModificationException、IllegalStateException,甚至陷入无法收敛的递归。真正的问题不是 lambda 不能递归,而是映射函数执行期间不应该修改它所属的 Map。
computeIfAbsent的回调只负责计算并返回值;不要在回调里对当前 Map 调用put、compute或再次对同一个键调用computeIfAbsent。需要递归时,让递归函数返回结果,最后在回调外一次性写入。
- Map 接口的默认语义近似于先
get,再执行映射函数,最后put;回调并不是一个可随意重入的事务。 - HashMap 等非并发实现可能尽力报告
ConcurrentModificationException;ConcurrentHashMap 对无法完成的递归更新通常报告IllegalStateException。 - 最稳的改法是把递归计算与 Map 写入分开,或使用独立的局部 memo,而不是在映射函数里改同一个容器。
先确认失败点在映射函数修改当前 Map
下面这种写法的问题不在“同一个键被调用两次”,而在外层 computeIfAbsent 尚未完成时,回调又拿当前 Map 做了一次更新:
Mapcache = new HashMap(); cache.computeIfAbsent("root", key -> { // 回调正在为 root 计算值,却再次修改所属 Map。 return cache.computeIfAbsent(key, ignored -> 1) + 1; });
外层调用正在处理“键不存在”的分支,回调返回前,外层还没有把最终值放回 Map。内层调用看到同一个键仍然缺失,于是又进入计算。实现为了防止这种更新破坏内部结构或永远等待,会按自身策略报错。把回调换成另一个名字,并不会改变这个约束。

拆开 get、计算、put 的调用结构
Oracle 的 Map 文档把默认实现描述成一个近似结构:先判断 map.get(key) 是否为空,调用 mappingFunction.apply(key),非空时再执行 map.put(key, newValue)。因此,回调里再次更新当前 Map,等于把写操作插进了外层的“计算尚未结束”区间。
这里还有两个容易误判的边界。第一,映射函数返回 null 时不会记录映射;第二,映射函数抛出未检查异常时,异常会重新抛出,当前缺失映射不会被正常写入。它不是“先创建一个占位值,再让递归函数填充”的 API。
可以把一次调用抽象为三块:Map.get 观察状态,mappingFunction 计算候选值,Map.put 提交结果。静态关系如下,图中的连线只表示调用和数据依赖,不表示真实运行截图或执行证据。

对照 HashMap 与 ConcurrentHashMap 的异常差异
不要把某一次异常名称当成所有 Map 的统一承诺。Map 接口只要求映射函数不要修改当前 Map,并明确指出默认实现不保证检测这种修改。具体实现可以覆盖方法并增加尽力检测。
| 实现 | 回调内更新当前 Map 的处理 | 排查重点 |
|---|---|---|
| HashMap | 非并发实现,可能检测到结构变化并抛 ConcurrentModificationException | 检查回调是否调用 put、remove 或再次 compute |
| ConcurrentHashMap | 计算调用具有原子性约束;可检测会导致递归更新无法完成的情况并抛 IllegalStateException | 确认回调是否重入当前表,且不要把原子性当成可嵌套修改许可 |
| 其他 Map | 行为由具体实现文档决定,默认接口不提供统一检测保证 | 先看实现类对 computeIfAbsent 的覆盖说明 |
所以,看到 ConcurrentModificationException 时,重点是“回调改变了当前非并发 Map”;看到 IllegalStateException 时,重点是“递归更新已经被实现识别为无法完成”。两者都不应靠捕获异常后再次提交同一段回调来修复。
把递归结果移到 Map 外部再一次性写入
如果递归关系本身是树或有向无环图,先让普通递归函数返回结果,再由最外层负责写缓存。下面的例子用独立的 visiting 集合检测环,递归函数内部没有修改 cache:
static int resolve(String key, Mapparent, Map cache, Set visiting) { // 先读取已经完成的结果,避免重复计算。 Integer saved = cache.get(key); if (saved != null) return saved; // 当前路径再次出现同一个键,说明关系图有环。 if (!visiting.add(key)) throw new IllegalArgumentException("cycle: " + key); try { String parentKey = parent.get(key); int value = parentKey == null ? 1 : resolve(parentKey, parent, cache, visiting) + 1; // 递归函数返回后再提交,回调边界之外才修改 Map。 cache.put(key, value); return value; } finally { // 无论成功还是失败,都撤销当前路径标记。 visiting.remove(key); } }
如果必须使用 computeIfAbsent,可以让回调只构造一个不依赖当前 Map 的值,例如 map.computeIfAbsent(id, IdNode::new),然后在外层继续处理节点之间的递归关系。把缓存写入职责与递归计算职责拆开,代码也更容易测试。
检查 null、并发和多键递归边界
“不同键递归”只能说明键关系不同,不能说明它安全。回调修改当前 Map 的约束仍然存在;如果有多个线程,还要另外考虑 Map 的同步与原子性。普通 HashMap 不提供并发保护,ConcurrentHashMap 的原子 computeIfAbsent 也不允许把映射函数当作可重入写事务。
排查时按这份清单走:记录实际 Map 实现类;搜索回调内的 put、remove、compute 和 computeIfAbsent;确认返回值是否可能为 null;对递归输入建立访问路径检测环;若涉及多线程,再单独检查共享 Map 的并发契约。不要只改成 ConcurrentHashMap 就认为业务递归已经正确。
相关问题
computeIfAbsent 返回 null 会发生什么?
不会记录该键的映射,调用结果也是 null。如果 null 表示“暂时算不出来”,应在业务层区分缺失、失败和合法空值。
为什么捕获 ConcurrentModificationException 后重试不可靠?
异常说明回调设计违反了当前 Map 的修改边界。原逻辑不变时重试只会再次触发同样的结构变化,应该先把递归计算移到 Map 外部。
ConcurrentHashMap 能解决同键递归吗?
不能。它提供更明确的并发计算语义,但仍要求映射函数不要修改当前表;可检测的无限递归更新会以 IllegalStateException 暴露。
什么时候适合用 computeIfAbsent?
适合“缺键时独立构造一个值并返回”的场景,例如创建集合或节点。构造过程若需要递归访问同一缓存,先设计独立的计算上下文和环检测。
这类错误最值得记住的判断是:computeIfAbsent 是“计算后提交一个值”的入口,不是给递归算法提供可嵌套写入的事务。先让递归返回结果,再在回调外更新 Map,通常比围绕异常名称反复试容器更可靠。
-
239 收藏
-
126 收藏
-
文章 · java教程 | 3个月前 | 并发编程 · Spring Boot · 生产实践 · Java教程 · 线程池隔离 · java 并发编程 线程池 spring boot completablefuture191 收藏
-
文章 · java教程 | 3个月前 | Spring Boot · mybatis · 生产实践 · Java教程 · 数据库性能 · java MyBatis 性能优化 spring boot N+1116 收藏
-
文章 · java教程 | 3个月前 | JWT · Spring Boot · 生产实践 · Java教程 · Spring Security · java jwt spring boot Spring Security 后端安全255 收藏
-
文章 · java教程 | 11分钟前 | Java · 集合 · Stream · Collectors · toMap · groupingBy · map 重复键 Collectors.groupingBy Java Collectors.toMap Stream收集203 收藏
-
488 收藏
-
214 收藏
-
475 收藏
-
347 收藏
-
365 收藏
-
127 收藏
-
265 收藏
-
107 收藏
-
381 收藏
-
198 收藏
-
289 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习