3. 单线程执行器陷阱:默认线程池配置(SingleThreadScheduledExecutor)导致的阻塞问题
Spring Boot 的 @EnableScheduling 默认使用 SingleThreadScheduledExecutor 作为底层执行器。这意味着所有被 @Scheduled 注解的方法共享同一个后台线程。当其中一个任务发生阻塞(如网络 I/O、数据库慢查询、死循环)时,后续所有定时任务都会排队等待,导致调度延迟甚至完全停止。
3.1 默认执行器的工作原理
Spring 在初始化定时任务时,会创建一个 ThreadPoolTaskScheduler,其核心线程数默认为 1。该执行器内部包装了 java.util.concurrent.ScheduledThreadPoolExecutor,核心池大小固定为 1。所有定时任务提交到同一个工作队列,由唯一的工作线程顺序执行。
关键点: 默认配置下,定时任务之间没有隔离性。一个任务阻塞,全局阻塞。
3.2 阻塞问题的典型场景
| 场景 | 阻塞原因 | 影响范围 |
|---|---|---|
| HTTP 远程调用超时 | RestTemplate/WebClient 未设置超时,第三方服务挂起 | 所有定时任务延迟 |
| 数据库慢查询 | SQL 执行超过调度间隔,锁等待 | 后续任务排队 |
| 文件 I/O 阻塞 | 大文件读写、网络文件系统挂载 | 全局调度停止 |
| 死循环或无限递归 | 代码逻辑缺陷,线程永远不释放 | 永久阻塞 |
3.3 通过日志验证单线程阻塞
要确认是否因单线程执行器导致阻塞,需要在日志中捕获以下关键信息:
- 任务开始/结束时间戳 — 确认任务是否按时完成。
- 线程名称 — 默认线程名称为
scheduling-1,所有任务应使用同一线程。 - 任务执行耗时 — 超过调度间隔的耗时任务会阻塞后续任务。
3.3.1 配置日志输出
在 application.yml 中开启定时任务相关日志:
logging:
level:
org.springframework.scheduling: DEBUG
com.yourpackage.scheduler: DEBUG
3.3.2 在任务方法中添加日志
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class BlockingTaskDemo {
@Scheduled(fixedRate = 5000) // 每5秒执行一次
public void taskA() {
long start = System.currentTimeMillis();
String threadName = Thread.currentThread().getName();
log.info("[{}] 任务A 开始执行", threadName);
try {
// 模拟阻塞操作,例如远程调用超时
Thread.sleep(15000); // 阻塞15秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
long elapsed = System.currentTimeMillis() - start;
log.info("[{}] 任务A 执行完毕,耗时 {} ms", threadName, elapsed);
}
@Scheduled(fixedRate = 5000)
public void taskB() {
long start = System.currentTimeMillis();
String threadName = Thread.currentThread().getName();
log.info("[{}] 任务B 开始执行", threadName);
long elapsed = System.currentTimeMillis() - start;
log.info("[{}] 任务B 执行完毕,耗时 {} ms", threadName, elapsed);
}
}
3.3.3 观察日志输出
运行上述代码,日志输出将呈现明显的阻塞模式:
2025-01-15 10:00:00.001 [scheduling-1] INFO - [scheduling-1] 任务A 开始执行
2025-01-15 10:00:05.002 [scheduling-1] INFO - [scheduling-1] 任务B 开始执行 ← 本该5秒执行,但被阻塞
2025-01-15 10:00:15.003 [scheduling-1] INFO - [scheduling-1] 任务A 执行完毕,耗时 15002 ms
2025-01-15 10:00:15.004 [scheduling-1] INFO - [scheduling-1] 任务B 执行完毕,耗时 10002 ms ← 任务B实际耗时10秒,但大部分时间在等待
阻塞特征: 所有任务使用同一线程
scheduling-1;任务B的调度时间点(10:00:05)已到,但实际开始执行时间被推迟到任务A释放线程之后;任务B的“执行耗时”远大于其实际业务逻辑耗时,大部分时间消耗在排队等待上。
3.4 验证阻塞的辅助手段
| 方法 | 操作 | 预期结果 |
|---|---|---|
| 线程 Dump | jstack <pid> 查看线程状态 |
看到 scheduling-1 线程处于 RUNNABLE 或 TIMED_WAITING 状态,但队列中有等待任务 |
| Actuator 端点 | 访问 /actuator/scheduledtasks |
显示任务执行历史,观察 lastExecution 时间戳是否异常 |
| 自定义计数器 | 使用 AtomicInteger 记录任务实际执行次数 |
预期 5 秒执行一次,实际执行次数远低于预期 |
3.5 解决方案:配置自定义线程池
通过显式配置 ThreadPoolTaskScheduler,设置合理的核心线程数,避免单线程阻塞:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration
public class SchedulerConfig {
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 设置核心线程数为5
scheduler.setThreadNamePrefix("sched-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return scheduler;
}
}
建议: 核心线程数应根据定时任务数量、任务类型(CPU 密集型 vs I/O 密集型)以及任务执行耗时来设定。对于 I/O 密集型任务,可适当增加线程数;对于 CPU 密集型任务,线程数不宜超过 CPU 核心数。
3.6 小结
- Spring Boot 默认使用 单线程执行器,所有
@Scheduled任务共享一个线程。 - 阻塞问题表现为:任务调度延迟、执行时间异常、线程名称始终为
scheduling-1。 - 通过日志记录任务开始/结束时间、线程名称和执行耗时,可以快速定位阻塞。
- 解决方案是自定义
ThreadPoolTaskScheduler,设置合适的线程池大小。