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

相关推荐
万少2 小时前
DeepSeek 昨晚刚开源了 Harness:附万少的2 万字保姆级教程
前端·后端·架构
大鸡腿同学6 小时前
身弱体质|你的 FM 该关了📻
后端
Csvn6 小时前
📊 SQL 入门 Day 18:索引原理
后端·sql
Csvn6 小时前
🐍 Day3 : Python 容器精讲 — list、dict、set、tuple 底层实现与高级操作
后端·python
用户938515635076 小时前
ESLint 代码规范完全指南——从 AST 原理到 flat config 逐行解析
javascript·后端·代码规范
用户938515635077 小时前
深入理解 Next.js:从 SPA 的 SEO 问题到约定式路由与 SSR 原理
前端·后端·全栈
码事漫谈9 小时前
Deepseek涨价了,前后对比,竟然差这么多
后端
东风破_10 小时前
大前端手里的 Next.js:从 #root 到 SEO,一份 HTML 的两种命运
前端·后端
IT_陈寒10 小时前
Vue的v-for不听话?我被这个Key的坑整懵了
前端·人工智能·后端
众人皆醒我独醉10 小时前
训练加速实战:Flash Attention、Gradient Checkpointing 与数据流水线
后端·面试·gpu