线上应用突然卡死,进程存活但请求无响应。一次
jstack救场,让我深刻理解了 Spring 容器初始化的"危险窗口"以及不同版本间的锁机制演进。
一、现象:应用启动"僵住"
某天下午,运维反馈服务无法正常提供服务。登录服务器检查,进程仍在运行,CPU 和内存指标正常,但所有 HTTP 接口全部超时。凭借经验,第一反应是可能发生了死锁。
果断执行 jstack <pid>,在输出的末尾,一行醒目的信息跃入眼帘:
text
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 输出的栈信息,我们可以还原出两个线程的执行路径:
线程一:容器启动线程-1(Tomcat 启动时初始化 Spring 容器)
-
正在执行
DefaultSingletonBeanRegistry.getSingleton(),位于synchronized (this.singletonObjects)代码块内,持有 了 Spring 容器的单例对象缓存(即singletonObjects,一个ConcurrentHashMap),我们称之为锁 A。 -
继续初始化
UserSessionServiceAspectBean,其@PostConstruct方法initRouteMap()被调用。 -
initRouteMap()内部通过 AOP 代理调用了getRouteKey(),触发了 AOP 拦截链。 -
AOP 拦截器需要获取切面实例,于是进入
LazySingletonAspectInstanceFactoryDecorator.getAspectInstance()------ 该方法需要获取锁 B(该装饰器实例自身的锁或关联的 mutex)。 -
然而,锁 B 此刻正被线程二持有,因此线程一进入阻塞等待。
线程二:定时任务线程-1(一个 @Scheduled 定时任务)
-
定时任务正在执行
SignServiceImpl中的某个方法,该方法同样经过 AOP 代理(比如@Before增强)。 -
进入 AOP 拦截链后,需要获取切面实例,于是它先成功进入了
getAspectInstance(),持有 了锁 B。 -
在创建切面实例的过程中,又需要获取目标 Bean(可能是尚未完全初始化的 Bean),于是调用了
getBean(),最终进入DefaultSingletonBeanRegistry.getSingleton()。 -
而
getSingleton()需要synchronized (this.singletonObjects)------ 即锁 A,但锁 A 正被线程一持有。 -
于是线程二也进入阻塞等待。
至此,死锁形成:
-
线程一持锁 A,等锁 B;
-
线程二持锁 B,等锁 A。
三、深入源码:不同 Spring 版本锁机制差异
1. Spring 4.1.6 中的 getAspectInstance
在较早的版本(如 4.1.6)中,LazySingletonAspectInstanceFactoryDecorator.getAspectInstance() 的实现非常简单:
java
@Override
public synchronized Object getAspectInstance() {
if (this.materialized == null) {
this.materialized = this.maaif.getAspectInstance();
}
return this.materialized;
}
整个方法直接使用 synchronized 修饰,锁是当前实例对象本身。这意味着任何线程调用该方法,都会竞争同一个对象锁(即我们所说的锁 B)。这把锁的粒度很粗,一旦被定时任务线程持有,初始化线程就只能等待。
2. Spring 5.0.6 中的改进
在 Spring 5.0.6 中,getAspectInstance() 的实现更加精细:
java
@Override
public Object getAspectInstance() {
Object aspectInstance = this.materialized;
if (aspectInstance == null) {
Object mutex = this.maaif.getAspectCreationMutex();
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;
}
这里不再使用方法级的 synchronized,而是通过 getAspectCreationMutex() 获取一个专用的互斥对象(通常是 BeanFactory 或 AspectInstanceFactory 的相关锁),并在 synchronized (mutex) 块内进行双重检查锁定。这把 mutex 就是锁 B 的具体实现。
这种改动确实降低了锁的粒度,允许不同切面实例使用不同的 mutex,减少了不必要的竞争。然而,这并没有消除死锁的可能性,因为:
-
锁 A(
singletonObjects)依然是全局的synchronized块; -
锁 B(mutex)仍然是某个特定对象的监视器锁。
只要两个线程以相反的顺序获取这两把锁,死锁依然会发生。
3. 为什么 getSingleton 一直使用全局锁?
或许你会问:为什么 Spring 不把 DefaultSingletonBeanRegistry.getSingleton() 的锁粒度也细化?毕竟它使用的是 synchronized (this.singletonObjects) 这么大的一把锁。
这其实是一个设计权衡 。DefaultSingletonBeanRegistry 负责管理所有单例 Bean 的创建和缓存,除了 singletonObjects(一级缓存),它还维护了 earlySingletonObjects(二级缓存)和 singletonFactories(三级缓存),用于处理循环依赖等问题。在创建 Bean 的过程中,这些缓存需要原子性 地变化,以确保线程安全。如果为每个 beanName 单独加锁,虽然能提高并发度,但实现会极度复杂,且容易引入微妙的并发缺陷。
Spring 作为基础框架,优先选择了正确性 而非极致的性能,因此始终维持了这把全局锁。这也解释了为何在容器初始化阶段,任何 getBean 操作都会成为热点竞争。
四、为什么死锁偏偏在启动时发生?
关键在于时间窗口。
-
容器启动线程-1正处于refresh()→finishBeanFactoryInitialization()阶段,此时 Spring 正在预实例化所有单例 Bean。该线程已经持有了锁 A,并在初始化过程中调用了 AOP 代理方法,试图获取锁 B。 -
定时任务线程-1是@Scheduled方法所在的线程,它在 Bean 实例化后就会立即启动(默认initialDelay=0)。该线程先获取了锁 B,然后因为业务逻辑需要getBean,又试图获取锁 A。
两个线程在容器尚未完全启动的"危险窗口"内,以相反的顺序请求锁,最终撞车。
五、解决方案
1. 延迟定时任务启动(快速止血)
为定时任务配置 initialDelay,确保其不会在容器启动阶段运行:
java
@Scheduled(initialDelay = 60000, fixedDelay = 300000)
public void scheduledTask() { ... }
这样,任务会在容器启动 60 秒后才执行,避开了锁竞争的高峰期。
2. 将 @PostConstruct 中的 AOP 调用移至 ApplicationRunner
既然 initRouteMap 触发了 AOP 代理,我们完全可以将其从 @PostConstruct 移出,放到容器完全启动后的回调中:
java
@Component
public class AppStartupRunner implements ApplicationRunner {
@Autowired
private UserSessionServiceAspect aspect;
@Override
public void run(ApplicationArguments args) throws Exception {
aspect.initRouteMap(); // 此时容器已就绪,锁竞争已消失
}
}
删除原有 @PostConstruct 注解。这样初始化线程在创建 Bean 时不会触发 AOP,自然也就不会去争抢切面实例锁。
3. 审查 AOP 切面的依赖链
检查切面是否依赖了其他未完全初始化的 Bean,如果可能,将切面改为原型作用域(@Scope("prototype")),但需要评估性能开销。
六、总结与思考
这次死锁排查,让我对 Spring 容器的生命周期和锁机制有了更深刻的认识:
-
无论 Spring 版本如何演进,
DefaultSingletonBeanRegistry的全局锁设计始终是容器初始化阶段的基石。 虽然 Spring 5 对getAspectInstance做了锁粒度优化,但死锁风险并未消失,因为锁 A 和锁 B 依然存在,且获取顺序不可控。 -
在容器初始化过程中,应避免调用任何经过 AOP 增强的方法。 这是最安全的实践。如果需要执行初始化逻辑,建议使用
ApplicationListener或ApplicationRunner在ApplicationReadyEvent之后触发。 -
@Scheduled定时任务默认会立即启动,务必设置initialDelay。 这是一个容易被忽视的细节,却可能在生产环境中引发严重故障。 -
jstack是排查死锁的第一利器。 学会解读它的输出,能让你快速定位问题线程和锁对象。