Spring Framework 近日发布了 v7.0.2 版本,本次更新修复了一个由 ConcurrentReferenceHashMap#computeIfAbsent 方法引发的致命死锁问题(Issue #35944)。该 Bug 可能导致应用在启动时完全卡死,且不产生任何错误日志,因其偶发性而极难排查和复现。
Spring 7 中的死锁陷阱
在 Spring Framework 7.0.2 版本中,包含了一项针对核心并发组件中死锁 Bug 的关键修复。问题的典型现象是:Spring Boot 应用在启动时突然“卡住”,没有任何错误日志,线程 DUMP 显示存在死锁。
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args); // ← 应用在此处卡死!
}
}
根据社区反馈,此 Bug 并非每次必现,而是在特定的初始化时序和哈希分布下偶然发生,增加了排查难度。
影响范围
- 影响版本:Spring Framework 6.2.13 - 7.0.1
- 严重级别:严重(Critical)
- 影响组件:所有使用
ConcurrentReferenceHashMap 的 Spring 应用
- 触发条件:复杂的 Spring 上下文初始化时,特定的 Bean 加载顺序和并发访问模式
根因分析
问题的根源在于 Spring 框架内部广泛使用的 ConcurrentReferenceHashMap,尤其是在其 computeIfAbsent 方法的锁实现上。
ConcurrentReferenceHashMap 的作用
ConcurrentReferenceHashMap 是 Spring 框架提供的核心并发数据结构,结合了 ConcurrentHashMap 的并发分段锁机制和弱引用/软引用支持。它在 Spring 内部被用于多种缓存场景,例如注解元数据缓存、类型转换器缓存和 Bean 定义缓存等,是 Spring 框架 高效运行的基础组件之一。
死锁产生的根本原因
根据 Issue #35944 的分析,问题源于一次为修复其他 Bug 而进行的代码提交(commit 12dd758)。该修改导致 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 时,就可能发生循环等待,导致死锁。例如,在 并发编程 中典型的 AB-BA 死锁场景:
- 线程1持有 Segment A 锁(对应 key1),尝试获取 Segment B 锁(对应 key2)。
- 线程2持有 Segment B 锁(对应 key2),尝试获取 Segment A 锁(对应 key1)。
- 两个线程互相等待,形成死锁。
Spring 框架中的实际触发点:
在 Spring 上下文初始化期间,注解元数据的加载过程会触发此问题。例如,在 AnnotationTypeMappings.Cache.get() 方法中:
public AnnotationTypeMappings get(Class<? extends Annotation> annotationType) {
return this.mappings.computeIfAbsent(annotationType, type -> {
// 在创建子注解映射时,可能递归访问同一个 map
return createMappings(type); // ← 潜在的再次触发 computeIfAbsent 调用
});
}
当应用存在多级嵌套注解,且初始化过程涉及并发时,就容易命中这个死锁陷阱。
修复方案
Spring 团队在 v7.0.2 和 v6.2.15 版本中采用了“双重检查加锁 + 解锁重试”的方案来修复此问题。
修复后的核心策略是:将用户提供的映射函数执行移出锁的范围。主要步骤如下:
- 无锁快速路径:首先不加锁检查 key 是否存在。
- 加锁二次检查:获取锁后再次检查,防止其他线程已插入值。
- 关键修复点:在调用
mappingFunction.apply(key) 之前释放锁,确保用户代码在无锁环境下执行。
- 重新加锁并插入:计算完新值后,重新获取锁,检查状态,然后执行插入操作。
这种设计避免了在持有锁时执行不可控的用户逻辑,从根本上切断了死锁链,同时通过双重检查保证了线程安全性和数据一致性。
修复要点总结
- 隔离用户逻辑:确保自定义函数不在锁区内运行。
- 保证线程安全:通过经典的双重检查锁模式处理竞态条件。
- 内存可见性:通过合理的锁顺序和
volatile 变量保证 happens-before 关系。
升级建议与验证
如果你正在使用 Spring Framework 6.2.13 至 7.0.1 版本,建议立即升级到修复版本。
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 6.2.x -->
<properties>
<spring-framework.version>6.2.15</spring-framework.version>
</properties>
升级后验证方法:
- 线程 DUMP 分析:应用启动后,检查线程 dump 是否还存在
ConcurrentReferenceHashMap 相关的死锁。
jstack <pid> > threaddump.txt
grep -i "deadlock" threaddump.txt
- 启动监控:观察应用启动时间是否稳定,不再出现随机挂起。
- 日志检查(可选):可启用 Spring 核心注解处理的调试日志,观察加载过程是否顺畅。
logging.level.org.springframework.core.annotation=DEBUG
最佳实践与预防
即使已升级到修复版本,遵循以下 软件开发 最佳实践也有助于避免类似的并发陷阱:
- 避免递归调用:尽量避免在
computeIfAbsent 的映射函数中再次调用同一个 Map 的方法。
- 保持函数纯净:映射函数应只做纯粹的计算,避免访问共享资源或调用外部服务。
- 简化对象构建:避免在映射函数中进行复杂的、可能触发其他动态加载的对象图构建。
总结
Spring Framework 7.0.2 对 ConcurrentReferenceHashMap 死锁 Bug 的修复,体现了一个成熟框架对稳定性的高度重视。该 Bug 的隐蔽性在于其对并发时序的高度依赖,这也提醒开发者,在复杂框架中,即使是基础工具类的细微改动也可能引发系统性风险。及时升级依赖版本,并建立有效的应用启动监控,是保障线上服务稳定的重要手段。