Java基础、中级、高级、架构面试资料

Spring 7 现重大死锁 bug,应用假死无日志,偶发难复现

JAVA herman 20浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:xttblog2,发送下载链接帮助你免费下载!
本博客日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,ConcurrentReferenceHashMapcomputeIfAbsent 实现被修改,导致 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(); // 确保解锁
        }
    }
}

修复要点

下面总结一下修复要点。

  1. 将用户代码移出锁区:最关键的改变是在调用 mappingFunction.apply()之前释放锁,避免在锁内执行不可控的用户逻辑。
  2. 双重检查加锁:采用经典的 DCL 模式,既保证了性能,又确保了线程安全。
  3. 优雅处理竞态:即使释放了锁,也能正确处理其他线程并发插入的情况。
  4. 保证 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)的典型特征。

  1. 不可重现性:同样的代码,有时运行正常,有时死锁
  2. 环境敏感性:CPU 核心数、线程调度策略、JVM 版本都会影响触发概率
  3. 连锁反应:框架层的死锁会导致整个应用层无法诊断

最后

Spring Framework 7.0 作为新一代框架,引入了多项重大改进。这次及时的 Bug 修复展现了 Spring 团队对质量的高度重视。

如果你的项目使用了 Spring 7,建议立即检查你的项目依赖,升级 Spring Framework 版本!并时刻牢记记预防胜于治疗,监控优于排查

业余草公众号

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

本文原文出处:业余草: » Spring 7 现重大死锁 bug,应用假死无日志,偶发难复现