线程池拒绝策略CallerRunsPolicy反而卡死了主线程

线程池的拒绝策略选了 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% 了才发现问题。

相关推荐
GoGeekBaird30 分钟前
Agent 时代,你的生产环境,真的敢让它裸奔吗
后端·agent
IT_陈寒35 分钟前
Redis Pipeline用错竟比不用还慢,这个坑我帮你踩过了
前端·人工智能·后端
名字还没想好☜1 小时前
Go 1.21 context.WithoutCancel 实战:父 context 取消了,收尾任务还要继续跑
开发语言·后端·golang·go
源代码•宸1 小时前
前置准备:定时微服务有什么价值
经验分享·后端·微服务·云原生·架构
catino1 小时前
高并发详解
后端
掘金者阿豪1 小时前
GPT-6 Astra 来了,GPT-5.6 Sol 还值得用吗?聊聊 Coding、百万上下文、价格和 Plus/Pro
前端·后端
SamDeepThinking2 小时前
HashMap 分组操作的演进:从三次查找到一次调用
java·后端·程序员
newerp2 小时前
Golang 接口的两副面孔:eface、iface 与动态派发之谜
后端·程序员·go
万物智能2 小时前
开源鸿蒙内核配置与驱动三条路径—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
Zane19942 小时前
自己写一个 java.lang.String,为什么永远替换不掉 JDK 那个
java·后端