记一次 Spring AOP 与定时任务引发的死锁排查

线上应用突然卡死,进程存活但请求无响应。一次 jstack 让我发现了 Spring 4.x 的一个经典死锁场景,也见证了 Spring 5 是如何从底层修复这个问题的。


一、现象:应用启动"僵住"

某天下午,运维反馈服务无法正常提供服务。登录服务器检查,进程仍在运行,CPU 和内存指标正常,但所有 HTTP 接口全部超时。凭借经验,第一反应是可能发生了死锁

果断执行 jstack <pid>,在输出的末尾,一行醒目的信息跃入眼帘:

text

csharp 复制代码
Found one Java-level deadlock:
=============================
"定时任务线程-1":
  waiting to lock monitor 0x... (object 0x... a java.util.concurrent.ConcurrentHashMap),
  which is held by "容器启动线程-1"
"容器启动线程-1":
  waiting to lock monitor 0x... (object 0x... a org.springframework.aop.aspectj.annotation.LazySingletonAspectInstanceFactoryDecorator),
  which is held by "定时任务线程-1"

典型的死锁:两个线程各自持有一把锁,又都在等待对方持有的锁。


二、抽丝剥茧:谁持有了谁的锁?

从 jstack 输出还原两个线程的执行路径:

线程一:容器启动线程(初始化 Spring 容器)

  • 正在执行 DefaultSingletonBeanRegistry.getSingleton(),位于 synchronized (this.singletonObjects) 代码块内,持有 了 Spring 容器的单例对象缓存(即 singletonObjects,一个 ConcurrentHashMap),我们称之为锁 A
  • 继续初始化某个 Bean(如 UserSessionServiceAspect),其 @PostConstruct 方法被调用。
  • @PostConstruct 方法内部通过 AOP 代理调用了业务方法,触发了 AOP 拦截链。
  • AOP 拦截器需要获取切面实例,于是进入 LazySingletonAspectInstanceFactoryDecorator.getAspectInstance() ------ 该方法需要获取锁 B (该装饰器实例自身的 synchronized 锁)。
  • 然而锁 B 此刻正被线程二持有,线程一进入阻塞等待。

线程二:定时任务线程(@Scheduled 任务)

  • 定时任务正在执行某个经过 AOP 代理的方法。
  • 进入 AOP 拦截链后,需要获取切面实例,先成功进入了 getAspectInstance()持有锁 B
  • 在创建切面实例的过程中,需要获取目标 Bean,于是调用了 getBean(),最终进入 DefaultSingletonBeanRegistry.getSingleton()
  • getSingleton() 需要 synchronized (this.singletonObjects) ------ 即锁 A,但锁 A 正被线程一持有。

死锁形成:

  • 线程一持锁 A,等锁 B;
  • 线程二持锁 B,等锁 A。

三、源码分析:锁的真相

Spring 4.1.6 中的两把锁

在 Spring 4.1.6 中,LazySingletonAspectInstanceFactoryDecorator.getAspectInstance() 的实现是:

java

kotlin 复制代码
public synchronized Object getAspectInstance() {
    if (this.materialized == null) {
        this.materialized = this.maaif.getAspectInstance();
    }
    return this.materialized;
}

方法级别的 synchronized,锁对象是当前实例。而 DefaultSingletonBeanRegistry.getSingleton() 使用的是:

java

java 复制代码
synchronized (this.singletonObjects) { ... }

两把不同的锁

  • 锁 A:singletonObjects(Spring 容器的单例缓存)
  • 锁 B:LazySingletonAspectInstanceFactoryDecorator 实例

两个线程以相反的顺序获取这两把锁,死锁必然发生。

Spring 5.0.6 的修复:两锁合一

Spring 团队意识到了这个问题(SPR-14241),修复方案是让 getAspectInstance 使用与 getSingleton 同一把锁

java

ini 复制代码
public Object getAspectInstance() {
    Object aspectInstance = this.materialized;
    if (aspectInstance == null) {
        Object mutex = this.maaif.getAspectCreationMutex();
        // mutex 就是 ConfigurableBeanFactory.getSingletonMutex()
        // 也就是 DefaultSingletonBeanRegistry 中的 singletonObjects
        if (mutex == null) {
            aspectInstance = this.maaif.getAspectInstance();
            this.materialized = aspectInstance;
        } else {
            synchronized (mutex) {  // 同一把锁!
                aspectInstance = this.materialized;
                if (aspectInstance == null) {
                    aspectInstance = this.maaif.getAspectInstance();
                    this.materialized = aspectInstance;
                }
            }
        }
    }
    return aspectInstance;
}

getAspectCreationMutex() 返回的是 ConfigurableBeanFactory.getSingletonMutex(),而 singletonMutex 正是 DefaultSingletonBeanRegistrygetSingleton 方法使用的 singletonObjects 锁。

两把不同的锁变成了一把锁,死锁的前提条件被彻底消除。这也是为什么升级到 Spring 5 后,同样代码不再死锁的原因。


四、为什么死锁偏偏在 Spring 4.x 启动时发生?

关键在于时间窗口两把不同的锁

  • 容器启动线程持锁 A,在 @PostConstruct 中触发 AOP,试图获取锁 B。
  • 定时任务线程(默认 initialDelay=0)先获取了锁 B,然后因 getBean 试图获取锁 A。
  • 两把不同的锁 + 相反的获取顺序 = 死锁。

Spring 5 通过让两把锁变成同一把锁从根本上解决了这个问题。


五、解决方案

1. 升级到 Spring 5(根本解决)

Spring 5 已经修复了此问题,升级即可彻底解决。

2. 延迟定时任务启动(快速止血)

为定时任务配置 initialDelay,避开启动阶段:

java

java 复制代码
@Scheduled(initialDelay = 60000, fixedDelay = 300000)
public void scheduledTask() { ... }

3. 将 @PostConstruct 中的 AOP 调用移至 ApplicationRunner

java

java 复制代码
@Component
public class AppStartupRunner implements ApplicationRunner {
    @Autowired
    private UserSessionServiceAspect aspect;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        aspect.initRouteMap();  // 容器就绪后再执行
    }
}

六、总结

这次排查让我深刻认识到:

  1. Spring 4.x 中 getAspectInstancegetSingleton 使用的是两把不同的锁,在特定场景下会形成死锁。
  2. Spring 5 的修复方案是让两把锁变成同一把锁singletonMutex),从根本上消除了死锁的可能。
  3. 升级 Spring 版本是解决这类问题的根本之道。
  4. 在生产环境遇到死锁时,jstack 是第一排查利器,能快速定位问题线程和锁对象。
相关推荐
程序员良辰9 小时前
【TongWeb8】使用 Crontab 定时重启和检测 TongWeb 服务
java·中间件·tomcat
SL_staff9 小时前
JVS数字底座实践:如何复用企业文档能力快速构建知识类应用
java·数据库·程序员
sugar__salt9 小时前
MyBatis ③动态 SQL 完全指南 —— 从条件拼接到批量操作
java·sql·mybatis
吃饱了得干活10 小时前
Java并发安全:看这一篇就懂了!
java·后端·面试
前端双越老师10 小时前
前端学习 Java 其实很容易:TS 和 Java 语法的 N 个相同点
java·全栈
evans在进步10 小时前
LeetCode 17:电话号码的字母组合——Java DFS 回溯法详解
java·leetcode·深度优先
2401_8504811710 小时前
UVA10391
java
UQR10 小时前
一、Java基础高频面试题
java·开发语言
AC赳赳老秦10 小时前
网页公开附件自动采集:OpenClaw 批量下载页面内嵌 Word/Excel 附件,统一格式后结构化入库
java·python·word·php·excel·deepseek·openclaw
上海云盾商务经理杨杨11 小时前
Tomcat 弱配置漏洞渗透实战!批量 getshell 高频漏洞
java·web安全·tomcat