首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >搞懂线程池这一次就够:拒绝策略、核心参数全解析

搞懂线程池这一次就够:拒绝策略、核心参数全解析

原创
作者头像
用魔法才能打败魔法
发布2025-11-19 14:36:15
发布2025-11-19 14:36:15
800
举报

前言

我刚开始学习java的那几年,对线程池没什么感觉。但实际上在线上系统里,线程池是比锁、比 MQ 都更容易装死的东西。很多人以为 new 一个就完事,结果高峰期直接把 CPU 干到 800% ,队列堆到几千条,线程一直创建不下来。我把这几年踩过的坑、救过的火、修过的线程池都写进这篇文章里,希望你看完后能真的写出一个适合自己业务的线程池,而不是工具类复制粘贴。

线程池底层到底长啥样?

Java 的线程池结构大概是这样:

  • 有一堆“核心线程”—— 平时常驻
  • 忙不过来时会尝试把任务塞进队列
  • 如果队列满了,那就开“临时线程”
  • 临时线程也不够?抱歉,线程池甩手:拒绝策略走起
  • 长时间闲着的线程,会被干掉(keepAlive)

听着简单,但线程池的行为是被几个参数联动控制的,一改一个命。下面这些参数,你要真搞懂,线程池基本就通了。

关键参数

corePoolSize:你愿意为系统留多少“常驻战力”?

核心线程数量就是你的“起步并发能力”。我踩过一次特别蠢的坑就是:我们有个风控接口,计算量特别轻,大部分都是查询。结果我把 corePoolSize 设置得特别大,32 个核心线程。那段时间 CPU 飙得很莫名其妙。最后才发现:核心线程多到离谱,线程上下文切换比业务逻辑都耗时。

实际业务平均 RT 是 10ms 左右,给 8~10 个线程就够了。有同事问我,怎么估核心线程数量?我一般懒得算太复杂,用最简单的经验公式:

代码语言:txt
复制
CPU 密集型:核心线程 ≈ CPU 核数
IO 密集型:核心线程 ≈ CPU 核数 * 2 或更多

别死算,但至少别瞎设。

maximumPoolSize:高峰期你的“应急能力”有多强?

这个值决定你愿意让线程池撑到什么程度。我踩过一个坑是把 maxPoolSize 设置得太小。

那次我们一个接口发生毛刺峰值(QPS 平时 300,那天突然冲到 900+),核心线程都满了、队列堆满了、maxPoolSize 不够,线程池直接把请求往 “拒绝策略” 里扔。

最后导致用户端各种“网络繁忙,请稍后再试”。从那次以后,我意识到要合理使用设置maxPoolSize的大小,避免主机负载过高,一般会设成 corePoolSize 的 2~4 倍,但一定要结合监控。

queue(任务队列):你的“缓冲区”到底应该多大?

队列是个太多人不重视的参数。队列无限大,等于慢性自杀。我们曾经有个服务把队列设成 LinkedBlockingQueue(Integer.MAX_VALUE)。结果某个接口被外部系统打了 10 倍流量,线程处理不过来,队列一下堆了 近 20 万 条任务。整个服务像中暑了一样:

  • CPU 忙着干 GC
  • 内存被队列撑爆
  • 最后 OOM

后来我总结了一句话:队列不是用来塞无限任务的,是用来撑住瞬时波动的。能接受的队列大小,一般看接口 RT 和平均并发。比如 RT 50ms,平均 QPS 200,那 10% 的余量就是个 1000 级别的队列。

keepAliveTime:线程待太久,我要不要打发它走?

大部分业务可以把它设得稍微小一点,让临时线程尽快关闭。除非你的流量波动巨大(比如某些活动场景),否则留太久也只会浪费资源。我习惯的设置是:

代码语言:txt
复制
IO 密集:30 秒左右
CPU 密集:10 秒左右

拒绝策略到底该怎么选?别默认用 AbortPolicy

Java 内置四种拒绝策略:

1. AbortPolicy:直接扔异常(默认)

适合那些“必须强一致”的接口,比如:

  • 下单扣库存
  • 转账扣钱

你宁愿失败,也不愿排队排出 10 秒。但别忘了 catch,不然你会在线上看到满屏堆栈日志。

2. DiscardPolicy:默默丢掉

我基本不用,风险太高。但有一种情况会用:

“非核心链路”、“可丢数据” 的异步任务,比如埋点日志。

丢了就丢了,用户感知不到。

3. DiscardOldestPolicy:丢掉最旧的一条

这在高峰期有时很有用。某次我们有个“延迟同步用户资料”的任务堆积了,堆积原因是某第三方接口 RT 不稳定。业务逻辑是:后来的任务一般比旧的更有意义(用户最新资料 >旧资料)。所以选这种策略刚好合适。

4. CallerRunsPolicy:让调用线程干活

这个策略有点“自保”意味。当线程池满了就让调用线程处理任务,从而让系统整体节奏降下来。我在压测接口时经常用它。好处是很明显:

  • 不会丢任务
  • QPS 会自然下降
  • 整体不会崩

坏处也明显:

  • 调用线程可能被拖死

所以它不是万能的,但非常好用。

怎么设计业务线程池?别把所有接口都一个线程池

第一次做线程池隔离的时候,我犯了一个致命错误:

所有接口都用一个线程池。

结果是某个 RT 爆炸的接口把线程池占满了,其他接口直接被“绑架”。那次我记得非常清楚:晚上 11 点半,我一个人在会议室被领导围观修复。自那之后,我的线程池策略是这样的:

1. 单线程池负责单类业务

比如:

  • 发送短信、邮件、通知 —— 通知线程池
  • 数据分析计算 —— 计算线程池
  • 外部 API 调用 —— IO 线程池

2. 不同业务线程池参数不一样

举例:

外部接口调用(IO 密集)
代码语言:txt
复制
core 20
max 100
queue 5000
计算密集任务(CPU 密集)
代码语言:txt
复制
core = CPU 核数
max=CPU 核数 + 1 或 2
queue=100~500

你不可能用一样的线程池去做完全不同的任务。

CPU 密集 vs IO 密集,线程池到底怎么设才靠谱?

CPU 密集任务

代表任务:

  • 加密
  • 压缩
  • 图像处理
  • 大量循环计算

关键点:无论你开 100 个线程,它们也要排队等 CPU。

所以线程池设大了完全没意义。

推荐:

代码语言:txt
复制
core = CPU 核心数
max = CPU 核心数 + 2

IO 密集任务

代表任务:

  • RPC 调用
  • HTTP 请求
  • 数据库查询
  • Redis 访问

线程花大量时间在等待上,所以可以多开一些。

推荐:

代码语言:txt
复制
core = CPU 核数 * 2
max = CPU 核数 * 3~5

但注意:如果队列太大,会造成请求延迟无限堆积,导致排队时间比执行时间还长。

定时任务线程池和普通线程池有什么区别?

很多同事以为 ScheduledThreadPoolExecutor 和普通线程池差不多,其实差别很大。

最大的坑:它默认核心线程不会被清理。

而且它的任务可能因为执行时间长导致“任务堆叠”。

我有一次遇到一个定时任务:

  • 每 5 秒执行一次
  • 某天外部接口变慢,每次执行耗时 7 秒
  • 结果任务越堆越多,线程越来越多
  • 最后 CPU 撑爆

解决方法:

代码语言:txt
复制
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(4);
executor.setRemoveOnCancelPolicy(true);
executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);

或者干脆用 XXL-JOB / Quartz,不要自己乱写。

线程池监控怎么做?别等线上炸了才知道它满了

一个“不会监控线程池”的系统,是迟早会炸的。

我最关注三个指标:

1. 活跃线程数(activeCount)

如果一直接近 maxPoolSize,说明池子太小或业务 RT 太长。

2. 队列长度(queueSize)

我会在 Prometheus 里加一个报警:

队列连续 1 分钟 > 80% 容量 → 报警

3. 任务耗时(taskTime)

可以在 wrapper 中记录:

代码语言:java
复制
long start = System.currentTimeMillis();
try {
  runnable.run();
} finally {
  long cost = System.currentTimeMillis() - start;
  if (cost > 200) log.warn("任务耗时过长: {} ms", cost);
}

这比单纯看 RT 有意义得多。

踩过的线程池坑

当时我们搞一个风控缓存刷新任务,每分钟几十个任务,不多。结果某天 Redis 异常,刷新任务卡在 IO 上,队列一路上涨到 5 万条。线程池没报错,但系统就是迟迟不处理,像是“半死不活”。

教训:

大队列不是保险,是隐患。 让系统早点抛错,远比堆成一座山靠谱。

现在的处理方式:

代码语言:txt
复制
队列上限 1000
超过就走 CallerRunsPolicy

把问题暴露在可控范围。

可复用的线程池模板

这个是我在多个项目实战验证过的线程池模板,

特点:

  • 默认日志埋点
  • 默认拒绝策略包装
  • 默认线程命名(排查问题极其重要)
  • 默认监控扩展点
  • 默认打印执行耗时

线程池封装类

代码语言:java
复制
public class BizThreadPool {

    public static ThreadPoolExecutor create(
            String name,
            int core,
            int max,
            int queueSize) {

        return new ThreadPoolExecutor(
                core,
                max,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(queueSize),
                new NamedThreadFactory(name),
                new RejectedHandler(name)
        ) {
            @Override
            protected void beforeExecute(Thread t, Runnable r) {
                r = wrap(r);
                super.beforeExecute(t, r);
            }
        };
    }

    private static Runnable wrap(Runnable runnable) {
        return () -> {
            long start = System.currentTimeMillis();
            try {
                runnable.run();
            } catch (Exception e) {
                log.error("线程池任务执行异常", e);
                throw e;
            } finally {
                long cost = System.currentTimeMillis() - start;
                if (cost > 200)
                    log.warn("任务执行耗时过长:{} ms", cost);
            }
        };
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final AtomicInteger index = new AtomicInteger(1);
        private final String name;

        NamedThreadFactory(String name) {
            this.name = name;
        }

        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r, name + "-" + index.getAndIncrement());
        }
    }

    static class RejectedHandler implements RejectedExecutionHandler {
        private final String name;

        public RejectedHandler(String name) {
            this.name = name;
        }

        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            log.error("线程池[{}]已满:active={}, queue={}", name, 
                e.getActiveCount(), e.getQueue().size());
            throw new RejectedExecutionException("线程池满:" + name);
        }
    }
}

使用方式

代码语言:java
复制
ThreadPoolExecutor executor =
    BizThreadPool.create("order-sync", 8, 32, 2000);

executor.execute(() -> {
    // 业务逻辑
});

这个线程池在我们公司至少支撑过:

  • 618 高峰(接口 QPS 3000)
  • Redis 异常但系统未崩
  • 外部接口抖动场景
  • 黑产攻击流量(RT 暴涨但系统未挂)

你可以根据业务轻松扩展。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 线程池底层到底长啥样?
  • 关键参数
    • corePoolSize:你愿意为系统留多少“常驻战力”?
    • maximumPoolSize:高峰期你的“应急能力”有多强?
    • queue(任务队列):你的“缓冲区”到底应该多大?
    • keepAliveTime:线程待太久,我要不要打发它走?
  • 拒绝策略到底该怎么选?别默认用 AbortPolicy
    • 1. AbortPolicy:直接扔异常(默认)
    • 2. DiscardPolicy:默默丢掉
    • 3. DiscardOldestPolicy:丢掉最旧的一条
    • 4. CallerRunsPolicy:让调用线程干活
  • 怎么设计业务线程池?别把所有接口都一个线程池
    • 1. 单线程池负责单类业务
    • 2. 不同业务线程池参数不一样
      • 外部接口调用(IO 密集)
      • 计算密集任务(CPU 密集)
  • CPU 密集 vs IO 密集,线程池到底怎么设才靠谱?
    • CPU 密集任务
    • IO 密集任务
  • 定时任务线程池和普通线程池有什么区别?
    • 最大的坑:它默认核心线程不会被清理。
  • 线程池监控怎么做?别等线上炸了才知道它满了
    • 1. 活跃线程数(activeCount)
    • 2. 队列长度(queueSize)
    • 3. 任务耗时(taskTime)
  • 踩过的线程池坑
  • 可复用的线程池模板
    • 线程池封装类
    • 使用方式
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档