线上应用突然卡死,进程存活但请求无响应。一次
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(); // 容器就绪后再执行
}
}
六、总结
这次排查让我深刻认识到:
- Spring 4.x 中
getAspectInstance和getSingleton使用的是两把不同的锁,在特定场景下会形成死锁。 - Spring 5 的修复方案是让两把锁变成同一把锁 (
singletonMutex),从根本上消除了死锁的可能。 - 升级 Spring 版本是解决这类问题的根本之道。
- 在生产环境遇到死锁时,
jstack是第一排查利器,能快速定位问题线程和锁对象。