Skip to content

线程池设计

1. 这是什么

线程池用于复用线程资源、限制并发度、减少线程创建销毁成本。
它是 Java 并发开发里最常见也最容易出问题的工具之一。

一句话理解:

  • 线程池不是“让代码变异步”的小工具
  • 它更像“线程资源配额中心”

2. 为什么重要

线程池参数配置不当,轻则吞吐下降,重则把系统、数据库、缓存、下游接口一起拖垮。
高级开发者需要理解:

  • 线程池是容量控制工具
  • 也是系统保护机制

如果线程池设计不好,常见后果包括:

  • 任务无限堆积
  • 请求排队时间暴涨
  • 线程数过高导致上下文切换激增
  • 下游资源被并发打爆

3. 先建立直觉:线程池到底在管理什么

线程池本质上在管理三件事:

维度问题
线程数量同时允许多少任务并发执行
排队容量暂时执行不过来时,允许积压多少任务
拒绝策略当线程和队列都撑满时怎么办

所以线程池从来不只是:

  • “开几个线程”

而是完整的资源调度和保护设计。

4. 核心内容

4.1 ThreadPoolExecutor 的关键参数

最重要的几个参数是:

  • corePoolSize
  • maximumPoolSize
  • keepAliveTime
  • workQueue
  • threadFactory
  • RejectedExecutionHandler

4.2 核心线程数和最大线程数怎么配合

可以先用这个顺序理解任务进入线程池后的路径:

  1. 先看核心线程是否还没满
  2. 没满就优先创建核心线程执行
  3. 核心线程满了,任务先尝试入队
  4. 队列也满了,再尝试扩到最大线程数
  5. 如果最大线程数也满了,就走拒绝策略

这条路径非常关键,因为很多线程池问题都源于对它理解不清。

4.3 队列容量为什么重要

队列并不是“越大越安全”。

队列太大可能导致:

  • 任务大量积压
  • 请求延迟变长
  • 问题被隐藏得更深
  • 内存压力变大

队列太小则可能导致:

  • 拒绝更早发生
  • 波峰期更容易触发降级

真正的关键是:

  • 队列大小要和业务可接受延迟、下游容量、失败策略一起设计

4.4 线程工厂为什么不能忽略

自定义 ThreadFactory 的常见价值:

  • 给线程命名,方便排障
  • 设置是否守护线程
  • 统一设置优先级或上下文

线上排查线程池问题时,线程名非常重要。
如果全是默认名字,定位成本会明显增加。

4.5 拒绝策略为什么是系统保护机制

线程池满了以后,必须有明确策略。
常见拒绝策略包括:

  • 直接抛异常
  • 由调用线程执行
  • 丢弃任务
  • 丢弃最旧任务

它们没有绝对好坏,重点在于:

  • 你的系统希望“怎么失败”

这就是线程池为什么本质上也是系统保护机制。

4.6 CPU 密集型和 I/O 密集型任务的差异

CPU 密集型

特点:

  • 任务主要消耗 CPU
  • 线程太多会导致大量上下文切换

通常思路:

  • 线程数接近 CPU 核数或略高

I/O 密集型

特点:

  • 任务大量时间在等待 I/O
  • 线程可以适当多一些

通常思路:

  • 线程数可比 CPU 核数高,但不能无限加

4.7 为什么不建议直接使用默认线程池工厂

Executors 提供的某些默认工厂虽然方便,但在生产环境里容易埋坑,例如:

  • 队列过大或无界
  • 最大线程数过高
  • 缺少业务可观测配置

工程上更推荐:

  • 显式使用 ThreadPoolExecutor
  • 明确核心参数、线程名和拒绝策略

5. 学习重点

这一章最重要的是建立这些判断:

  • 线程数不是越大越好
  • 队列不是越大越安全
  • 拒绝策略不是兜底细节,而是架构选择
  • 不同类型任务应该尽量分开线程池
  • 线程池本质上承担容量控制和隔离职责

6. 常见问题

6.1 直接使用默认线程池工厂

方便不等于适合生产。
你需要的是可控,而不是省两行代码。

6.2 任务无限堆积却没有监控

线程池如果只有“能跑”而没有:

  • 活跃线程监控
  • 队列长度监控
  • 拒绝次数监控

问题往往要等到系统变慢后才暴露。

6.3 一个线程池承载完全不同类型的任务

例如把:

  • 核心接口任务
  • 慢 I/O 任务
  • 批量后台任务

全塞到一个池里,会导致相互拖累。

7. 动手验证

这一节可以直接复制运行,边看边验证。

7.1 准备一个可运行示例

新建文件 ThreadPoolDesignDemo.java,内容如下:

java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadPoolDesignDemo {
    public static void main(String[] args) throws Exception {
        AtomicInteger threadId = new AtomicInteger(0);
        AtomicInteger rejected = new AtomicInteger(0);
        AtomicInteger active = new AtomicInteger(0);
        AtomicInteger maxActive = new AtomicInteger(0);
        CountDownLatch release = new CountDownLatch(1);
        CopyOnWriteArrayList<String> threadNames = new CopyOnWriteArrayList<>();

        ThreadFactory threadFactory = task -> {
            Thread thread = new Thread(task);
            thread.setName("biz-pool-" + threadId.incrementAndGet());
            return thread;
        };

        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                1,
                2,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1),
                threadFactory,
                (task, pool) -> rejected.incrementAndGet());

        Runnable task = () -> {
            threadNames.add(Thread.currentThread().getName());
            int current = active.incrementAndGet();
            maxActive.updateAndGet(old -> Math.max(old, current));
            try {
                release.await();
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                active.decrementAndGet();
            }
        };

        executor.execute(task);
        executor.execute(task);
        executor.execute(task);
        executor.execute(task);

        Thread.sleep(200);
        System.out.println("corePoolSize=" + executor.getCorePoolSize());
        System.out.println("maximumPoolSize=" + executor.getMaximumPoolSize());
        System.out.println("largestPoolSize=" + executor.getLargestPoolSize());
        System.out.println("queueSizeBeforeRelease=" + executor.getQueue().size());
        System.out.println("rejectedCount=" + rejected.get());
        System.out.println("threadNamePrefixOk=" + threadNames.stream().allMatch(name -> name.startsWith("biz-pool-")));

        release.countDown();
        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);

        System.out.println("completedTaskCount=" + executor.getCompletedTaskCount());
        System.out.println("maxActiveThreads=" + maxActive.get());
    }
}

7.2 编译并运行

bash
javac ThreadPoolDesignDemo.java
java ThreadPoolDesignDemo

如果你在 PowerShell 中操作,也直接执行同样两条命令即可。

7.3 你应该观察到什么

输出不一定逐字完全一致,但应包含这些关键信息:

text
corePoolSize=1
maximumPoolSize=2
largestPoolSize=2
queueSizeBeforeRelease=1
rejectedCount=1
threadNamePrefixOk=true
completedTaskCount=3
maxActiveThreads=2

7.4 每一行在验证什么

  • corePoolSize=1maximumPoolSize=2:说明这个线程池的容量边界是明确配置的
  • largestPoolSize=2:说明线程池确实扩到了最大线程数
  • queueSizeBeforeRelease=1:说明核心线程满后,任务会先进入队列
  • rejectedCount=1:说明线程和队列都满时,会触发拒绝策略
  • threadNamePrefixOk=true:说明自定义线程工厂让线程更可观测
  • completedTaskCount=3:说明 4 个任务里有 3 个被接收执行,1 个被拒绝
  • maxActiveThreads=2:说明同时执行的任务数受最大线程数限制

7.5 再做两个延伸验证

你可以继续做下面两个实验:

  1. 把队列容量从 1 改成 10
  2. 把拒绝策略改成调用线程执行

你可以观察:

  • 队列变大后,拒绝可能变少,但排队延迟会增加
  • 不同拒绝策略会改变系统在超载时的行为

8. 练习建议

下面这些练习做完,这一章会更扎实:

  • 为不同业务场景设计不同线程池参数
  • 对比 CPU 密集型和 I/O 密集型任务的线程池配置思路
  • 观察不同队列容量和拒绝策略下的运行结果
  • 总结线程池核心参数之间的协作关系

9. 自测问题

  • 为什么不建议直接使用默认线程池工厂?
  • 核心线程数、最大线程数、队列容量如何共同作用?
  • 线程池为什么也是系统保护机制的一部分?
  • 为什么不同类型任务不应该随意共用一个线程池?
  • 自定义线程工厂为什么重要?

10. 自测核对要点

如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:

  • 线程池本质上是线程资源复用和容量控制工具
  • 核心线程、最大线程、队列和拒绝策略共同决定系统行为
  • 线程数和队列容量都不是越大越好
  • 线程池应该具备清晰命名、监控和隔离思维
  • 线程池设计直接关系到系统在高负载下的稳定性