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

线上应用突然卡死,进程存活但请求无响应。一次 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

  • 继续初始化 UserSessionServiceAspect Bean,其 @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() 获取一个专用的互斥对象(通常是 BeanFactoryAspectInstanceFactory 的相关锁),并在 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 容器的生命周期和锁机制有了更深刻的认识:

  1. 无论 Spring 版本如何演进,DefaultSingletonBeanRegistry 的全局锁设计始终是容器初始化阶段的基石。 虽然 Spring 5 对 getAspectInstance 做了锁粒度优化,但死锁风险并未消失,因为锁 A 和锁 B 依然存在,且获取顺序不可控。

  2. 在容器初始化过程中,应避免调用任何经过 AOP 增强的方法。 这是最安全的实践。如果需要执行初始化逻辑,建议使用 ApplicationListenerApplicationRunnerApplicationReadyEvent 之后触发。

  3. @Scheduled 定时任务默认会立即启动,务必设置 initialDelay 这是一个容易被忽视的细节,却可能在生产环境中引发严重故障。

  4. jstack 是排查死锁的第一利器。 学会解读它的输出,能让你快速定位问题线程和锁对象。

相关推荐
维天说2 小时前
CLI-Switch 2026年3月版历史设计:Hook、TTY 隔离与 JSON 状态
java·服务器·json
Zane19943 小时前
并发 vs 并行:别再傻傻分不清了,一文讲透 Java 并发编程的第一课
java·后端
噢,我明白了4 小时前
java中Excel的导入和导出(EasyExcel)
java·开发语言·excel
吠品4 小时前
Zabbix Web界面误报Server未运行的排查与解决
java·服务器·数据库
啊湘4 小时前
天气查询API接口 按月Token鉴权 实时天气 物联网可用 文档齐全
java·后端·struts
Java内核笔记4 小时前
告别十亿美元的错误 : Spring Boot 4 空安全 (JSpecify) 实战
java·spring boot·后端
糖果店的幽灵4 小时前
langgraph的 MessagesState 解读
java·开发语言·人工智能·windows·langgraph
极光代码工作室6 小时前
基于SpringBoot的在线博客系统
java·springboot·web开发·后端开发
我是唐青枫6 小时前
Java Spring Security 实战详解:从登录认证到 JWT 权限控制
java·spring
ihuyigui6 小时前
海外签收通知短信接口
android·java·开发语言·前端·数据库·后端