4、线程池实践:ThreadPoolExecutor的使用、任务提交与结果获取、线程池调优参数
4.1 为什么我们需要线程池?
说实话,我刚入行那会儿,写多线程代码特别「豪放」——来一个任务就 new 一个 Thread。直到有一次线上服务被流量打垮,我才发现这种方式的代价有多大。
每次创建线程都要跟操作系统打交道,开销不小。而且线程数量一多,CPU 光忙着上下文切换了,正经活儿反而干得慢。线程池的出现,说白了就是解决这两个问题:复用线程和控制并发数。
Java 里最核心的线程池实现,就是 ThreadPoolExecutor。你想想看,它就像一个「线程管家」,帮你管着线程的创建、调度、销毁。我们只需要把任务丢给它就行。
4.2 ThreadPoolExecutor 的核心构造参数
先看它的构造方法,七个参数,一个都不能少。我习惯把它们分成三组来理解:
| 参数 | 含义 | 我的理解 |
|---|---|---|
| corePoolSize | 核心线程数 | 平时常驻的「老员工」数量 |
| maximumPoolSize | 最大线程数 | 忙起来最多能招多少人 |
| keepAliveTime | 空闲线程存活时间 | 临时工没事干多久会被辞退 |
| unit | 时间单位 | 秒、毫秒等 |
| workQueue | 任务队列 | 排队等活干的任务列表 |
| threadFactory | 线程工厂 | 怎么给线程起名字、设优先级 |
| handler | 拒绝策略 | 实在忙不过来怎么办 |
这里有个容易踩的坑:corePoolSize 和 maximumPoolSize 不是同时生效的。线程池的工作流程是这样的:
- 任务来了,先看核心线程有没有空闲,有就给它干
- 核心线程都在忙,任务就丢进队列排队
- 队列满了,才创建新线程,直到 maximumPoolSize
- 如果还超过,就触发拒绝策略
嗯,这里要注意:不是核心线程满了就立刻创建新线程,而是先排队。这个顺序很多人搞反了。
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 闲着也是闲着。
4.5 拒绝策略怎么选?
当线程池和队列都满了,新任务就会被拒绝。Java 提供了四种策略:
- AbortPolicy(默认):直接抛 RejectedExecutionException。适合对任务丢失敏感的场景。
- CallerRunsPolicy:谁提交谁执行。让提交任务的线程自己跑这个任务,相当于一种「降级」。我比较喜欢这个,因为它能自然地把压力反馈回去。
- DiscardPolicy:悄悄丢掉,不抛异常。慎用,除非你能接受任务丢失。
- DiscardOldestPolicy:丢掉队列里最老的任务,然后尝试提交新任务。适合允许丢弃旧任务的场景。
我个人习惯用 CallerRunsPolicy。为什么呢?因为当系统过载时,让调用方线程自己执行任务,相当于把压力「反压」回去,调用方会变慢,从而自然限流。这比直接抛异常要优雅得多。
4.6 核心知识体系
下面这张图总结了线程池的核心知识点,我把它画成了流程图,方便你理解整体脉络:
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 一看线程名就知道是哪个池子的线程,省去了很多猜测的时间。