"禁止使用 Executors 创建线程池"几乎写进了所有 Java 规范。但规范只给了结论,没给推导过程,于是常见两种反应:一种是照抄 new ThreadPoolExecutor(...) 但参数随便填,另一种是继续用 Executors 并认为规范是教条。这篇把结论拆开:先看 FutureTask 的状态机——它是 submit 的返回值,也是异常语义的来源;再看四个工厂方法展开成什么;最后给出可落地的写法。
1. FutureTask 的状态机
FutureTask 内部用一个 volatile int state 加 CAS 管理生命周期,状态只有七个:
private static final int NEW = 0;
private static final int COMPLETING = 1; // 结果已写入,正在收尾
private static final int NORMAL = 2; // 正常结束
private static final int EXCEPTIONAL = 3; // 抛异常结束
private static final int CANCELLED = 4; // cancel(false) 成功
private static final int INTERRUPTING = 5; // 正在 interrupt 运行线程
private static final int INTERRUPTED = 6; // interrupt 已发出
| 起始 | 目标 | 触发 | 关键点 |
|---|---|---|---|
| NEW | COMPLETING | set / setException |
中间态,极短 |
| COMPLETING | NORMAL | 写完全部字段 | 之后结果不可改 |
| COMPLETING | EXCEPTIONAL | 同上,存的是异常 | get() 抛 ExecutionException |
| NEW | CANCELLED | cancel(false) |
不打断运行中的线程 |
| NEW | INTERRUPTING → INTERRUPTED | cancel(true) |
先 interrupt 再转态 |
两个细节经常被忽略:
COMPLETING是中间态,isDone()在这期间返回false。它存在的意义是让outcome字段的写入对其他线程可见——先 CAS 到COMPLETING,再写结果,最后putOrderedInt到终态。- 终态不可逆。一旦到
NORMAL,再调cancel返回false,set也不会生效。
2. run() 与 get() 怎么配合
run() 的核心逻辑(缩掉细节):
public void run() {
if (state != NEW || !RUNNER.compareAndSet(this, null, Thread.currentThread()))
return; // 已跑过或已被别的线程抢到
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try { result = c.call(); ran = true; }
catch (Throwable ex) { result = null; ran = false; setException(ex); }
if (ran) set(result);
}
} finally {
runner = null;
int s = state;
if (s >= INTERRUPTING) handlePossibleCancellationInterrupt(s);
}
}
get() 的分支只依赖状态:
get() 时的状态 |
行为 |
|---|---|
| 未完成 | 入等待队列 park,被唤醒后重新判断 |
NORMAL |
返回 outcome |
EXCEPTIONAL |
抛 ExecutionException,原异常在 getCause() |
CANCELLED / INTERRUPTED |
抛 CancellationException |
COMPLETING |
自旋等待(Thread.yield()) |
由此得到三条实践结论:submit 的异常不会自动打日志,只有 get() 时才暴露;cancel(true) 依赖任务对中断有响应;get() 不带超时会永久阻塞,线上必须用 get(timeout, unit)。
3. Callable 与 Runnable 的适配
submit 有两个重载,内部都用 FutureTask 包装:
public Future<?> submit(Runnable task) {
RunnableFuture<Void> ftask = newTaskFor(task, null); // 结果恒为 null
execute(ftask);
return ftask;
}
public <T> Future<T> submit(Runnable task, T result) {
RunnableFuture<T> ftask = newTaskFor(task, result); // 返回预设结果
execute(ftask);
return ftask;
}
public <T> Future<T> submit(Callable<T> task) { ... }
| 入口 | 返回的 Future | 异常可见性 |
|---|---|---|
execute(Runnable) |
无 | 抛到线程的 UncaughtExceptionHandler |
submit(Runnable) |
Future<?> |
被吞进 Future,只有 get() 才见 |
submit(Callable) |
Future<T> |
同上 |
Executors.callable(Runnable) 是把 Runnable 变成 Callable 的工具方法,适配后同样会吞异常。这是"任务静默失败"的最主要来源:用 submit 却从不 get,异常就消失了。
4. 四个工厂方法展开成什么
JDK 17 里这几个方法一行就能读完,重点在括号里的参数:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
| 工厂方法 | 核心/最大 | 队列 | 风险点 |
|---|---|---|---|
newFixedThreadPool(n) |
n / n | LinkedBlockingQueue(容量 Integer.MAX_VALUE) |
队列无界,堆积到 OOM |
newSingleThreadExecutor() |
1 / 1 | 同上 | 同上,且吞吐被单线程限制 |
newCachedThreadPool() |
0 / Integer.MAX_VALUE |
SynchronousQueue |
线程数无界,可能创建上万线程 |
newScheduledThreadPool(n) |
n / Integer.MAX_VALUE |
DelayedWorkQueue(无界) |
定时任务堆积无声无息 |
newFixedThreadPool 的"固定"只是核心线程固定,队列不固定。所谓"固定大小线程池"在生产里最常见的形态是:线程数永远是 8,队列涨到 300 万,最后老年代 OOM。
5. 无界队列到 OOM 的完整链路
- 入流量超过处理能力,任务开始排队;
LinkedBlockingQueue默认容量Integer.MAX_VALUE,offer永远成功;offer成功意味着永远不会扩容线程,也永远不会触发拒绝策略;- 队列节点持有任务引用(
Runnable及其闭包捕获的对象),全部是强引用; - 老年代不断增长 → Full GC 频繁 → 最终
OutOfMemoryError: Java heap space,或容器被 OOMKilled。
$ jcmd 5123 Thread.print | grep -A3 'pool-' # 线程都在 wait
$ jcmd 5123 GC.class_histogram | head -10 # java.util.concurrent.LinkedBlockingQueue$Node 靠前
第 5 步之前通常有很长的"变慢但还能跑"的阶段:GC 时间占比上升、P99 变差、队列继续长。这个过程里线程池指标看起来一切正常——活跃线程数等于核心线程数,队列长度虽然涨,但没人监控它。
6. newSingleThreadExecutor 为什么任务抛异常后还能继续
这常被当成"单线程池更稳"的证据,其实是 ThreadPoolExecutor 的通用行为:
| 步骤 | 发生什么 |
|---|---|
| 任务抛未捕获异常 | runWorker 的 task.run() 抛出,worker 循环被打断 |
| 线程终止 | 该 worker 线程结束 |
processWorkerExit |
检测到 completedAbruptly=true,补充新的 worker 线程 |
| 队列里的任务 | 由新线程继续取,所以"后续任务照跑" |
| 异常本身 | 走 UncaughtExceptionHandler,submit 场景则被 Future 吞掉 |
所以"继续执行"不代表任务成功,也不代表异常被处理了。要看到它就三选一:用 execute 并设置 UncaughtExceptionHandler、submit 后取 get()、或者在任务内部 try/catch 全部包住。
7. 可落地的写法
显式构造、有界队列、命名线程、明确拒绝策略,四件事一次做完:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 32, // core / max
60L, TimeUnit.SECONDS, // 空闲回收时间
new ArrayBlockingQueue<>(1000), // 有界队列
new ThreadFactory() { // 命名线程,便于 jstack
private final AtomicInteger seq = new AtomicInteger();
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-pool-" + seq.incrementAndGet());
t.setDaemon(false);
return t;
}
},
new ThreadPoolExecutor.AbortPolicy()); // 拒绝时抛 RejectedExecutionException
pool.prestartAllCoreThreads(); // 可选:预热
| 参数 | 建议取值依据 |
|---|---|
| core / max | CPU 密集 ≈ 核数;IO 密集按 QPS × 平均耗时估算 |
| 队列容量 | 按"可忍受的排队时长 × 处理速率"算,不要拍脑袋 |
| 存活时间 | 峰值与均值差距大时留出回收空间 |
| 拒绝策略 | 必须显式写,默认的 AbortPolicy 要有人接住异常 |
| 线程名 | 必须写,否则 jstack 里全是 pool-1-thread-3 |
队列容量的算法值得展开:处理速率 500 QPS、允许排队 2 秒,容量就是 1000。这个数比"随便写个 10000"有意义的点在于它和 SLA 挂钩。
8. 排错:五种典型现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 队列持续增长,线程数不变 | 队列无界,永不扩容 | 换有界队列 |
| 告警"拒绝数不为 0"但业务无感 | 默认策略抛异常被上层吞掉 | 显式策略 + 计数打点 |
| 进程被 OOMKilled,日志无 OOM | 队列节点撑爆堆,或直接内存 | 有界队列 + 压测验证 |
jstack 里线程数上千 |
newCachedThreadPool 在突发流量下失控 |
设 max 上限与有界队列 |
submit 的任务静默失败 |
异常被 FutureTask 捕获,从未 get() |
统一在包装层 get() 或加 handler |
9. 边界:线程池参数解决不了的部分
| 问题 | 该用什么 |
|---|---|
| 下游超时导致的线程占满 | 超时 + 熔断 |
| 上游突发流量 | 入口限流(令牌桶/漏桶) |
| 任务有优先级 | PriorityBlockingQueue 或分池 |
| 任务需要串行不阻塞 | 单线程事件循环或 actor 模型 |
| 需要可观测的任务追踪 | 线程池包装 + MDC/TraceContext 透传 |
MDC 透传这一条最容易漏:ThreadPoolExecutor 不会复制调用线程的上下文,日志里的 traceId 到池子里就断了,需要自己在任务提交时抓取、在线程内设置、finally 里清理。
10. 自测
FutureTask的COMPLETING状态为什么必须存在?submit(Runnable)的任务抛异常,异常去哪了?newFixedThreadPool(8)展开后的队列类型和容量是什么?- 为什么说"固定大小线程池"从不会触发拒绝策略?
newSingleThreadExecutor在任务抛异常后为什么还能继续跑后续任务?- 线程池的队列容量应该按什么算,而不是拍脑袋?