记一次 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 正是 DefaultSingletonBeanRegistry 中 getSingleton 方法使用的 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 中 getAspectInstance 和 getSingleton 使用的是两把不同的锁,在特定场景下会形成死锁。
  2. Spring 5 的修复方案是让两把锁变成同一把锁 (singletonMutex),从根本上消除了死锁的可能。
  3. 升级 Spring 版本是解决这类问题的根本之道。
  4. 在生产环境遇到死锁时,jstack 是第一排查利器,能快速定位问题线程和锁对象。
相关推荐
Dovis(誓平步青云)3 分钟前
总用电下降了,为什么家庭能源看板仍然提示异常
java·开发语言·数据库·人工智能·算法·电脑·能源
程序猿乐锅3 分钟前
【从0-1学习Python | 第四篇】Python 字符串和元组
java·开发语言·笔记·后端·python·学习
开开心心就好7 分钟前
Windows视频播放器推荐,精简版体积更小
java·服务器·开发语言·eclipse·pdf·ocr·音视频
Rain的Java大神之路29 分钟前
10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题
java·后端·面试
砚底藏山河35 分钟前
量化新手误区:因子计算与行情数据的工程化解耦
java·数据库·python·金融
威哥爱编程37 分钟前
【AI全栈12-07】Spring Boot 用 Agent 自动完成多步运营任务
java·spring boot·后端
未秃头的程序猿41 分钟前
我用 Spring AI + MCP 把公司内部系统接给了 AI,过程没想象中顺利
java·后端
m0_587383001 小时前
碰碰好评卡功能落地实战 从需求梳理到上线部署全流程实操指南
java·linux·服务器·前端·javascript·vue.js·人工智能
诚心呈意共享外卖配送系统1 小时前
多平台订单聚合系统的技术架构:接 API 只是开始
java·开发语言·架构·系统架构·创业创新
Java爱好狂.1 小时前
Java小白如何三天速通redis?
java·数据库·redis·架构师·分布式锁·java面试·java程序员