Java 线程池原理详解:从核心参数到线上调优

282 阅读10分钟

Java 线程池原理详解:从核心参数到线上调优

线程池大概是 Java 里「用起来最简单、却最容易出事故」的组件。创建只要一行代码,七个参数看着也都懂,可真到了线上,问题往往长这样:接口 P99 从 80ms 涨到 3s,日志里一条 RejectedExecutionException 都没有,线程数却一直是 8,队列已经排了三万个任务;或者服务突然 unable to create new native thread,重启后半小时再次复现。

这些现象的背后,其实都指向同一件事:很多人记住了七个参数的名字,但没有记住参数之间真实的生效顺序。所以这篇文章不按「参数说明书」来讲,而是按一次任务提交的真实路径展开:先看线程池解决了什么问题,再拆开七大参数与 ctl 的位运算,然后用 execute() 源码走一遍「核心线程 → 队列 → 最大线程 → 拒绝」这条决策链,接着讲线程是怎么被复用的、池是怎么关闭的、队列怎么选、线程数怎么算,最后落到线上监控、动态调参和故障排查。

全文配 11 张原创 SVG 图解和 12 段代码,读完你应该能同时拿下两类问题:面试里从「线程池有哪几个参数」到「为什么阿里不让用 Executors」的整条追问链,以及线上真出故障时该看哪个指标、敲哪条命令。

1. 为什么需要线程池:先把成本算清楚

Java 线程是操作系统线程的 1:1 映射(HotSpot 下 Thread.start0() 最终调用 pthread_create)。这意味着每创建一个线程,JVM 都要向操作系统申请资源:

  • 线程栈内存:默认 1MB(-Xss),1000 个线程仅栈就吃掉约 1GB 虚拟内存;
  • 内核态开销:创建/销毁线程涉及用户态与内核态切换,CPU 时间被消耗在调度器上,而不是业务逻辑上;
  • 上下文切换:线程数超过 CPU 核数后,切换成本开始抵消并行收益,常见量级是每次切换几微秒;
  • 线程上限:受 ulimit -u、kernel.threads-max、内存等限制,一旦触顶就是 OutOfMemoryError: unable to create new native thread,这类错误通常无法靠加大堆内存解决。

线程池的价值就是把上面这些成本摊薄,并额外拿到两样东西:可控的并发上限(这本身就是一种限流)和统一的管理入口(命名、监控、拒绝策略、优雅关闭)。

图 1:线程池的核心价值——复用线程、限制并发、统一管理

2. 七大参数与 ctl:先看清内部结构

线程池的完整行为由七个参数决定。先把构造函数摆在明面上:

    public ThreadPoolExecutor(int corePoolSize,                      // 核心线程数
                              int maximumPoolSize,                   // 最大线程数
                              long keepAliveTime,                    // 非核心线程空闲存活时间
                              TimeUnit unit,                         // keepAliveTime 的时间单位
                              BlockingQueue<Runnable> workQueue,     // 任务等待队列
                              ThreadFactory threadFactory,           // 线程工厂
                              RejectedExecutionHandler handler) {    // 拒绝策略
        if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize
            || keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

这七个参数里,有四个直接决定容量行为(core、max、keepAlive、queue),一个决定线程长什么样(factory),一个决定过载时怎么处理(handler),还有一个是时间单位。下面这张图把它们的职责与运行时组件对应起来:

图 2:七大参数与 ctl 的职责划分

ctl 是理解线程池状态管理的关键。JDK 用了一个很巧的设计:把「运行状态」和「线程数量」压进同一个 AtomicInteger 的 32 位里,高 3 位存状态(5 个状态需要 3 位),低 29 位存线程数,这样一次 CAS 就能同时保证两者的原子性。

图 3:ctl 的 32 位布局——高 3 位状态、低 29 位线程数

状态比较用 < 就能判断「是否已经关闭」,代码里到处都是 isRunning(c)、rs >= SHUTDOWN 这类写法,根源就在状态值被设计成了单调递增的整数。

3. 任务提交:execute() 的完整决策链

所有的行为分歧都发生在 execute() 里。把 JDK 8 的源码逐段拆开看:

    public void execute(Runnable command) {
        if (command == null) throw new NullPointerException();

        int c = ctl.get();
        // ① 线程数 < core:直接新建核心线程执行
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true)) return;
            c = ctl.get();
        }
        // ② 线程数已达 core:先尝试入队
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            // 入队后池被关闭 → 回滚出队并拒绝
            if (!isRunning(recheck) && remove(command)) {
                reject(command);
            } else if (workerCountOf(recheck) == 0) {
                // 有队列没线程(核心线程都被回收且允许超时)→ 补一个
                addWorker(null, false);
            }
        }
        // ③ 入队失败(队列满):再扩到 max
        else if (!addWorker(command, false)) {
            // ④ 已达 max 且队列满:拒绝
            reject(command);
        }
    }

这段代码里藏着三个高频误解:

**误解一:线程数到 core 之后继续建线程。**事实相反,先入队。这是为了尽量复用已创建的线程,避免线程频繁创建销毁。只有当队列也满了,才会把线程数扩到 maximumPoolSize。

**误解二:maximumPoolSize 是「线程数上限」,所以设大点没坏处。**在无界队列(如 LinkedBlockingQueue 不传容量)下,队列永远不会满,第 ③ 步根本走不到,maximumPoolSize 形同虚设——这正是 Executors.newFixedThreadPool 的隐患所在。

**误解三:入队成功就一定会被执行。**不一定,入队后还会 recheck 一次:如果此刻线程池已经关停,任务会被 remove() 回滚出队并交给拒绝策略,避免任务滞留在队列里永远不执行。

图 4:execute() 的四步决策链与每步的边界条件

**一句话记住顺序:**先看核心线程,再看队列,最后才看最大线程。这个顺序决定了线程池的一个反直觉特性——线程池是否「愿意」扩容,取决于你的队列有多小。想让线程数快速涨起来,就要用小容量队列(如 ArrayBlockingQueue)或 SynchronousQueue。

4. 线程复用:Worker 与 runWorker 循环

线程池能复用线程,靠的是 Worker 类。它是一个 AbstractQueuedSynchronizer 子类(因此本身就是一个锁),同时实现了 Runnable:

    private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
        final Thread thread;              // Worker 持有并启动的线程
        Runnable firstTask;               // 第一个任务(可为 null)
        volatile long completedTasks;     // 已完成任务数(监控用)

        Worker(Runnable firstTask) {
            setState(-1);                 // 禁止在 runWorker 前被中断
            this.firstTask = firstTask;
            this.thread = getThreadFactory().newThread(this);  // 把自己交给线程
        }
        public void run() { runWorker(this); }                // 线程启动后进入循环
    }

关键在于 runWorker(this) 里的这个 while 循环——线程执行完一个任务后不会退出,而是继续去队列里取下一个任务:

    final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask;
        w.firstTask = null;
        w.unlock();                                  // 允许中断
        boolean completedAbruptly = true;
        try {
            // 循环:先执行首任务,然后不断 getTask() 从队列取任务
            while (task != null || (task = getTask()) != null) {
                w.lock();                            // 加锁:shutdownNow 时才允许中断
                try {
                    beforeExecute(wt, task);         // 扩展点:埋点、MDC 传递
                    try {
                        task.run();                  // 执行用户任务
                    } finally {
                        afterExecute(task, null);    // 扩展点:清理 ThreadLocal 等
                    }
                } finally {
                    task = null; w.completedTasks++; w.unlock();
                }
            }
            completedAbruptly = false;
        } finally {
            // 循环退出:移除 Worker,必要时补线程
            processWorkerExit(w, completedAbruptly);
        }
    }

而 getTask() 决定了「什么时候这个线程该退出」:

  • 线程池已 STOP 或正在关闭且队列为空 → 返回 null,线程退出;
  • 当前线程数超过 corePoolSize(或设置了 allowCoreThreadTimeOut)→ 用 poll(keepAliveTime) 超时获取,等不到任务就返回 null;
  • 否则用 take() 无限阻塞等任务——这就是核心线程「常驻」的实现方式;
  • 返回 null 前会 CAS 减少 workerCount,保证线程数统计准确。

图 5:线程复用的实现路径与 getTask 的退出条件

**实操建议:**因为 beforeExecute / afterExecute 是线程池留出的官方扩展点,MDC 透传、任务耗时埋点、ThreadLocal 清理都应该写在这两个方法里(覆写 ThreadPoolExecutor 子类),而不是散落在每个业务任务的 try-finally 中。

5. 五种运行状态与优雅关闭

线程池不是「开着就一直开着」的。它由 ctl 高 3 位维护五种状态,流转方向基本是单向的:

状态值含义与行为
RUNNING-536870912接受新任务,并处理队列中的任务
SHUTDOWN0不再接受新任务,但会继续处理完队列里的任务;中断空闲线程
STOP536870912不再接受新任务,不处理队列任务,并中断正在执行的任务
TIDYING1073741824所有任务已终止、线程数为 0,即将执行 terminated() 钩子
TERMINATED1610612736terminated() 执行完毕,线程池彻底结束

图 6:五种状态与流转条件

shutdown() 与 shutdownNow() 的差异经常被面试追问,也经常在生产上出错:

  • shutdown():状态置为 SHUTDOWN,中断所有空闲线程(interruptIdleWorkers),队列中剩余任务会继续执行完;
  • shutdownNow():状态置为 STOP,中断所有线程(包括正在执行任务的),并返回队列中未执行的任务列表。注意:中断只是「通知」,如果任务不响应中断(没有 InterruptedException 或 isInterrupted() 检查),它会继续跑完。

因为 shutdown() 不会等待任务执行完,生产上稳妥的关闭姿势是「先温和、再等待、最后强制」:

    public void gracefulShutdown(ThreadPoolExecutor pool, long timeout, TimeUnit unit) {
        pool.shutdown();                              // ① 停止接收新任务,队列任务继续跑
        try {
            if (!pool.awaitTermination(timeout, unit)) {   // ② 等待一段「合理」时间
                List<Runnable> dropped = pool.shutdownNow(); // ③ 超时则强制中断
                log.warn("线程池未能优雅关闭,丢弃 {} 个任务", dropped.size());
                pool.awaitTermination(5, TimeUnit.SECONDS);
            }
        } catch (InterruptedException e) {
            pool.shutdownNow();
            Thread.currentThread().interrupt();       // 恢复中断标记,别吞掉
        }
        log.info("线程池已关闭,活跃线程={}, 队列剩余={}",
                 pool.getActiveCount(), pool.getQueue().size());
    }

**容易漏掉的一点:**Web 应用里线程池如果不随容器关闭而关闭,会表现为「应用明明重启了,日志还在打旧任务的输出」,或者类加载器无法卸载导致 Metaspace 缓慢泄漏。Spring 环境下让线程池实现 DisposableBean / SmartLifecycle,或在 @PreDestroy 里调用上面的关闭方法;Servlet 容器也可以注册 ServletContextListener 兜底。

6. 拒绝策略:过载时唯一能做的「正确的事」

当队列满且线程数达到 maximumPoolSize 时,线程池必须做出选择。JDK 提供了四种策略,它们的差别远不止「抛不抛异常」:

图 7:四种拒绝策略的行为与代价对比

四种策略都不能满足「先记一笔、再短暂重试、实在不行才失败」这类诉求,所以生产上常见做法是自定义一个:

    public class MonitoringRejectPolicy implements RejectedExecutionHandler {
        private static final Logger log = LoggerFactory.getLogger(MonitoringRejectPolicy.class);
        private final Counter rejectedCounter;   // 指标:Micrometer / Prometheus Counter

        public MonitoringRejectPolicy(Counter rejectedCounter) {
            this.rejectedCounter = rejectedCounter;
        }

        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            rejectedCounter.increment();
            log.error("线程池过载拒绝:poolSize={}, activeCount={}, queueSize={}, largestPoolSize={}",
                      e.getPoolSize(), e.getActiveCount(), e.getQueue().size(), e.getLargestPoolSize());
            throw new RejectedExecutionException("task queue is full, rejected by policy");
        }
    }

一个反直觉的结论:CallerRunsPolicy 常常是「不加机器也不崩」的最优解——它把压力原路退回给调用方,让上游的 QPS 自然下降。但它有两个前提:调用方线程允许被占用较长一段时间(不能是 Netty EventLoop 这类 I/O 线程),并且下游任务一定会结束(如果任务会阻塞很久,等于把 Tomcat 线程池一起拖死)。

7. 队列选择与 Executors 的坑

队列是线程池里最被低估的参数。它的类型和容量,直接决定了线程池过载时会「堆积」还是「扩容」还是「拒绝」:

队列类型是否有界特点典型用途
LinkedBlockingQueue可无界(默认 Integer.MAX_VALUE)链表结构,吞吐高;不传容量即无界,maximumPoolSize 失效容量可控的缓冲队列(务必指定容量)
ArrayBlockingQueue有界数组 + 单锁,容量固定,队列满会触发扩容与拒绝需要强背压、容量明确的场景
SynchronousQueue无容量不存储元素,必须有线程立刻接手,否则扩容或拒绝任务极短、希望线程数弹性伸缩
PriorityBlockingQueue无界按优先级出队,无容量限制,存在堆积风险任务有明确优先级(告警优先于报表)
DelayQueue无界延迟到期才能取,常配合定时任务延迟执行、重试退避

队列容量不是一个可以随手填的数字,它等于「你允许任务最多等多久」。一个可用的估算方式:

  • 先明确可接受的排队时延上限,比如核心接口 P99 排队不超过 200ms;
  • 再按稳态处理能力反推:队列容量 ≈ (峰值 QPS − 最大处理能力) × 允许排队时长;
  • 例如峰值 2000 QPS、最大处理能力 1200 QPS、允许等 0.2s → 容量约 160;
  • 再加上一点余量(如 1.5 倍)与明确的拒绝兜底,就得到具体数值。填完不要当结论,压测校准。

图 8:Executors 快捷工厂方法的隐藏代价

所以更推荐手写创建,把每个参数的选择理由都留在代码里:

    ThreadPoolExecutor pool = new ThreadPoolExecutor(
            8,                                   // core:按下方公式估算,先给初值
            16,                                  // max:队列满时的应急容量
            60L, TimeUnit.SECONDS,               // 非核心线程空闲 60s 回收
            new ArrayBlockingQueue<>(200),        // 有界队列:让扩容与拒绝真正生效
            new NamedThreadFactory("order-async-"),   // 可读线程名 + 异常处理器
            new MonitoringRejectPolicy(rejectedCounter));

    pool.allowCoreThreadTimeOut(true);           // 低峰期连核心线程一起回收
    pool.prestartAllCoreThreads();               // 启动即预热,避免首波请求慢

**三个「一上手就该加」的配置:**① 自定义 ThreadFactory 给线程起名(order-async-1 比 pool-3-thread-1 在 jstack 里价值大得多);② 线程工厂里设置 UncaughtExceptionHandler,否则任务抛出的异常会被线程池吞掉,只留下「任务莫名消失」的诡异现象;③ prestartAllCoreThreads() 预热,避免冷启动后的第一批请求等待线程创建。

8. 核心线程数到底怎么定

「线程数设多少」是线程池里最没有标准答案、但最有思考路径的一个问题。可以从任务类型切入:

  • CPU 密集型(加解密、序列化、复杂计算、图片处理):线程数约等于 CPU 核数 + 1。多的线程只会增加上下文切换。加 1 是为了在某个线程偶发缺页中断或被短暂阻塞时,CPU 不空转。
  • IO 密集型(远程调用、数据库查询、文件读写):线程的大部分时间在等待,可以用更多线程填满 CPU。经典公式是 线程数 = 核数 × (1 + 等待时间 / 计算时间)。纯等待型任务(例如只有一次 RPC)等待/计算比很高,2N~2N+1 是常见经验起点。
  • 混合型:优先考虑拆分,把 CPU 密集与 IO 密集任务分到不同线程池做隔离,避免互相拖累;无法拆分时按占比加权估算,或干脆按 IO 型设置后再压测收敛。

图 9:线程数估算的三条路径与压测收敛闭环

把公式落到代码里,最实用的版本不是算出一个精确数字,而是「先算、再观测、再调」:

    // 例:单个任务 = 2ms 计算 + 8ms 等 RPC,8 核机器
    int cores = Runtime.getRuntime().availableProcessors();   // 8
    double waitRatio = 8.0 / (8.0 + 2.0);                     // 等待占比 0.8
    int coreSize = (int) Math.ceil(cores / (1 - waitRatio));  // 40
    // 但 40 只是理论值:还要受下游连接池上限、下游承载能力约束
    int core = Math.min(coreSize, 32);                        // 被下游限流能力压回来
    log.info("估算 core={}(理论上限 {},受下游容量约束)", core, coreSize);

**Little's Law 视角:**并发数 = 到达率 × 平均处理时间。如果线程池要支撑 500 QPS、单任务平均 100ms,那么稳态需要的并发就是 50 个线程——这与「公式」可以互相校验。用这个视角看问题时,你会发现「线程数」本质上是在为下游的响应时间买单。

9. 线上调优与故障排查

9.1 先建立可观测性

线程池的六个指标几乎覆盖了所有故障场景,值得全部接入监控:

指标含义异常时的信号
getActiveCount()正在执行任务的线程数长期等于 max → 容量不足,任务在排队
getPoolSize()当前线程总数长期等于 core 且队列在涨 → 队列太大 / max 失效
getQueue().size()队列积压长度持续上升 → 消费能力跟不上,即将雪崩
getLargestPoolSize()历史峰值线程数远小于 max → max 设大了;等于 max → 曾打到上限
getCompletedTaskCount()累计完成数增速骤降 → 处理能力劣化
拒绝次数(自定义计数器)过载次数任何非零都值得看一眼,突增意味着容量已到顶

图 10:指标、告警与动态调参组成的治理闭环

9.2 动态调参为什么能生效

很多人以为 setCorePoolSize 只是改个字段,其实它会主动「推」线程一把:

    // JDK 8 ThreadPoolExecutor#setCorePoolSize(简化)
    public void setCorePoolSize(int corePoolSize) {
        if (corePoolSize < 0) throw new IllegalArgumentException();
        int delta = corePoolSize - this.corePoolSize;
        this.corePoolSize = corePoolSize;
        if (workerCountOf(ctl.get()) > corePoolSize) {
            interruptIdleWorkers();          // 缩容:中断空闲线程,让它们从 getTask() 退出
        } else if (delta > 0) {
            // 扩容:有多少新容量就补多少线程(最多补到新 core 值)
            int k = Math.min(delta, workQueue.size());
            while (k-- > 0 && addWorker(null, true)) {
                if (workQueue.isEmpty()) break;   // 队列都空了,不用再补
            }
        }
    }

    private void interruptIdleWorkers() {
        // 关键实现:tryLock() 成功 = 该 Worker 空闲(正在执行任务时锁被持有)
        // 因此这一操作只会唤醒空闲线程,不会打断正在执行的任务
        for (Worker w : workers) {
            Thread t = w.thread;
            if (!t.isInterrupted() && w.tryLock()) {
                try { t.interrupt(); } finally { w.unlock(); }
            }
        }
    }

这里 Worker 的 AQS 锁设计就体现出价值了:正在执行任务的 Worker 持有锁,所以不会被误中断。同时也解释了一个常被误解的点——interruptIdleWorkers() 的名字确实准确,它只处理空闲线程。

9.3 四类典型线上故障与排查动作

图 11:四类典型故障的现象、根因与处置动作

两条常用命令,值得贴进值班手册:

    # ① jstack 找线程池里的阻塞点:看线程名(所以一定要自定义 ThreadFactory)
    jstack <pid> | grep -A 12 "order-async-"

    # 典型「任务不执行」的栈形态:大量线程停在队列取任务上,说明是任务处理慢而非线程不够
    "order-async-3" #12 prio=5 os_prio=0 tid=0x... nid=0x... waiting on condition [0x...]
       java.lang.Thread.State: WAITING (parking)
            at sun.misc.Unsafe.park(Native Method)
            at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
            at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
            at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)

    # ② arthas 看线程池内部状态与最忙线程
    thread --all | grep order-async
    thread -n 3          # 查看 CPU 占用最高的 3 个线程,直接看到业务方法名

9.4 三个最容易埋雷的写法

**第一个是父子任务提交到同一个池(死锁)。**父任务占满所有线程并等待子任务结果,而子任务还在队列里排队等线程——双方互等,池彻底僵住:

    // 反例:父任务占满线程后阻塞等待子任务
    ExecutorService pool = new ThreadPoolExecutor(4, 4, 0L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(1000), new NamedThreadFactory("batch-"));
    Future<Result> f = pool.submit(() -> {
        Future<Integer> inner = pool.submit(() -> queryRemote());  // 队列里等线程
        return inner.get(3, TimeUnit.SECONDS);                      // 父任务占着线程等
    });
    // 4 个父任务同时进来 → 所有线程都在等子任务,子任务永远拿不到线程 → 超时或永久阻塞
    // 修法:子任务用独立的池,或改用 ForkJoinPool / CompletableFuture 的异步编排

**第二个是任务里没有超时。**一次不设超时的 HTTP 调用、一次没有 socketTimeout 的数据库查询,就能把线程池里的线程逐个钉死。所有外部依赖调用都必须有超时,并且超时值要小于上游的等待时间。

**第三个是ThreadLocal 与 MDC 没清理。**线程被复用意味着「上一个任务留下的上下文」会跟着进入下一个任务:

    // 反例:ThreadLocal 未清理 → 线程复用后串数据,且强引用导致内存泄漏
    pool.execute(() -> {
        UserContext.set(currentUser);           // 设置上下文
        doBusiness();                          // 途中抛异常,下面这行不执行
        UserContext.remove();                  // 永远执行不到
    });
    // 修法 1:任务内用 try-finally 包住,确保 remove()
    // 修法 2(推荐):覆写 beforeExecute / afterExecute 统一清理
    public class ContextAwarePool extends ThreadPoolExecutor {
        @Override protected void beforeExecute(Thread t, Runnable r) { MDC.clear(); }
        @Override protected void afterExecute(Runnable r, Throwable t) {
            MDC.clear(); UserContext.remove();   // 无论成功失败都会执行
        }
    }

**还有一个隐蔽陷阱:**线程池的并发上限不等于系统的并发上限。如果多个池共享同一批下游资源(数据库连接池 20、Redis 连接池 50),即便每个池都只开 10 个线程,多池叠加也会把下游连接池打满,表现为「线程池很闲,但任务都在等连接」。所以容量规划要沿调用链一起看。

10. 面试速查表:十个高频问题

问题关键回答
七大参数分别是什么?core、max、keepAliveTime、unit、workQueue、threadFactory、handler
任务提交的顺序是怎样的?core 没满 → 建核心线程;否则入队;队列满 → 扩到 max;仍不行 → 拒绝
为什么线程数到 core 后先入队?优先复用已有线程,减少线程创建销毁开销;也避免线程数频繁抖动
无界队列有什么问题?队列永远不满,走不到扩容分支,max 失效;任务无限堆积直至 OOM
线程是怎么复用的?Worker 实现 Runnable,runWorker 里 while 循环调 getTask() 反复取任务
核心线程会销毁吗?会:allowCoreThreadTimeOut(true)、任务抛异常导致线程异常退出、池关闭
shutdown 与 shutdownNow 的区别?前者中断空闲线程并处理完队列;后者中断所有线程、丢弃队列并返回未执行任务
为什么要用 CallerRunsPolicy?提供天然背压,让上游变慢而不是把任务丢掉;注意会占用调用方线程
线程数怎么定?CPU 型 N+1;IO 型 N×(1+W/C) 常见 2N~2N+1;再用压测与监控收敛
为什么阿里禁止用 Executors?无界队列或无限线程会导致 OOM;参数语义被隐藏,事故难以发现

11. 小结

把线程池用对,靠的不是背参数,而是记住三条主线:

  1. **顺序决定行为。**core → 队列 → max → 拒绝,这意味着队列容量其实在控制「线程池愿不愿意扩容」。想让线程数弹性起来,队列必须小且有界。
  2. **一切过载都要有出口。**线程池把过载转换成队列积压,队列把过载转换成拒绝,拒绝必须能被观测到。缺了监控,线程池就变成了「静默丢任务的黑洞」。
  3. **线程池是隔离工具,而不是万能加速器。**不同业务分池、下游容量对齐、超时与降级配套,才是把线程池真正用稳的做法。

如果只带走一句话:先把队列改成有界、把拒绝策略接上监控、把线程名改成可读的业务名——这三件事做完,你已经能拦住绝大多数线上线程池事故了。