2312 字
约 7 分钟
18
S05-X07 · FutureTask 与 Executors 工厂:为什么大厂禁用 Executors
2024-12-31
2026-11-08

"禁止使用 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 再转态

两个细节经常被忽略:

  1. COMPLETING 是中间态,isDone() 在这期间返回 false。它存在的意义是让 outcome 字段的写入对其他线程可见——先 CAS 到 COMPLETING,再写结果,最后 putOrderedInt 到终态。
  2. 终态不可逆。一旦到 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 的完整链路

  1. 入流量超过处理能力,任务开始排队;
  2. LinkedBlockingQueue 默认容量 Integer.MAX_VALUE,offer 永远成功;
  3. offer 成功意味着永远不会扩容线程,也永远不会触发拒绝策略;
  4. 队列节点持有任务引用(Runnable 及其闭包捕获的对象),全部是强引用;
  5. 老年代不断增长 → 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. 自测

  1. FutureTask 的 COMPLETING 状态为什么必须存在?
  2. submit(Runnable) 的任务抛异常,异常去哪了?
  3. newFixedThreadPool(8) 展开后的队列类型和容量是什么?
  4. 为什么说"固定大小线程池"从不会触发拒绝策略?
  5. newSingleThreadExecutor 在任务抛异常后为什么还能继续跑后续任务?
  6. 线程池的队列容量应该按什么算,而不是拍脑袋?
S05-X07 · FutureTask 与 Executors 工厂:为什么大厂禁用 Executors
https://www.clxhxhhr.top/posts/5660/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。