高频八股 · Java 并发编程(9):线程池(下)——拒绝策略、调优与那些坑
写在前面
一、四种拒绝策略
当线程数到顶 + 队列也满的时候,就轮到 RejectedExecutionHandler 出场了。
JDK 内置了四种:
| 策略 | 行为 | 副作用 |
|---|---|---|
| AbortPolicy(默认) | 直接抛 RejectedExecutionException | 不捕获就会丢任务 + 抛异常 |
| CallerRunsPolicy | 交给提交任务的线程自己执行 | 会阻塞调用方,等于"反向降速" |
| DiscardPolicy | 静默丢弃新任务 | 丢了你也不知道 |
| DiscardOldestPolicy | 丢掉队列里最老的那个,再尝试提交 | 老任务被悄悄挤掉 |
怎么选?
- 想尽快发现问题 →
AbortPolicy(默认,别吞异常); - 想不丢任务、能接受变慢 →
CallerRunsPolicy,天然限流; DiscardPolicy/DiscardOldestPolicy慎用,除非你明确允许丢任务。
实战里更推荐——自定义:
public class MyRejectHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// 1. 记日志 / 打点告警
// 2. 落库,后续补偿重试
// 3. 或者降级:走同步链路
log.warn("任务被拒绝,当前活跃线程 {}, 队列长度 {}",
e.getActiveCount(), e.getQueue().size());
}
}
核心思路:拒绝不是终点,要留痕、要能补偿。
二、Executors 的四个"坑"
Executors 提供了几个一行创建线程池的方法,用起来很爽,但生产环境基本不建议。
| 方法 | 队列 / 线程数 | 风险 |
|---|---|---|
newFixedThreadPool(n) | LinkedBlockingQueue(无界,默认 Integer.MAX_VALUE) | 任务堆积 → OOM |
newSingleThreadExecutor() | 同样无界队列 | 同上 |
newCachedThreadPool() | SynchronousQueue + max = Integer.MAX_VALUE | 线程无限创建 → OOM |
newScheduledThreadPool(n) | DelayedWorkQueue(无界) | 任务堆积 → OOM |
两个典型事故:
- 无界队列:任务生产速度 > 消费速度,队列无限涨,最后
OutOfMemoryError: Java heap space; - 无限线程:
CachedThreadPool在高并发下一口气创建上万线程,直接打爆内存 / CPU。
结论:生产环境手动
new ThreadPoolExecutor(...),明确指定有界队列和最大线程数。
三、参数怎么调?
① 线程数
| 任务类型 | 经验公式 |
|---|---|
| CPU 密集型 | N + 1(N = CPU 核数) |
| IO 密集型 | 2N 左右,更严谨是 N × (1 + 等待时间 / 计算时间) |
别照抄公式。真正靠谱的方法是:压测 + 监控 + 逐步调。
② 队列容量
- 必须是有界队列(
ArrayBlockingQueue或指定容量的LinkedBlockingQueue); - 太小 → 频繁触发拒绝;太大 → 堆积、延迟高。要结合业务能接受的最大排队时长来定。
③ 线程工厂
建议自定义,给线程起个有意义的名字:
ThreadFactory factory = r -> {
Thread t = new Thread(r);
t.setName("order-pool-" + t.getId());
return t;
};
出问题时,
jstack里一眼能看出是哪个池的线程,排查效率差好几倍。
④ 监控指标
线上至少要盯这几个:
getActiveCount():活跃线程数getQueue().size():队列积压getCompletedTaskCount():已完成任务数getLargestPoolSize():历史峰值线程数
四、线程池最容易踩的 5 个坑
① submit() 把异常吞了
pool.submit(() -> { throw new RuntimeException("boom"); }); // 没有任何输出
submit() 的异常被封装进 Future,不调 get() 你根本发现不了。想及时暴露,用 execute(),或者在任务内部 try-catch。
② 父子任务同池,可能死锁
在一个线程池里提交任务,然后同步等待子任务结果——如果池子被父任务占满,子任务永远排不上队,直接卡死。
③ 忘了 shutdown()
线程池不关,JVM 可能不退出,也可能一直占着资源。用完记得 shutdown()(温和)或 shutdownNow()(立即)。
④ ThreadLocal 没清理
线程是复用的,ThreadLocal 里的值会跟着线程活下来,可能造成内存泄漏或数据串号。用完必须 remove()(下一期细讲)。
⑤ 拒绝策略选错,任务悄悄消失
用了 DiscardPolicy,任务丢了却毫无感知,等到业务方发现"订单没生成"已经晚了。
五、ForkJoinPool:分治 + 工作窃取
ForkJoinPool 是 JDK 7 引入的,专为可拆分的计算密集任务设计。
核心机制:工作窃取(Work-Stealing)
- 每个线程都有自己的双端队列(Deque);
- 线程从自己队列的头部取任务执行;
- 空闲的线程,会去别的线程队列尾部"偷"一个任务来干。
为什么快? 因为空闲线程不会傻等,而是主动找活干,CPU 利用率更高。
用法:继承 RecursiveTask<V>(有返回值)或 RecursiveAction(无返回值):
class SumTask extends RecursiveTask<Long> {
private final long[] arr;
private final int start, end;
SumTask(long[] arr, int start, int end) {
this.arr = arr; this.start = start; this.end = end;
}
@Override
protected Long compute() {
if (end - start <= 1000) { // 足够小,直接算
long sum = 0;
for (int i = start; i < end; i++) sum += arr[i];
return sum;
}
int mid = (start + end) / 2;
SumTask left = new SumTask(arr, start, mid);
SumTask right = new SumTask(arr, mid, end);
left.fork(); // 拆出去
long r = right.compute(); // 自己算一半
return left.join() + r; // 合并
}
}
用在哪? 数组求和、归并排序、大文件处理这类"能拆成子问题"的场景。Java 8 的 parallelStream 底层用的就是 ForkJoinPool.commonPool()。
注意:IO 密集任务不要用 ForkJoinPool——它假设任务不会阻塞,阻塞会拖垮整个池。
六、高频面试题速答
Q1:线程池的四种拒绝策略?
AbortPolicy(抛异常,默认)、CallerRunsPolicy(调用者执行)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最老的)。
Q2:为什么不推荐用 Executors 创建线程池?
Fixed/Single/Scheduled 用无界队列,可能 OOM;Cached 的最大线程数是 Integer.MAX_VALUE,可能创建过多线程。生产建议手动 new。
Q3:线程池的核心线程数怎么设?
CPU 密集 N+1,IO 密集 2N 或 N×(1+等待/计算);实际应结合压测和监控调整。
Q4:submit() 和 execute() 在异常处理上的区别?
execute() 的异常会直接抛出(线程池会打日志);submit() 把异常包在 Future 里,不 get() 就看不到。
Q5:线程池会导致死锁吗?
会。典型场景是"父任务在池内同步等待子任务",池被占满后子任务进不来,互相等待。
Q6:ForkJoinPool 的原理?
工作窃取:每个线程有独立双端队列,空闲线程从别的队列尾部偷任务,提高 CPU 利用率;适合可拆分的计算密集任务。
七、一句话总结
线程池用得对不对,就看四件事:队列有界、拒绝留痕、线程有名字、用完要关闭。 记住这句,线上 80% 的线程池事故都能避开。
(本系列持续更新中,共 10 期,敬请关注~)
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2401_88601046/article/details/168884109




