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

相关推荐
神奇小汤圆1 小时前
Jaws:从零构建一个”五脏俱全”的 Java RPC 框架
后端
Csvn2 小时前
📊 SQL 入门 Day 10:递归 CTE — 破解无限层级查询的终极武器
后端·sql
echohelloworld112 小时前
HarmonyOS开发实战:小分享-CreateSelectPage创建分享类型选择器
后端
啊湘2 小时前
天气查询API接口 按月Token鉴权 实时天气 物联网可用 文档齐全
java·后端·struts
用户69371750013842 小时前
从代码生产者到 AI 协作者:软件工程师的角色重构
android·前端·后端
Java内核笔记2 小时前
告别十亿美元的错误 : Spring Boot 4 空安全 (JSpecify) 实战
java·spring boot·后端
码栈研说2 小时前
Go 语言大白话入门 10 - 排序与常用数据操作
后端·程序员
JavaGuide3 小时前
再见 Superpowers!很多 Skill 真的可以扔掉了。
后端·ai编程
用户7713970207064 小时前
我在项目里发现了一个“神秘文件“——.editorconfig
后端