☰
FutureTask 源码深度剖析:Runnable/Future双身份适配与状态机设计
2026/10/10 5:38:37 网站建设 项目流程

写 Java 并发的时候,我最开始对 FutureTask 的认知停留在"把 Callable 丢进去,然后 get() 拿返回值"这个层面。直到有一天我想自己实现一个简单的异步任务,才发现 Runnable 的 run() 没有返回值、Callable 能返回但没法直接丢给 Thread,两个接口之间横着一条天然的鸿沟。FutureTask 最巧妙的地方,就是它同时实现了 Runnable 和 Future 两套身份:对线程池来说它是一个普普通通的 Runnable,对调用方来说它又是一个可以查询任务状态、阻塞获取结果、取消任务的 Future 凭证。这篇文章我从源码层面拆一下 FutureTask 的适配思路、状态机设计、阻塞唤醒机制和完整执行链路,适合那些想深入 JUC 底层、或者已经在用 FutureTask 但始终没搞懂"结果到底怎么回来的"这类问题的开发者。

1. 一个"既是任务、又是凭证"的类:适配的入口设计

1.1 Runnable 和 Callable 之间的那条鸿沟

先看两个接口本身。Runnable 的定义极简:public interface Runnable { void run(); }。run() 没有返回值,也不能抛出受检异常,它是为"线程要执行的一段代码"而生的。Callable 则是public interface Callable<V> { V call() throws Exception; },call() 可以返回一个泛型结果,也可以把异常往上抛。这两个接口简直是对着设计的:一个能当任务但不能给结果,一个能给结果但没法直接交给 Thread 或者 ThreadPoolExecutor 去执行。

那早期没有 FutureTask 的时候怎么办?我见过不少土办法:定义一个共享容器,任务线程把结果写进去,主线程轮询标志位;或者自己维护一个Map<Runnable, Object>,任务执行完往里塞结果,主线程按 Runnable 查。这些方案都能跑,但线程安全、内存可见性、等待通知全都得自己手搓,稍不留神就是并发 Bug。

1.2 RunnableFuture:把两个身份焊在一起

FutureTask 的核心设计可以从它的继承关系一眼看穿:

public class FutureTask<V> implements RunnableFuture<V> public interface RunnableFuture<V> extends Runnable, Future<V> { void run(); }

RunnableFuture 这个接口非常直白:你既是 Runnable,可以被提交给任何只认 Runnable 的执行器;你又是 Future,可以调用 get()、cancel()、isDone() 来管理任务生命周期。FutureTask 就是这个接口的默认实现。

这种"一个对象承担两种职责"的设计,本质上就是适配器模式的一种落地。调用方视角下,提交任务时只需要new FutureTask<>(callable),然后把它交给线程池或者new Thread(task).start(),完全不关心内部适配细节;另一个视角下,同一个对象保存了任务执行结果,后续随时可以通过 get() 把这个结果取出来。

1.3 为什么适配不入线程池内部

有人可能会问:线程池的 execute() 方法只接受 Runnable,那为什么不让 ThreadPoolExecutor 内部把 Callable 包一下、再单独维护一个"任务到 Future 的映射"就完事了呢?这个方案理论上可行,但代价实在太大。

如果在线程池内部做适配,就得为每一个提交的 Callable 额外创建包装对象,还要用一个并发 Map 保存 Runnable 和 Future 的对应关系。任务完成前 Future 不能丢,任务完成后又要清理映射,这个映射表的生命周期和线程池绑定在一起,内存回收、并发访问全是麻烦。FutureTask 把"任务本身"和"任务凭证"合二为一,任务对象自己保管结果,线程池最多只把 callable 字段置空帮助 GC,完全不需要外部映射。这也是 Doug Lea 那一套并发设计里很典型的思路:能用对象自身状态解决的问题,就不引入额外结构。

2. 状态机:跨线程安全的最大底座

2.1 七个状态分别代表什么

FutureTask 内部有一个private volatile int state;字段,整个类的状态流转全靠它。状态常量定义如下:

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; private static final int INTERRUPTING = 5; private static final int INTERRUPTED = 6;

下面是每个状态的含义和最终流向:

状态数值含义是否终态
NEW0任务新建,尚未被执行否
COMPLETING1正在设置结果,outcome 即将写入否
NORMAL2正常执行完成是
EXCEPTIONAL3执行过程抛出异常是
CANCELLED4被取消,未中断运行线程是
INTERRUPTING5调用 cancel(true),正在中断线程否
INTERRUPTED6中断完成是

这里有一个看起来很不起眼、实则很关键的点:state 只能从 NEW 开始单向推进,最终停在 NORMAL、EXCEPTIONAL、CANCELLED、INTERRUPTED 这四个终态之一。任务一旦完成或者被取消,就永远不可能回到 NEW,所以 FutureTask 不能复跑。这个设计直接决定了整个类的行为:run() 只有在 state 还是 NEW 的时候才真正执行任务,其余任何状态下调用 run() 都是直接返回。

2.2 为什么强依赖 volatile 和 CAS

state 是 volatile 的,保证了所有线程读取到这个字段时都能拿到最新写入值;状态的变更大量依赖 CAS,保证多个线程同时尝试推进状态时只有一个能成功。为什么要这么较真?因为 FutureTask 的工作线程、等待结果的线程、发出 cancel 的线程很可能是三个不同的线程,它们之间没有任何锁,所有的协同都落在 state 之上。

举一个最简单的例子:任务执行完毕,工作线程把 state 从 COMPLETING 推进到 NORMAL;主线程在 get() 里循环读 state,一旦读到 NORMAL 就认为结果就绪了。如果没有 volatile,主线程可能一直读到旧值;如果没有 CAS,两个线程同时调用 set() 可能会把同一个任务设置两次结果,状态就乱了。

2.3 outcome 的写入顺序:为什么先写结果、后置终态

FutureTask 里保存结果的字段是private Object outcome;,它本身并不是 volatile 的。你可能会疑惑:outcome 不是 volatile,那等待线程怎么保证能看到最新结果?答案藏在写入顺序里。

在 set() 方法中,源码大致是这样处理的:

protected void set(V v) { if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) { outcome = v; UNSAFE.putOrderedInt(this, stateOffset, NORMAL); finishCompletion(); } }

也就是:先把 state 从 NEW CAS 到 COMPLETING,然后写入 outcome,最后再写 state 到 NORMAL。这是一个典型的"发布安全"模式:普通写操作 outcome=v 后面紧跟一个有序写操作 state=NORMAL,读到 state=NORMAL 的线程,由于设置了 happens-before 关系,再回头读 outcome 一定能看到最新值。这就是 COMPLETING 中间态的意义——它创造了一个"结果正在落盘"的窗口,等待线程看到 COMPLETING 时不会把半成品当结果拿走,而是乖乖继续等待。

3. get() 阻塞的秘密:等待链表与 LockSupport 的配合

3.1 get() 的快速通道

get() 方法源码很短:

public V get() throws InterruptedException, ExecutionException { int s = state; if (s <= COMPLETING) s = awaitDone(false, 0L); return report(s); }

首先读一次 state,如果任务已经完成(state > COMPLETING),直接进入 report() 把 outcome 取出来;如果任务还没完成或者正在完成,就进入 awaitDone() 阻塞等待。report() 的逻辑也很清晰:NORMAL 就直接强转返回值,CANCELLED 或更高状态抛 CancellationException,EXCEPTIONAL 就包一层 ExecutionException 抛给调用方。

3.2 awaitDone 里的等待链表

awaitDone() 是整个 get() 的精髓。FutureTask 内部维护着一个volatile WaitNode waiters的无锁链表,每个调用 get() 而被阻塞的线程都会被包装成一个 WaitNode 节点,通过 CAS 头插法挂到链上。核心循环逻辑可以简化成下面几步:

  • 读 state,如果大于 COMPLETING,说明任务已经完成,把自己从链表里摘掉,返回状态;
  • 如果 state 等于 COMPLETING,说明结果正在写入,当前线程执行Thread.yield()让出一下 CPU,等下轮循环再读;
  • 如果当前线程被中断,清理链表中自己的节点,抛出 InterruptedException;
  • 如果节点还没创建,创建一个 WaitNode;
  • 如果节点还没入队,用 CAS 把节点头插到 waiters 链表;
  • 最后调用LockSupport.park(this)或者带超时的LockSupport.parkNanos(this, nanos)真正阻塞。

这个链表的目的是什么?因为调用 get() 等待同一个任务的线程可能非常多,如果用一个锁加条件队列,每次唤醒所有线程都免不了锁竞争。用无锁链表加 LockSupport 的好处是:等待线程各 sleep 各的,任务完成时统一 unpark 就好,不需要持有任何锁。

3.3 完成后的唤醒:finishCompletion

任务完成时,set() 或 setException() 末尾会调用 finishCompletion()。它的逻辑是:先通过 CAS 把 waiters 置为 null,拿到旧链表,然后遍历链表中每个 WaitNode,把节点里的 thread 取出来执行LockSupport.unpark(t)。这样所有正在 get() 里 park 的线程都会被唤醒,重新进入循环读取 state,发现已经完成,就退出等待返回结果。

这里必须说一下 LockSupport 相比 Object.wait/notify 的巨大优势。wait/notify 必须在 synchronized 块内使用,而且 notify 如果早于 wait 发生,线程可能永远睡死过去。LockSupport 的 unpark 可以先于 park 调用,每个线程有一个许可(permit)的概念,预先 unpark 一次,线程后续 park 时不会真的阻塞,直接消费掉这个许可继续执行。因此 FutureTask 的唤醒机制天然不存在"过早通知导致丢失信号"的问题。

3.4 超时版 get 的实现思路

带超时版本的get(long timeout, TimeUnit unit)走的是同一个 awaitDone,只是 timed 参数为 true。在进入等待之前,它会计算一个 deadline:System.nanoTime() + unit.toNanos(timeout)。每一次循环都重新计算剩余时间nanos = deadline - System.nanoTime(),如果剩余时间已经小于等于 0,就清理掉自己的 WaitNode,返回当前 state。外层看到 state 仍然 <= COMPLETING,就抛 TimeoutException;如果恰好任务刚刚完成,则不抛超时,直接返回结果。所以超时语义是"到点还没完成就放弃等待",并不是严格到纳秒级别,剩余一点点时间时任务完成也会正常返回。

这里还有一个细节值得注意:awaitDone 中会先把 COMPLETING 状态的线程执行 yield 再重读,为什么不直接 park?因为 COMPLETING 是一个极短的过渡态,紧接着状态就会变成 NORMAL 或 EXCEPTIONAL,线程让出 CPU 转一圈再读,大概率就已经完成,省掉一次 park/unpark 的开销。这种对短暂中间态做忙等待的处理方式,其实也值得在业务代码里借鉴。

4. run() 方法里的每一步:从 call() 到结果落袋

4.1 第一道防线:runner 的 CAS 抢占

run() 方法的开头是 FutureTask 整个并发逻辑里我最喜欢的一段:

public void run() { if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread())) return; // ... }

这里做了两件事:先检查 state 是否为 NEW,不是就返回;然后用 CAS 把 runner 字段从 null 替换成当前线程。runner 字段是 volatile 的,这个 CAS 保证了同一个 FutureTask 即使被多个线程同时执行 run(),也只有一个线程能够抢占成功,真正去调用内部的 Callable,其余线程直接返回。

你可能会想,为什么要多此一举用 runner 来抢占,而不是只看 state?因为从"状态还是 NEW"到"真正开始调 call()"之间有一个时间窗口。如果两个线程同时发现 state 是 NEW 都想执行,单靠状态判断不够;runner CAS 相当于给"谁是这个任务的执行者"做了唯一标记。这一手非常巧妙地避免了重复执行,而且对线程池的场景也很友好,因为同一个任务可能被同一个 Worker 多次 getTask 取到,或者被多个 Worker 抢到,但真正执行的只有一次。

4.2 call() 的结果与异常分别怎么存

成功执行时走 set(),前面已经讲过:先 CAS 到 COMPLETING,写入 outcome,再推进到 NORMAL。异常执行时走 setException():

protected void setException(Throwable t) { if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) { outcome = t; UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); finishCompletion(); } }

异常对象本身也会被当作"结果"存进 outcome,只是状态置为 EXCEPTIONAL。这样设计非常克制:执行线程的任务就是执行 Callable,至于结果如何转交给调用方,是 FutureTask 的事;异常也一样,被包装成一个结果,等工作线程把状态推进完成后,调用方在 get() 里才把它转换成 ExecutionException 抛出来。

为什么要包一层 ExecutionException 而不是直接把原始异常抛给 get()?因为 get() 是通用接口,无法预知调用方具体想捕获什么类型的异常;统一包成 ExecutionException,调用方只需要catch (ExecutionException e),再通过e.getCause()拿原始异常,整个 API 的使用就非常规整。另外,这种设计还避免了受检异常在跨线程传递时被方法签名限制的问题。

4.3 finally 里的清理和中断协作

run() 的 finally 块里有两段逻辑:

finally { runner = null; int s = state; if (s >= INTERRUPTING) handlePossibleCancellationInterrupt(s); }

runner 置 null 是把"当前执行者"标记清掉,方便后续 cancel(true) 等逻辑判断。但这里要注意,任务完成之后 state 已经是终态,runner 置 null 并不会让任务可以再次执行。

handlePossibleCancellationInterrupt 是 JUC 源码里很少见的"主动让出等待":

private void handlePossibleCancellationInterrupt(int s) { if (s == INTERRUPTING) { while (state == INTERRUPTING) Thread.yield(); } }

如果 run() 结束前发现状态是 INTERRUPTING,说明有另一个线程正在执行 cancel(true) 并准备 interrupt 当前任务线程。这里必须一直 yield 等到 cancel 线程把状态推进到 INTERRUPTED 再退出,否则工作线程可能已经离开 run(),而 cancel 线程还在尝试 interrupt,导致中断信号丢失。这种处理方式看着笨拙,但恰恰是为了保证 cancel 的语义完整。

4.4 顺便说说 runAndReset

FutureTask 还留了一个受保护的 runAndReset() 方法,它和 run() 很像,区别是执行完 Callable 后不保存结果、不推进状态到终态,而是保持 NEW,供 ScheduledThreadPoolExecutor 这类需要周期性执行的任务使用。这个方法平时几乎用不到,但理解了它,你会更清楚 FutureTask 的"状态机"设计是多么克制:连作为周期任务基石的方法,也没破坏"任务只能被正确推进一次"的核心原则。

5. 从 submit 到 get 的完整链路:线程池视角

5.1 submit 里发生的其实只有两件事

平时我们写ExecutorService pool = Executors.newFixedThreadPool(4); Future<String> f = pool.submit(() -> "hello");的时候,AbstractExecutorService 的 submit(Callable) 内部逻辑非常简单:

public <T> Future<T> submit(Callable<T> task) { if (task == null) throw new NullPointerException(); RunnableFuture<T> ftask = newTaskFor(task); execute(ftask); return ftask; }

newTaskFor 默认就是new FutureTask<>(callable),所以返回的 Future 真实类型就是 FutureTask。这里还有一个模板方法设计:newTaskFor 是 protected 的,子类可以通过重写它返回自定义的 RunnableFuture,很多高性能框架就是这么扩展 FutureTask 的。

再往下的 execute(ftask) 就把 FutureTask 当成一个普通 Runnable 交给了线程池。ThreadPoolExecutor 内部完全不知道这个 Runnable 内部藏着一个 Callable,也不需要知道——它只负责在某个 Worker 线程里执行 task.run()。

5.2 Worker 线程里的旅程

把时序捋一下,整个执行链路是这样的:

  1. 主线程调用 submit,构造出一个 FutureTask,通过 execute() 把它作为任务对象加入线程池;
  2. 线程池内部,某个 Worker 线程通过 getTask() 拿到这个 task;
  3. Worker 线程执行 task.run(),也就是 FutureTask 的 run(),run() 里通过 runner 的 CAS 抢占到执行权后,调用内部包装的 Callable.call();
  4. Callable 正常返回,结果写入 outcome,状态推进到 NORMAL,finishCompletion() 唤醒所有等待者;
  5. 主线程在 get() 的 awaitDone() 中被 unpark 唤醒,重新读 state 发现已经完成,调用 report() 把 outcome 强转成 V 返回。

整个过程里,线程池只扮演了"调度器"的角色,任务状态管理、结果保存、等待唤醒这些核心逻辑全部由 FutureTask 自己完成。这也正是我能放心把它当作标准并发原语使用的原因——边界划分非常清晰。

5.3 cancel 与 get 超时的组合拳

实际业务里最常见的用法是配合超时:

Future<String> f = pool.submit(callableTask); try { String result = f.get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { f.cancel(true); }

cancel(true) 的执行流程值得特别注意。它首先判断 state 是否为 NEW,再用 CAS 把 state 置为 INTERRUPTING;然后取出 runner 字段对应的线程执行 interrupt();最后把状态推进到 INTERRUPTED。也就是说,cancel(true) 并不保证任务一定停下来——如果任务内部不响应中断信号,它照样会跑完。但从 FutureTask 的角度看,它的语义是完整的:取消成功后,所有在 get() 里等待的线程会被唤醒,并且 get() 会抛出 CancellationException。

如果任务已经完成再调用 cancel,CAS 会失败,返回 false。这个细节在业务里挺实用:可以用cancel(false)去试探取消一个可能完成了的任务,返回 false 就说明任务已经结束,不用再担心后台资源泄漏。

5.4 不用线程池,FutureTask 也能直接用

有些轻量场景根本不需要引入线程池,FutureTask 配合 Thread 就能工作:

FutureTask<Integer> task = new FutureTask<>(() -> { Thread.sleep(1000); return 42; }); new Thread(task).start(); Integer result = task.get();

Thread 构造器只认 Runnable,FutureTask 恰好就是 Runnable。这种写法的价值在于:它帮你把"异步执行"和"等待结果"两件事统一到了一个对象上,代码可读性远好于自己搞一个共享变量再轮询。

6. 实战建议与几个容易踩的坑

6.1 用 FutureTask 子类重写 done() 做完成回调

FutureTask 有一个钩子方法 done(),默认实现是空的,它会在任务完成、所有等待线程被唤醒之后被调用,调用者是完成任务的那个 Worker 线程。利用它可以做一些轻量的完成回调,比如打日志、发信号、清理资源:

FutureTask<String> task = new FutureTask<>(() -> { Thread.sleep(2000); return "done"; }) { @Override protected void done() { // 这里不需要担心锁,任务已完成 System.out.println("run 已完成,等待线程正在被唤醒"); } };

要注意的是,done() 里调用 get() 时如果任务是异常完成的,会抛出 ExecutionException,所以最好用 try/catch 包一层。我第一次用这个钩子的时候就忽略了这一点,异常任务在回调里还炸了一次。

6.2 几个必须避开的坑

不要在 Callable 内部调用同一个 FutureTask 的 get()。这是一个典型的自等死锁:执行线程在执行 call(),却跑去 get() 等待自己完成,而能完成任务的线程就是它自己,结果谁也无法推进状态,线程永久阻塞。正确做法是只由外部线程调用 get()。

不要以为同一个 FutureTask 提交两次会被执行两次。即使在两个线程里执行同一个 task.run(),runner 的 CAS 也只能让一个线程成功,另一个直接返回。你需要的是每次新 new 一个 FutureTask。

使用 get() 无限等待时要格外小心。如果任务因为某种原因始终不结束,所有调用 get() 的线程都会一直 park,这在线程池场景下可能会把池子里的线程全部占满。一个现实的做法是尽量用带超时的 get(long, TimeUnit),并在超时后评估是否需要 cancel。

6.3 和 CompletableFuture 的边界

被问得比较多的一个问题是:有了 FutureTask 还要 CompletableFuture 干什么?我的理解是,FutureTask 更接近"单个异步任务的结果获取原语",它的模型是"一个任务,一个 Future,一次等待";CompletableFuture 则是异步编排框架,善于把多个异步操作串成流水线,还有 thenApply、thenCombine、exceptionally 这些组合操作。

如果你只是想让某个后台线程执行一段耗时逻辑并回传结果,FutureTask 足够了,简单直接,语义清晰。如果要做多任务并行编排、回调链、异常恢复,那就直接上 CompletableFuture,没必要拿 FutureTask 硬凑。两者不是替代关系,而是不同抽象层级的产物。

6.4 读源码时抓的三根主线

最后给想读 FutureTask 源码的朋友一个建议:不要一头扎进 CAS 偏移量的细节里,抓住三根主线就能把整份源码吃透。

第一根线是状态机:state 的七种状态如何单向流转,哪些是终态,哪些是过渡态。第二根线是发布安全:outcome 普通字段为什么能安全跨线程读取,关键是"先写结果、再置终态、等待者读终态后再读结果"的顺序保证。第三根线是无锁等待:WaitNode 链表加 LockSupport.park/unpark 替代了传统的 synchronized 加 wait/notify,解除了锁依赖,也消除了唤醒信号丢失的问题。

这三根线搞明白之后,再看 run()、get()、cancel() 这些方法,会发现它们都只是在状态机上做文章而已。

最后说点个人体会:我第一次追 FutureTask 源码时,最惊讶的是整个类几乎没有用到 synchronized 关键字,复杂的状态流转全靠 volatile 加 CAS 加 LockSupport 撑起来。后来我写业务代码也养成了一个习惯,遇到"两个线程要协同,一个等结果一个出结果"的场景,第一反应不是加锁,而是先画一个最小状态机,把谁负责推进状态、谁负责消费状态理清楚。这种并发思维比死记几个 API 有用得多,至少能让你面对各种并发问题时,知道往哪个方向去拆解。希望这篇剖析对你也有同样的启发。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询