4、线程池实践:ThreadPoolExecutor的使用、任务提交与结果获取、线程池调优参数

4.1 为什么我们需要线程池?

说实话,我刚入行那会儿,写多线程代码特别「豪放」——来一个任务就 new 一个 Thread。直到有一次线上服务被流量打垮,我才发现这种方式的代价有多大。

每次创建线程都要跟操作系统打交道,开销不小。而且线程数量一多,CPU 光忙着上下文切换了,正经活儿反而干得慢。线程池的出现,说白了就是解决这两个问题:复用线程和控制并发数。

Java 里最核心的线程池实现,就是 ThreadPoolExecutor。你想想看,它就像一个「线程管家」,帮你管着线程的创建、调度、销毁。我们只需要把任务丢给它就行。

4.2 ThreadPoolExecutor 的核心构造参数

先看它的构造方法,七个参数,一个都不能少。我习惯把它们分成三组来理解:

参数 含义 我的理解
corePoolSize 核心线程数 平时常驻的「老员工」数量
maximumPoolSize 最大线程数 忙起来最多能招多少人
keepAliveTime 空闲线程存活时间 临时工没事干多久会被辞退
unit 时间单位 秒、毫秒等
workQueue 任务队列 排队等活干的任务列表
threadFactory 线程工厂 怎么给线程起名字、设优先级
handler 拒绝策略 实在忙不过来怎么办

这里有个容易踩的坑:corePoolSize 和 maximumPoolSize 不是同时生效的。线程池的工作流程是这样的:

  1. 任务来了,先看核心线程有没有空闲,有就给它干
  2. 核心线程都在忙,任务就丢进队列排队
  3. 队列满了,才创建新线程,直到 maximumPoolSize
  4. 如果还超过,就触发拒绝策略

嗯,这里要注意:不是核心线程满了就立刻创建新线程,而是先排队。这个顺序很多人搞反了。

4.3 任务提交与结果获取

提交任务有两种方式:execute() 和 submit()。区别很简单:

  • execute:只管执行,不关心返回值。适合 Runnable 任务。
  • submit:提交后返回一个 Future 对象,可以拿到执行结果或异常。适合 Callable 任务。

我项目中经常这么用:

// 创建一个线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    2, 5, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(10),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.AbortPolicy()
);

// 提交一个带返回值的任务
Future<String> future = executor.submit(() -> {
    // 模拟耗时计算
    Thread.sleep(1000);
    return "任务执行完毕";
});

// 获取结果,会阻塞直到任务完成
String result = future.get();
System.out.println(result);

// 记得关闭线程池
executor.shutdown();

这里有个细节:future.get() 会阻塞当前线程。如果你不想一直等,可以设置超时时间:future.get(3, TimeUnit.SECONDS)。我曾经因为没设超时,导致一个远程调用卡住了整个线程池,教训深刻。

避坑指南:我曾经在生产环境遇到过一个问题——用 submit() 提交任务,但从来不调用 future.get()。结果任务里抛出的异常被「吞掉」了,日志里啥也看不到。排查了半天才发现是任务内部报错了。所以,要么调用 get() 处理异常,要么在任务内部自己 try-catch 打日志。

4.4 线程池调优参数

调优说白了就是回答三个问题:核心线程设多少?队列用多大?最大线程设多少?

我个人的经验公式是这样的:

任务类型 核心线程数建议 队列大小建议
CPU 密集型 CPU 核心数 + 1 小队列(如 10~50)
IO 密集型 CPU 核心数 × 2(或更高) 大队列(如 100~500)
混合型 根据 IO 等待时间占比计算 中等队列

为什么 CPU 密集型只设「核心数+1」?因为线程多了反而互相抢 CPU,上下文切换的开销会抵消掉并行收益。而 IO 密集型任务大部分时间在等待网络或磁盘,这时候多开几个线程能提高吞吐量。

你想想看,如果一个任务 90% 的时间都在等数据库返回,那 4 核的机器开 40 个线程也没问题——反正 CPU 闲着也是闲着。

我的调优习惯:不要一开始就追求「最优参数」。我通常的做法是:先根据经验设一个初始值,然后用压测工具(比如 JMeter 或 wrk)模拟真实流量,观察 CPU 使用率、线程池活跃度、任务排队时间等指标,再逐步调整。调优是个迭代过程,没有一劳永逸的参数。

4.5 拒绝策略怎么选?

当线程池和队列都满了,新任务就会被拒绝。Java 提供了四种策略:

  • AbortPolicy(默认):直接抛 RejectedExecutionException。适合对任务丢失敏感的场景。
  • CallerRunsPolicy:谁提交谁执行。让提交任务的线程自己跑这个任务,相当于一种「降级」。我比较喜欢这个,因为它能自然地把压力反馈回去。
  • DiscardPolicy:悄悄丢掉,不抛异常。慎用,除非你能接受任务丢失。
  • DiscardOldestPolicy:丢掉队列里最老的任务,然后尝试提交新任务。适合允许丢弃旧任务的场景。

我个人习惯用 CallerRunsPolicy。为什么呢?因为当系统过载时,让调用方线程自己执行任务,相当于把压力「反压」回去,调用方会变慢,从而自然限流。这比直接抛异常要优雅得多。

4.6 核心知识体系

下面这张图总结了线程池的核心知识点,我把它画成了流程图,方便你理解整体脉络:

线程池核心知识体系 ThreadPoolExecutor 构造参数 任务提交 调优参数 corePoolSize / maximumPoolSize keepAliveTime / unit workQueue / threadFactory handler(拒绝策略) execute(Runnable) submit(Callable) → Future future.get() 获取结果 shutdown() 优雅关闭 CPU密集型:核心数+1 IO密集型:核心数×2+ 队列大小:根据场景 压测验证,迭代调优 核心原则:复用线程 + 控制并发 + 合理拒绝 不要用 Executors 的快捷方法(如 newFixedThreadPool),直接 new ThreadPoolExecutor 更可控

4.7 一个完整的实践案例

最后,我分享一个实际项目中用到的线程池配置。这个场景是处理用户上传的图片,需要做压缩、水印、存储等操作,属于 IO 密集型任务:

ThreadPoolExecutor imageProcessor = new ThreadPoolExecutor(
    4,                    // 核心线程:4核CPU
    16,                   // 最大线程:IO密集型可以多开
    30L, TimeUnit.SECONDS,// 临时线程空闲30秒后回收
    new LinkedBlockingQueue<>(200), // 队列容量200
    new ThreadFactoryBuilder()
        .setNameFormat("image-processor-%d")
        .setDaemon(true)
        .build(),
    new ThreadPoolExecutor.CallerRunsPolicy() // 过载时由调用方执行
);

这里我用了 ThreadFactoryBuilder(来自 Guava 库)给线程起了有意义的名字。为什么?因为线上排查问题时,jstack 一看线程名就知道是哪个池子的线程,省去了很多猜测的时间。

一句话总结:线程池的核心不是「怎么创建」,而是「怎么配置」。参数之间相互影响,没有银弹。理解每个参数的含义,结合业务场景压测调优,才是正道。