当前位置:首页 > 文章列表 > 文章 > java教程 > Java Map.computeIfAbsent 递归更新同一个键为什么会失败

Java Map.computeIfAbsent 递归更新同一个键为什么会失败

来源:17golang原创 2026-09-14 10:21:58 0浏览 收藏

我第一次把递归缓存写进 Map.computeIfAbsent 时,直觉是“缺什么键就继续算什么键”。但如果回调再次更新当前 Map,尤其是更新同一个键,代码就可能得到 ConcurrentModificationExceptionIllegalStateException,甚至陷入无法收敛的递归。真正的问题不是 lambda 不能递归,而是映射函数执行期间不应该修改它所属的 Map。

computeIfAbsent 的回调只负责计算并返回值;不要在回调里对当前 Map 调用 putcompute 或再次对同一个键调用 computeIfAbsent。需要递归时,让递归函数返回结果,最后在回调外一次性写入。

要点速览
  • Map 接口的默认语义近似于先 get,再执行映射函数,最后 put;回调并不是一个可随意重入的事务。
  • HashMap 等非并发实现可能尽力报告 ConcurrentModificationException;ConcurrentHashMap 对无法完成的递归更新通常报告 IllegalStateException
  • 最稳的改法是把递归计算与 Map 写入分开,或使用独立的局部 memo,而不是在映射函数里改同一个容器。

先确认失败点在映射函数修改当前 Map

下面这种写法的问题不在“同一个键被调用两次”,而在外层 computeIfAbsent 尚未完成时,回调又拿当前 Map 做了一次更新:

Map cache = new HashMap();
cache.computeIfAbsent("root", key -> {
    // 回调正在为 root 计算值,却再次修改所属 Map。
    return cache.computeIfAbsent(key, ignored -> 1) + 1;
});

外层调用正在处理“键不存在”的分支,回调返回前,外层还没有把最终值放回 Map。内层调用看到同一个键仍然缺失,于是又进入计算。实现为了防止这种更新破坏内部结构或永远等待,会按自身策略报错。把回调换成另一个名字,并不会改变这个约束。

Java Map computeIfAbsent 外层计算回调与同键递归更新之间的静态调用关系示意图
图1:操作示意图。外层 computeIfAbsent、映射函数和同键递归调用都落在“当前 Map 更新回调”边界内,回调不应反向修改 Map。

拆开 get、计算、put 的调用结构

Oracle 的 Map 文档把默认实现描述成一个近似结构:先判断 map.get(key) 是否为空,调用 mappingFunction.apply(key),非空时再执行 map.put(key, newValue)。因此,回调里再次更新当前 Map,等于把写操作插进了外层的“计算尚未结束”区间。

这里还有两个容易误判的边界。第一,映射函数返回 null 时不会记录映射;第二,映射函数抛出未检查异常时,异常会重新抛出,当前缺失映射不会被正常写入。它不是“先创建一个占位值,再让递归函数填充”的 API。

可以把一次调用抽象为三块:Map.get 观察状态,mappingFunction 计算候选值,Map.put 提交结果。静态关系如下,图中的连线只表示调用和数据依赖,不表示真实运行截图或执行证据。

Java Map get、mappingFunction、put 与 null 和异常边界的静态结构图
图2:结果示意图。计算边界包含 get、mappingFunction 和 put 三个实体;null 与异常分别决定“不写入”和“传播异常”的结果。

对照 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, Map parent,
                   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 实现类;搜索回调内的 putremovecomputecomputeIfAbsent;确认返回值是否可能为 null;对递归输入建立访问路径检测环;若涉及多线程,再单独检查共享 Map 的并发契约。不要只改成 ConcurrentHashMap 就认为业务递归已经正确。

相关问题

computeIfAbsent 返回 null 会发生什么?

不会记录该键的映射,调用结果也是 null。如果 null 表示“暂时算不出来”,应在业务层区分缺失、失败和合法空值。

为什么捕获 ConcurrentModificationException 后重试不可靠?

异常说明回调设计违反了当前 Map 的修改边界。原逻辑不变时重试只会再次触发同样的结构变化,应该先把递归计算移到 Map 外部。

ConcurrentHashMap 能解决同键递归吗?

不能。它提供更明确的并发计算语义,但仍要求映射函数不要修改当前表;可检测的无限递归更新会以 IllegalStateException 暴露。

什么时候适合用 computeIfAbsent?

适合“缺键时独立构造一个值并返回”的场景,例如创建集合或节点。构造过程若需要递归访问同一缓存,先设计独立的计算上下文和环检测。

这类错误最值得记住的判断是:computeIfAbsent 是“计算后提交一个值”的入口,不是给递归算法提供可嵌套写入的事务。先让递归返回结果,再在回调外更新 Map,通常比围绕异常名称反复试容器更可靠。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
PHP json_validate 遇到非法 UTF-8 时如何区分格式和编码错误PHP json_validate 遇到非法 UTF-8 时如何区分格式和编码错误
上一篇
PHP json_validate 遇到非法 UTF-8 时如何区分格式和编码错误
Go sql.Tx 回滚失败时怎样保留原始业务错误
下一篇
Go sql.Tx 回滚失败时怎样保留原始业务错误
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    8次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    125次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    49次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    16次使用
  • OpenCompass大模型评测体系详解:功能、使用指南与应用场景
    OpenCompass
    OpenCompass是上海AI实验室推出的开源大模型评测平台,提供CompassKit、CompassHub和CompassRank三大核心组件,支持LLM及多模态模型的一站式标准化评估与排行榜查询。
    67次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议隐私政策
返回登录
  • 重置密码