21. 线程池的拒绝策略怎么选
发布于 • 阅读量 0
上一节已经看到了,线程池不是无限接收任务的。
它有线程数量限制,也有队列容量限制。
当线程池里的线程都在忙,队列也已经满了,再继续提交任务,就会触发拒绝策略。
也就是说,拒绝策略解决的是这个问题:
线程池实在处理不过来了,新来的任务怎么办?
这个问题在学习阶段可能感觉不明显,但在真实项目里很重要。
因为只要系统有上限,就一定要考虑超过上限以后怎么处理。
为什么一定会有拒绝策略
假设我现在有一个 PDF 处理线程池:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
2,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy()
);
这个线程池的能力很有限:
最多 2 个线程同时执行;
队列最多放 2 个任务。
也就是说,它最多能承载:
2 个正在执行的任务;
2 个排队等待的任务。
一共 4 个。
如果我提交第 5 个任务,线程池就没地方放了。
这时候就必须有一个处理规则。
这个规则就是拒绝策略。
先写一个触发拒绝的例子
新建类:
com.succos.threadpool.ThreadPoolRejectDemo
代码如下:
package com.succos.threadpool;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolRejectDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
2,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy()
);
for (int i = 1; i <= 6; i++) {
int taskId = i;
System.out.println("准备提交任务:" + taskId);
executor.execute(() -> {
System.out.println(Thread.currentThread().getName()
+ " 开始执行任务 "
+ taskId);
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName()
+ " 执行完成任务 "
+ taskId);
});
System.out.println("任务 " + taskId
+ " 提交完成,当前队列数量:"
+ executor.getQueue().size());
}
executor.shutdown();
}
}
这个例子大概率会在提交第 5 个任务时报错。
因为执行过程是:
任务1、任务2:被两个线程执行;
任务3、任务4:进入队列等待;
任务5:线程满了,队列也满了,触发拒绝策略。
这里用的是:
new ThreadPoolExecutor.AbortPolicy()
它会直接抛异常。
AbortPolicy:直接抛异常
AbortPolicy 是默认拒绝策略。
它的特点是:
任务放不下时,直接抛 RejectedExecutionException。
比如:
java.util.concurrent.RejectedExecutionException
这种策略比较直接。
它的好处是:任务提交失败这件事不会被隐藏。
调用方能明确知道:
线程池已经满了,这个任务没有提交成功。
坏处也很明显:如果没有捕获异常,程序可能直接报错中断。
在 PDF 处理场景里,如果我不希望任务悄悄丢掉,AbortPolicy 其实是可以接受的。
比如接口里可以捕获这个异常,然后返回:
当前任务过多,请稍后再试。
示例:
try {
executor.execute(() -> {
processPdf(file);
});
} catch (RejectedExecutionException e) {
System.out.println("任务提交失败,线程池已满:" + file.getName());
}
这种写法至少能让用户知道任务没有被接收。
DiscardPolicy:悄悄丢掉任务
第二种是 DiscardPolicy。
写法是:
new ThreadPoolExecutor.DiscardPolicy()
它的特点是:
任务放不下时,直接丢掉,不抛异常,也不提示。
这个策略我个人会比较谨慎。
因为它太安静了。
比如我提交了 100 个 PDF 任务,线程池满了以后,有些任务被丢掉,但程序没有报错。
最后用户可能发现:
有几个 PDF 怎么一直没有处理结果?
排查起来会很难。
所以像 PDF 水印这种不能随便丢任务的场景,我一般不会用 DiscardPolicy。
它更适合一些不重要的数据,比如:
日志采样;
埋点数据;
临时统计;
允许丢失的监控事件。
即使丢掉一部分,也不影响主业务。
但业务任务不要随便用。
DiscardOldestPolicy:丢掉队列里最老的任务
第三种是 DiscardOldestPolicy。
写法是:
new ThreadPoolExecutor.DiscardOldestPolicy()
它的意思是:
如果线程池满了,就丢掉队列里最早排队的任务;
然后尝试把当前新任务放进去。
这个策略有点像“保新不保旧”。
比如队列里现在排着:
任务3、任务4
这时候任务5来了,但队列满了。
DiscardOldestPolicy 会丢掉任务3,然后尝试提交任务5。
这种策略适合那种“只关心最新结果”的场景。
比如:
实时刷新状态;
最新位置上报;
某些监控面板数据;
只保留最新一次计算。
但 PDF 处理不适合。
因为 PDF 任务没有新旧谁更重要这种说法。
用户提交的每个 PDF 都应该被明确处理,要么成功,要么失败,要么告诉用户任务提交失败。
不能悄悄把前面排队的 PDF 丢掉。
CallerRunsPolicy:让提交任务的线程自己执行
第四种是 CallerRunsPolicy。
写法是:
new ThreadPoolExecutor.CallerRunsPolicy()
它的意思是:
线程池忙不过来时,不抛异常,也不丢任务;
谁提交的任务,就让谁自己执行这个任务。
比如在 main 方法里提交任务,那么任务会由 main 线程执行。
如果是在 Web 接口线程里提交任务,那么可能会由当前请求线程执行。
这个策略有一个很有意思的效果:它会让提交任务的线程变慢。
比如主线程疯狂提交任务,线程池处理不过来了。
这时候 CallerRunsPolicy 让主线程自己去执行一个任务。
主线程一执行任务,就没法继续那么快提交新任务了。
这相当于给提交方降速。
所以它有一点“反压”的味道。
用 CallerRunsPolicy 看一下效果
可以新建类:
com.succos.threadpool.ThreadPoolCallerRunsDemo
代码如下:
package com.succos.threadpool;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolCallerRunsDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
2,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 1; i <= 6; i++) {
int taskId = i;
System.out.println("准备提交任务:" + taskId
+ ",提交线程:"
+ Thread.currentThread().getName());
executor.execute(() -> {
System.out.println(Thread.currentThread().getName()
+ " 开始执行任务 "
+ taskId);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName()
+ " 执行完成任务 "
+ taskId);
});
System.out.println("任务 " + taskId + " 提交完成");
}
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.HOURS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("全部任务结束");
}
}
运行时可能会看到某个任务不是由 pool-1-thread-1 或 pool-1-thread-2 执行,而是由:
main
执行。
这就是 CallerRunsPolicy 生效了。
线程池没地方放任务时,提交任务的 main 线程自己干活。
四种拒绝策略对比
我现在可以先这样记:
| 拒绝策略 | 行为 | 适合场景 |
|---|---|---|
AbortPolicy |
直接抛异常 | 任务不能丢,提交失败要明确感知 |
DiscardPolicy |
悄悄丢掉新任务 | 不重要、允许丢失的任务 |
DiscardOldestPolicy |
丢掉队列里最老的任务 | 只关心最新结果的任务 |
CallerRunsPolicy |
提交任务的线程自己执行 | 不想丢任务,希望降低提交速度 |
如果是 PDF 水印这种业务任务,我一般会优先考虑:
AbortPolicy;
CallerRunsPolicy。
不太会考虑:
DiscardPolicy;
DiscardOldestPolicy。
因为 PDF 任务不应该无声无息地丢掉。
PDF 任务应该选哪种拒绝策略
如果是后台批量处理 PDF,我更倾向于两种方案。
一种是 CallerRunsPolicy。
比如:
new ThreadPoolExecutor.CallerRunsPolicy()
它的好处是任务不容易丢。
线程池处理不过来时,提交任务的线程会自己执行一部分任务,提交速度自然会慢下来。
这种适合普通批处理程序。
另一种是 AbortPolicy,然后自己捕获异常。
比如:
try {
executor.execute(() -> {
processPdf(file);
});
} catch (RejectedExecutionException e) {
// 记录失败状态
// 告诉用户任务提交失败
}
这种适合 Web 接口场景。
因为接口应该明确告诉用户:
任务太多了,本次提交没有成功。
而不是让请求线程自己去处理一个很耗时的 PDF。
如果 PDF 处理需要很久,CallerRunsPolicy 放在 Web 请求线程里可能会导致接口响应变慢。
所以要看场景。
Web 接口里要谨慎使用 CallerRunsPolicy
CallerRunsPolicy 不丢任务,这点很好。
但如果它用在 Web 请求线程里,要小心。
假设用户调用接口上传 PDF,接口线程负责提交任务。
如果线程池满了,CallerRunsPolicy 会让这个请求线程自己执行 PDF 处理。
这可能导致接口卡住很久。
比如 PDF 处理要 30 秒,那这个 HTTP 请求可能就要等 30 秒。
所以在 Web 服务里,我不一定会无脑用 CallerRunsPolicy。
更稳的设计可能是:
任务提交到数据库;
后台线程池慢慢消费;
如果任务队列满了,接口直接返回“系统繁忙”;
用户可以稍后重试。
这样请求线程不会被长时间占用。
当然,如果任务很轻,CallerRunsPolicy 也不是不能用。
还是要看任务耗时。
不要用无界队列逃避拒绝策略
有些人为了避免拒绝,会用很大的队列,甚至无界队列。
比如:
new LinkedBlockingQueue<>()
不指定容量时,它的容量非常大。
这样短时间内确实不容易触发拒绝策略。
但问题是,任务会不断堆积在内存里。
如果提交速度远远大于处理速度,队列会越来越大。
最后可能变成:
内存越来越高;
GC 越来越频繁;
接口看起来没拒绝,但任务延迟越来越大;
严重时内存溢出。
所以我更倾向于使用有界队列。
比如:
new ArrayBlockingQueue<>(100)
队列容量明确,系统压力超过上限时,就让拒绝策略发挥作用。
系统要有边界。
无限排队不是解决问题,只是把问题往后拖。
拒绝不一定是坏事
我以前会觉得任务被拒绝就是坏事。
后来想清楚一点:拒绝其实是系统保护自己的一种方式。
如果线程池已经满了,队列也满了,说明当前处理能力已经达到上限。
这时候继续硬塞任务,不一定是好事。
更合理的做法是:
明确告诉调用方系统繁忙;
记录失败原因;
让用户稍后重试;
或者把任务落库,后面再慢慢处理。
总比系统被压垮强。
所以拒绝策略不是“失败处理的补丁”,而是线程池设计里必须考虑的一部分。
这一节小结
这一节我主要记住几点:
1. 拒绝策略是在“线程满了、队列也满了”时触发的;
2. AbortPolicy 会直接抛异常,适合任务不能无声丢失的场景;
3. DiscardPolicy 会悄悄丢任务,业务任务一般不要用;
4. DiscardOldestPolicy 会丢掉队列中最老的任务,适合只关心最新数据的场景;
5. CallerRunsPolicy 会让提交任务的线程自己执行,能降低提交速度;
6. PDF 处理这种任务,通常不应该悄悄丢;
7. 线程池队列最好有边界,不要用无限队列逃避拒绝问题。
用一句话总结:
拒绝策略不是可有可无的配置,而是系统忙不过来时的兜底规则。
下一节继续看线程池参数怎么配置。
因为拒绝策略只是最后一道防线,更前面的核心还是:线程数、队列大小到底该怎么设置。