线程池的拒绝策略选了 CallerRunsPolicy,结果主线程被阻塞,服务无响应------这个 bug 在生产环境里出现过多次,根因很清晰,但不理解线程池工作原理的人很难想到。
线程池的四种拒绝策略
线程池有一个核心参数设置:核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、等待队列(workQueue)。当队列满了且线程数达到最大值时,新提交的任务会触发拒绝策略:
- AbortPolicy(默认) :抛出
RejectedExecutionException,任务被丢弃 - CallerRunsPolicy:由提交任务的线程来执行这个任务
- DiscardPolicy:静默丢弃,不抛异常
- DiscardOldestPolicy:丢弃队列里最老的任务,然后重新提交当前任务
CallerRunsPolicy 看起来是最"好"的策略:任务没有被丢弃,由调用方自己执行,保证了任务不丢失。但这个"好"在某些场景下会变成系统崩溃的根因。
CallerRunsPolicy 的问题
scss
// 典型的配置:用线程池处理异步任务
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, 20,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// HTTP 请求处理线程里提交任务
@PostMapping("/process")
public ResponseEntity<?> process(Request req) {
executor.submit(() -> heavyTask(req)); // 提交异步任务
return ResponseEntity.ok("submitted");
}
正常情况:HTTP 请求线程提交任务到线程池,立刻返回"submitted",很快。
线程池满载情况(队列满 + 线程数达到最大值):CallerRunsPolicy 让 HTTP 请求线程直接执行 heavyTask(req)。heavyTask 可能要执行几秒,这段时间内 HTTP 请求线程被占住------Tomcat 的请求处理线程全被 heavyTask 占了,新的 HTTP 请求无法被处理,请求积压,服务开始超时。
这是一个正反馈的恶性循环:线程池满了 → 请求线程执行重任务 → 请求线程占满 → 新请求无法处理 → 更多任务积压 → 线程池更满。
什么时候 CallerRunsPolicy 是合适的
CallerRunsPolicy 在特定场景下是正确的选择------调用方线程被阻塞是可以接受的,甚至是期望的(起到自然的背压作用)。
典型场景:批处理任务提交。一个批处理程序的主线程读文件,把每行数据提交到线程池处理:
scss
ExecutorService pool = new ThreadPoolExecutor(
8, 8, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
Files.lines(path).forEach(line -> {
pool.submit(() -> processLine(line)); // 队列满时,主线程自己处理
});
这里主线程被阻塞是正确的------它的工作就是生产任务,被阻塞意味着"等消费跟上",不会有其他副作用。这是 CallerRunsPolicy 作为背压机制的正确用法。
正确的防护
如果调用方是 Web 请求处理线程,拒绝策略应该快速失败,不能阻塞:
scss
// 方案一:AbortPolicy + 业务层捕获异常
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, 20, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.AbortPolicy()
);
@PostMapping("/process")
public ResponseEntity<?> process(Request req) {
try {
executor.submit(() -> heavyTask(req));
return ResponseEntity.ok("submitted");
} catch (RejectedExecutionException e) {
// 线程池满,快速返回错误,不阻塞
return ResponseEntity.status(429).body("服务繁忙,请稍后重试");
}
}
// 方案二:自定义拒绝策略,记录日志 + 返回错误
executor = new ThreadPoolExecutor(
10, 20, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
(task, pool) -> {
log.warn("线程池已满,任务被拒绝,队列大小: {}", pool.getQueue().size());
throw new RejectedExecutionException("线程池已满");
}
);
// 方案三:tryOffer 代替 submit,不阻塞地检查是否能提交
if (!executor.getQueue().offer(task)) {
// 队列满了,做降级处理
handleOverload(task);
}
监控线程池状态
线程池被打满通常不是突发的,是逐渐接近边界的。提前监控可以预防:
less
// 暴露线程池指标给 Micrometer(Spring Boot Actuator)
Metrics.gauge("executor.queue.size", executor, e -> e.getQueue().size());
Metrics.gauge("executor.active.threads", executor, ThreadPoolExecutor::getActiveCount);
Metrics.gauge("executor.pool.size", executor, ThreadPoolExecutor::getPoolSize);
或者直接用 Spring 的 ThreadPoolTaskExecutor,配合 Actuator 的 /actuator/metrics 端点,自动暴露线程池指标。
队列使用率超过 80% 就应该告警,而不是等到 100% 了才发现问题。