记一次 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 是第一排查利器,能快速定位问题线程和锁对象。
相关推荐
用户446139430271 小时前
从单体到微服务:我们项目的拆分思路和踩坑记录
java
TDengine (老段)2 小时前
TDengine 免费版说明
java·大数据·数据库·物联网·时序数据库·tdengine
码上有光2 小时前
异常和智能指针
java·大数据·c++·servlet·异常·智能指针
学计算机的计算基3 小时前
操作系统内存管理全解:虚拟内存、页表、COW、malloc、OOM一篇搞定
java·笔记·算法
tachibana23 小时前
hot100 前 K 个高频元素(347)
java·数据结构·算法·leetcode
万亿少女的梦1683 小时前
基于Spring Boot、Java与MySQL的网络订餐系统设计与实现
java·spring boot·mysql·系统设计·网络订餐
咩咩啃树皮11 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚12 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
大模型码小白13 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程