Skip to content

线程池介绍

1. ThreadPoolExecutor 的七个核心参数

线程池的核心是 ThreadPoolExecutor,它的构造参数决定了“并发上限、排队策略与背压方式”。

参数 作用 关键影响
corePoolSize 核心线程数 小于该值时优先新建线程执行
maximumPoolSize 最大线程数 队列满后是否还能扩容线程
keepAliveTime + unit 非核心线程空闲回收时间 回收非核心线程,释放资源
workQueue 任务队列 决定排队语义与是否“无界堆积”
threadFactory 线程工厂 线程命名、是否守护线程、异常处理
rejectedExecutionHandler 拒绝策略 饱和时如何背压(抛错/降速/丢弃)

补充点:allowCoreThreadTimeout(true) 可以让核心线程也参与空闲回收,但要结合业务流量形态评估。

2. workQueue 怎么选

常见队列语义:

  • ArrayBlockingQueue:有界队列,容量可控,推荐线上优先考虑。
  • LinkedBlockingQueue:可配置容量,但很多工厂方法默认是无界,容易堆积导致 OOM 或延迟飙升。
  • SynchronousQueue:不存储任务,直接交接;容易触发线程数扩到 maximumPoolSize

队列的选择决定了“突发流量是排队还是扩线程”,也决定了故障形态是“拒绝”还是“堆积”。

3. 拒绝策略

拒绝发生的典型条件:

  • 线程池已关闭仍提交任务。
  • 线程数达到 maximumPoolSize 且队列已满。

ThreadPoolExecutor 内置四种策略:

  1. AbortPolicy:默认,抛 RejectedExecutionException,让调用方显式感知失败。
  2. CallerRunsPolicy:调用者线程执行任务,起到“主动降速”的背压效果。
  3. DiscardPolicy:直接丢弃任务(静默),只适合极少数允许丢的场景。
  4. DiscardOldestPolicy:丢弃队列最旧任务再尝试入队,适合“保最新、可丢历史”的场景。

线上常见做法是自定义拒绝策略:记录日志与指标,并明确降级路径,避免静默丢任务。

4. Java 创建线程池时需要注意什么

创建线程池不是“随便 new 一个 ExecutorService”就结束了,真正要考虑的是:

  • 线程数是否合理。
  • 队列是否可控。
  • 饱和后系统是降速、失败,还是把内存打爆。
  • 任务异常、上下文、关闭流程是否可观测、可治理。

4.1 不要在线上默认使用 Executors 的几个工厂方法

面试和生产实践里,这几乎是必考点。

原因是 Executors 提供的若干快捷方法,默认参数容易埋坑:

  • newFixedThreadPool():底层通常配无界 LinkedBlockingQueue,流量堆积时容易造成内存膨胀、延迟失控。
  • newSingleThreadExecutor():本质也是无界队列,只是变成单线程串行执行。
  • newCachedThreadPool():底层是 SynchronousQueue,线程数理论上可无限增长,突发流量下容易把 CPU、内存、上下文切换打满。
  • newScheduledThreadPool():适合定时任务,但如果任务执行时间长、任务堆积严重,同样会出现延迟和资源争用问题。

因此线上更推荐显式使用 ThreadPoolExecutor,把:

  • 核心线程数。
  • 最大线程数。
  • 队列容量。
  • 线程工厂。
  • 拒绝策略。

都写清楚、管起来。

4.2 线程数要看任务类型

线程池大小和任务类型强相关。

常见经验:

  • CPU 密集型任务:线程数通常接近 CPU 核数CPU 核数 + 1
  • I/O 密集型任务:线程数可以适当大一些,因为线程大量时间在等待网络、磁盘、数据库。
  • 混合型任务:最好拆池,不要把 CPU 计算和慢 I/O 混在同一个池里。

如果线程数过小,会导致吞吐上不去;如果线程数过大,会导致:

  • 上下文切换增加。
  • CPU 抖动。
  • GC 压力增大。
  • 数据库连接池、下游服务被打满。

所以线程池容量不能脱离下游资源单独配置。

4.3 队列必须尽量有界

无界队列看上去“很安全”,因为不容易触发拒绝,但它把问题从“失败”变成了“无限堆积”。

典型后果:

  • 请求持续排队,延迟越来越大。
  • 任务对象堆积导致 OOM。
  • 上游以为系统还活着,实际上已经进入“假性可用、真实不可用”状态。

因此线上一般优先考虑:

  • ArrayBlockingQueue
  • 指定容量的 LinkedBlockingQueue

核心思想是:宁可有边界地失败,也不要无边界地堆积。

4.4 拒绝策略一定要和业务语义匹配

拒绝策略不是“最后兜底的小配置”,而是线程池的背压设计。

例如:

  • 核心链路任务:通常不能静默丢弃,适合显式报错或降级。
  • 可延迟但不能丢的任务:可以考虑调用方阻塞、降速、落消息队列。
  • 非核心、可丢弃任务:才可能考虑 DiscardPolicy 一类策略。

很多线上事故不是线程池“满了”,而是满了以后行为不明确

4.5 线程工厂不要省略

自定义 ThreadFactory 通常至少要做三件事:

  1. 给线程起可识别的名字。
  2. 决定是否为守护线程。
  3. 配置 UncaughtExceptionHandler

线程名非常重要,因为它直接决定:

  • jstack 能不能快速定位问题。
  • 监控平台能不能区分不同线程池。
  • 日志排查是否高效。

示例:

ThreadFactory factory = new ThreadFactory() {
    private final AtomicInteger seq = new AtomicInteger(1);

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, "order-worker-" + seq.getAndIncrement());
        t.setUncaughtExceptionHandler((thread, ex) ->
                log.error("thread {} crashed", thread.getName(), ex));
        return t;
    }
};

4.6 注意 execute()submit() 的异常差异

这是 Java 线程池里非常容易忽视的点。

  • execute():任务抛出的运行时异常会走线程的未捕获异常处理逻辑。
  • submit():异常会被封装进 Future,如果调用方不执行 get(),异常很容易被“吞掉”。

所以如果使用 submit(),要明确:

  • 谁来消费 Future
  • 谁来记录任务失败。
  • 是否需要统一包装任务埋点和异常日志。

否则你会看到“任务没成功,但也没人报警”。

4.7 不要把不同性质的任务混在一个线程池

典型反模式:

  • 把快任务和慢任务放在一个池里。
  • 把 RPC 调用、数据库写入、文件 I/O、CPU 计算都扔进同一个池。
  • 把核心链路任务和低优先级异步任务共用同一个池。

这样很容易出现:

  • 慢任务把线程全占满。
  • 快任务排队甚至超时。
  • 整个服务被低优先级任务拖垮。

工程上通常要做线程池隔离

  • 按业务隔离。
  • 按任务类型隔离。
  • 按优先级隔离。
  • 按下游依赖隔离。

4.8 警惕线程池里的阻塞和嵌套等待

线程池最怕的不是“有任务”,而是“任务把线程卡住”。

典型风险包括:

  • 任务内部执行慢 SQL、慢 RPC、长时间锁等待。
  • 一个线程池任务内部再提交子任务到同一个线程池,并同步等待结果。
  • Future#get()CountDownLatch.await()join() 等阻塞调用把工作线程耗尽。

最经典的死锁场景是:

  1. 线程池只有少量线程。
  2. 父任务占住线程后,又向同一个池提交子任务。
  3. 父任务同步等子任务结果。
  4. 子任务因为没有空闲线程永远无法执行。

所以线程池设计时要特别小心池内嵌套提交 + 同步等待

4.9 在线程池里使用 ThreadLocal 必须手动清理

线程池线程会复用,所以 ThreadLocal 的问题比普通新建线程更严重。

如果不清理,容易出现:

  • 上一个请求的上下文串到下一个请求。
  • 用户信息、租户信息、TraceId 污染。
  • 长生命周期线程挂住对象,造成内存泄漏。

正确做法通常是:

executor.execute(() -> {
    try {
        contextHolder.set(ctx);
        doWork();
    } finally {
        contextHolder.remove();
    }
});

4.10 关闭线程池要规范,不要只管创建不管回收

线程池是资源,需要明确生命周期。

至少要考虑:

  • 应用停止时是否调用 shutdown()
  • 是否需要 awaitTermination() 等待任务收尾。
  • 超时后是否 shutdownNow()
  • 容器托管环境下是否交给框架统一管理。

这里还有一个很容易被问到的问题:调用 shutdown() 以后,线程会不会立刻停止?

答案是:不会立刻停止。

  • shutdown() 的语义是拒绝接收新任务,但会继续把队列里已经提交的任务正在执行的任务跑完。
  • 所以调用 shutdown() 后,线程池通常会进入“停止接单,但处理存量任务”的状态,而不是马上把工作线程杀掉。

shutdownNow() 也不是“保证立刻停掉所有线程”,它做的是:

  • 尝试中断正在执行的工作线程;
  • 把队列里还没开始执行的任务直接返回给调用方。

但要注意,中断只是通知,不是强杀。如果任务代码:

  • 没有响应中断;
  • 卡在不支持中断的阻塞里;
  • 自己吞掉了中断信号继续执行;

那么即使调用了 shutdownNow(),任务也可能不会马上结束。

所以更准确的理解是:

  • shutdown()平滑关闭,不会立刻停。
  • shutdownNow()尽力快速关闭,但也不保证正在运行的任务瞬间停止。

工程上更推荐的关闭顺序通常是:

  1. shutdown(),停止接收新任务。
  2. awaitTermination(),给存量任务一个收尾时间。
  3. 超时后再 shutdownNow(),尝试中断仍未结束的任务。

如果线程池不关闭,常见后果有:

  • JVM 无法退出。
  • 线程泄漏。
  • 定时任务、后台任务在应用停机时仍继续运行。

4.11 要有监控,不要等线程池打满才知道

线程池属于必须重点监控的基础设施。

建议至少监控:

  • 当前线程数。
  • 活跃线程数。
  • 队列长度。
  • 任务完成数。
  • 拒绝次数。
  • 任务平均耗时、P99 耗时。

如果线程池没有监控,线上出问题时通常只能看到一个现象:

  • 请求慢了。
  • 但不知道是线程池满了、队列堆了、任务阻塞了,还是下游慢了。

5. 一个更适合线上使用的线程池示例

下面给一个比 Executors.newFixedThreadPool() 更适合线上治理的最小示例:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
        8,
        16,
        60L,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(500),
        new NamedThreadFactory("order-worker"),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

executor.allowCoreThreadTimeOut(false);

这个配置体现了几个原则:

  • 线程数有明确上限。
  • 队列有界。
  • 线程有名字。
  • 饱和时通过 CallerRunsPolicy 给调用方施加背压。

它不一定适合所有业务,但比默认无界队列或无限扩线程更可控。

6. 面试里怎么回答“创建线程池要注意什么”

  • 线上尽量不要直接用 Executors 的默认工厂方法,因为很多默认实现要么无界队列,要么线程数可能无限扩张。
  • 线程池参数要结合任务类型来定,区分 CPU 密集型和 I/O 密集型,核心线程数、最大线程数和队列容量都要有依据。
  • 队列尽量有界,拒绝策略要和业务语义匹配,不能让任务无限堆积。
  • 要自定义线程工厂,做好线程命名、异常处理和监控。
  • 要做线程池隔离,避免慢任务拖死快任务,避免线程池内部嵌套等待导致饥饿和死锁。
  • 在线程池中使用 ThreadLocal 要手动清理,线程池也要有规范的关闭流程和监控告警。