本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:xttblog2,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
腾讯云】1核2G5M轻量应用服务器50元首年,高性价比,助您轻松上云
距离上次浏览 Spring 官网差不多有两个星期了,于是我今天再次去官网找找看,有没有新的技术文章值得学习。
你还别说,突然发现 Spring 发布了 v7.0.2 版本,这个版本中竟然有一个重大 bug。
这个 bug 就是由ConcurrentReferenceHashMap#computeIfAbsent导致的上下文初始化死锁问题Issue #35944。
该 Bug 堪称“致命杀手”,它潜伏在 Spring 核心并发组件中,可能导致应用启动时完全卡死。所以,接下来,我们将深入剖析这个 Bug 的来龙去脉,深入解析致命死锁 Bug 的修复,带你了解从问题发现到修复的完整技术细节。
Spring 7 中的死锁陷阱
Spring Framework 发布了 7.0.2 版本,作为 7.x 系列的第二个维护版本,本次更新包含了 20+ 项新特性、30+ 个 Bug 修复以及多项文档改进。这其中最致命的 bug 竟然是隐藏在并发工具类中的死锁陷阱。
问题现象
问题的现象是,你的 Spring Boot 应用在启动时可能会突然“卡住”,并且没有任何错误日志显示。你等呀等,但你的应用永远不会启动完成,只能强行终止。
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args); // ← 卡在这里!
}
}
根据社区网友的反馈,这个 Bug 的典型表现是:应用在启动阶段无响应,既不报错也不继续执行,线程 DUMP 显示存在死锁。更诡异的是,它并非每次必现,而是在特定的时序和哈希分布下“偶尔”发生,这使得排查工作异常困难。
影响范围
目前确定的影响范围如下所示。
- 影响版本:Spring Framework 6.2.13 – 7.0.1
- 严重级别:严重(Critical)
- 影响组件:所有使用
ConcurrentReferenceHashMap的 Spring 应用 - 触发条件:复杂 Spring 上下文初始化时,特定的 Bean 加载顺序和并发访问模式
根因分析
通过众多网友的反馈,社区大牛进行了根因分析,内鬼竟然出在ConcurrentReferenceHashMap身上,尤其是在锁的嵌套与循环等待上更容易复现。
ConcurrentReferenceHashMap 是什么?
ConcurrentReferenceHashMap 是 Spring 框架提供的核心并发数据结构。它是一个组合怪,结合了:
ConcurrentHashMap的并发分段锁机制- 弱引用/软引用支持(用于内存敏感场景)
Spring 造这个类的轮子,是因为这个类在 Spring 内部被广泛使用:
- 注解元数据缓存(AnnotationTypeMappings)
- 类型转换器缓存
- Bean 定义缓存
- 各种内部注册表
死锁产生的根本原因
根据 Issue #35944 的详细分析,问题出在 ConcurrentReferenceHashMap.computeIfAbsent 方法的实现上。
接下来,我们看一下问题代码的演变。
在 commit 12dd758 中,为了修复另一个 Bug,ConcurrentReferenceHashMap 的 computeIfAbsent 实现被修改,导致 mapping 函数在持有锁的情况下被执行。
简化后的问题代码如下所示。
// 简化后的问题代码逻辑
public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
Segment segment = getSegment(hash); // 获取段锁
segment.lock(); //加锁
try {
// 在锁内执行用户提供的函数!危险操作
V value = mappingFunction.apply(key);
return put(key, value);
} finally {
segment.unlock(); // 解锁
}
}
接下来,我们进行死锁场景分析。
当用户提供的 mappingFunction 内部再次访问同一个 Map时,就可能发生死锁。
ConcurrentReferenceHashMap<String, String> map = new ConcurrentReferenceHashMap<>();
// 线程 1:尝试获取 key1 → 在函数中再获取 key2
map.computeIfAbsent("key1", k -> {
// 等待 key2 的锁
return map.computeIfAbsent("key2", k2 -> "value2");
});
// 线程 2:尝试获取 key2 → 在函数中再获取 key1
map.computeIfAbsent("key2", k -> {
// 等待 key1 的锁
return map.computeIfAbsent("key1", k2 -> "value1");
});
对应的时序分析如下所示。
时间线 线程1 线程2
─────────────────────────────────────────────────
t1 获取 segment A 锁 (key1)
t2 获取 segment B 锁 (key2)
t3 尝试获取 segment B 锁 (key2) ← 阻塞
t4 尝试获取 segment A 锁 (key1) ← 阻塞
─────────────────────────────────────────────────
结果:AB-BA 死锁!两个线程永久等待
现在,我们来看看 Spring 框架中的实际触发点。
在 Spring 上下文初始化期间,以下代码路径会触发这个问题:
AnnotationTypeMappings.Cache.get() 大约在 AnnotationTypeMappings.java 文件中的 273 行。
// Spring 框架内部的实际代码
public AnnotationTypeMappings get(Class<? extends Annotation> annotationType) {
return this.mappings.computeIfAbsent(annotationType, type -> {
// 在这里会递归调用 computeIfAbsent 创建子注解映射
return createMappings(type); // ← 可能再次访问同一个 map
});
}
当应用上下文复杂、存在多级注解嵌套,并且多个线程并发初始化或应用启动前后懒加载等并发初始化时,就有可能命中这个死锁陷阱。
修复方案
一般来说,有问题,不行的话,就再试一次,但这个问题,采用重试就越改越麻烦了,而且有点头疼医脚。
下面看看官方的解决方案。
修复策略
Spring 团队在 v7.0.2 和 v6.2.15 中采用了双重检查加锁(Double-Checked Locking)+ 解锁重试的方案。
// 修复后的代码逻辑(简化版)
public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
int hash = getHash(key);
Segment segment = getSegment(hash);
// 第一次检查:无锁快速路径
V value = get(key);
if (value != null) {
return value;
}
// 加锁并第二次检查
segment.lock();
try {
V currentValue = get(key);
if (currentValue != null) {
return currentValue; // 已经存在,直接返回
}
// 关键修复:在调用用户函数前释放锁!
segment.unlock();
// 在锁外计算新值
V newValue = mappingFunction.apply(key);
if (newValue == null) {
return null;
}
// 重新获取锁并检查,防止竞态条件
segment.lock();
try {
V existingValue = get(key);
if (existingValue != null) {
// 其他线程已经插入了值,放弃我们的计算结果
return existingValue;
}
return put(key, newValue);
} finally {
segment.unlock();
}
} finally {
if (segment.isHeldByCurrentThread()) {
segment.unlock(); // 确保解锁
}
}
}
修复要点
下面总结一下修复要点。
- 将用户代码移出锁区:最关键的改变是在调用
mappingFunction.apply()之前释放锁,避免在锁内执行不可控的用户逻辑。 - 双重检查加锁:采用经典的 DCL 模式,既保证了性能,又确保了线程安全。
- 优雅处理竞态:即使释放了锁,也能正确处理其他线程并发插入的情况。
- 保证 happens-before:通过合理的锁顺序和 volatile 变量,确保内存可见性。
这个 bug 并不是必现的,只有在高并发启动的微服务的场景,比如:使用 Spring Cloud 的微服务集群、实例启动时,涉及大量注解扫描的场景。或者是使用复杂注解体系的应用,大量使用自定义注解和元注解、注解之间存在复杂的继承关系的场景。也有网友反馈,在 AOT/GraalVM 原生镜像,Ahead-of-Time 编译时大量并发构建元数据、Native Image 构建过程中可能触发。
升级建议
不管怎么说,我建议大家进行升级。既然使用了 Spring 7 了,小版本升级影响不大的。
所以,如果你正在使用Spring Framework 6.2.13 - 7.0.1,最佳的实践是请立即升级到 7.0.2 或 6.2.15!
对应的 Maven 配置变更如下。
<!-- 对于 Spring Boot 3.x / Spring Framework 7.x -->
<properties>
<spring-framework.version>7.0.2</spring-framework.version>
</properties>
<!-- 或对于 Spring Boot 2.7.x / Spring Framework 5.3.x -->
<properties>
<spring-framework.version>6.2.15</spring-framework.version>
</properties>
升级后,可以进行验证修复。社区推荐通过以下方式验证问题是否解决。
可以进行线程 DUMP 分析启动时生成线程 dump,检查是否存在 ConcurrentReferenceHashMap 相关的死锁。
# 启动应用时获取线程 dump
jstack <pid> > threaddump.txt
# 检查是否包含 "deadlock" 关键字
grep -i "deadlock" threaddump.txt
或者进行启动时间监控,观察应用启动时间是否恢复正常,是否还有随机挂起现象。这个就比较难复现,一般版本升级后不会再出现这类问题。也或者进行日志检查,启用 Spring 调试日志,观察注解映射加载过程,这个也不太推荐。
logging.level.org.springframework.core.annotation=DEBUG
预防措施
社区还建议,即使升级后,也应遵循以下最佳实践。
避免在 computeIfAbsent 中递归调用。
// 危险做法
map.computeIfAbsent(key1, k -> {
return map.computeIfAbsent(key2, ...); // 可能死锁
});
// 安全做法
Value value = map.get(key2);
if (value == null) {
value = computeExpensiveValue();
map.putIfAbsent(key2, value);
}
map.put(key1, value);
其次是保持 mapping 函数简洁。
- 只做纯粹的计算,不调用外部服务
- 不访问共享资源
- 避免复杂的对象图构建
为什么这个 Bug 如此隐蔽?
这个 bug 之所以难以复现,是因为它符合并发 Bug 的典型特征。即,体现了并发编程中时序依赖(Timing Dependency)的典型特征。
- 不可重现性:同样的代码,有时运行正常,有时死锁
- 环境敏感性:CPU 核心数、线程调度策略、JVM 版本都会影响触发概率
- 连锁反应:框架层的死锁会导致整个应用层无法诊断
最后
Spring Framework 7.0 作为新一代框架,引入了多项重大改进。这次及时的 Bug 修复展现了 Spring 团队对质量的高度重视。
如果你的项目使用了 Spring 7,建议立即检查你的项目依赖,升级 Spring Framework 版本!并时刻牢记记预防胜于治疗,监控优于排查。

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号:xttblog2。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!
本文原文出处:业余草: » Spring 7 现重大死锁 bug,应用假死无日志,偶发难复现