1. 从一个“卡死”的线程说起:为什么要自己动手实现 FutureTask
多线程编程里最让人头疼的场景之一,就是主线程提交了一个任务之后,不知道该什么时候去拿结果。拿早了,任务还没跑完,只能干等;拿晚了,又浪费了并行的时间窗口。FutureTask这个类就是为解决这个矛盾而生的——它既是Runnable又是Future,既能被线程池执行,又能让调用方在合适的时机去取结果。
但很多人用FutureTask用了好几年,对它的内部机制依然是一知半解。比如:get()方法在任务没完成时到底是怎么阻塞的?它是靠synchronized还是靠LockSupport?任务完成后又是怎么唤醒等待线程的?这些问题如果不去翻源码,很难说清楚。
我这段时间在梳理 AQS(AbstractQueuedSynchronizer)的应用场景,发现FutureTask其实是一个被低估的 AQS 实战案例。大家平时聊 AQS,张口就是ReentrantLock、Semaphore、CountDownLatch,但FutureTask内部同样用 AQS 实现了一套精妙的同步机制,而且它的用法和前面几个都不太一样——它用的是共享模式,而不是独占模式。
所以这篇文章,我打算带着大家从零实现一个简化版的FutureTask,重点拆解get()方法的阻塞与唤醒逻辑,把 AQS 共享模式在其中的作用讲透。适合已经了解 AQS 基本结构(state、CLH 队列、tryAcquire/tryRelease)但还没深入看过FutureTask源码的读者。如果你对 AQS 还完全陌生,建议先补一下ReentrantLock的加锁流程,再来看这篇会顺畅很多。
2. 先搞清楚 FutureTask 的设计骨架
2.1 它到底承担了几个角色
在动手写代码之前,得先把FutureTask的职责理清楚。它同时实现了Runnable和Future两个接口,这意味着它有两个身份:
- 作为 Runnable:它可以被丢给线程或线程池去执行,
run()方法里会调用真正的业务逻辑(Callable),并把结果存起来。 - 作为 Future:它提供了
get()、cancel()、isDone()等方法,让调用方可以获取结果、取消任务、查询状态。
这两个身份之间需要一个“状态桥梁”来协调——任务没跑完时,get()要阻塞;任务跑完了,get()要立刻返回结果。这个桥梁就是state字段加上 AQS 的等待队列。
2.2 状态流转是整个设计的核心
FutureTask内部定义了一组状态常量,用来描述任务的生命周期。简化后大致是这样的:
| 状态 | 含义 | 是否终态 |
|---|---|---|
| NEW | 任务刚创建,还没开始跑 | 否 |
| COMPLETING | 任务已跑完,正在写入结果(中间态) | 否 |
| NORMAL | 任务正常完成,结果已写入 | 是 |
| EXCEPTIONAL | 任务抛异常,异常已写入 | 是 |
| CANCELLED | 任务被取消 | 是 |
| INTERRUPTED | 任务被中断取消 | 是 |
状态只能从NEW出发,经过COMPLETING到达NORMAL或EXCEPTIONAL,或者直接从NEW跳到CANCELLED/INTERRUPTED。一旦进入终态,就再也不会改变。
注意:
COMPLETING这个中间态很容易被忽略。它的存在是为了保证“结果写入”和“状态变更”这两步操作的顺序性。如果先把状态改成NORMAL再写结果,那么另一个线程可能在结果还没写入时就看到了NORMAL状态,读到一个空结果。所以必须先设COMPLETING,写完结果后再用 CAS 改成NORMAL。
2.3 为什么用 AQS 共享模式而不是独占模式
这是理解FutureTask的关键问题。ReentrantLock用的是独占模式,同一时刻只有一个线程能持有锁。但FutureTask的场景不一样:可能有多个线程同时调用get()等待同一个任务的结果。任务完成后,这些线程应该全部被唤醒,而不是只唤醒一个。
这正是 AQS 共享模式(SHARED)的用武之地。共享模式下,tryAcquireShared返回正数表示获取成功,并且会传播唤醒后续节点。FutureTask内部就是通过一个Sync类继承 AQS,重写tryAcquireShared和tryReleaseShared来实现的。
简单说:独占模式是“一把钥匙开一把锁”,共享模式是“一个信号弹通知所有等待的人”。FutureTask需要的是后者。
3. 手写一个简化版 FutureTask:核心结构搭建
3.1 定义状态与成员变量
先把骨架搭起来。我们需要一个Callable来承载业务逻辑,一个Object来存结果或异常,一个volatile int来表示状态,以及一个继承 AQS 的内部同步器。
public class SimpleFutureTask<V> implements Runnable, Future<V> { private Callable<V> callable; private Object outcome; // 存结果或异常 private volatile int state; // 任务状态 private volatile Thread runner; // 执行任务的线程 private final Sync sync = new Sync(); 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; public SimpleFutureTask(Callable<V> callable) { if (callable == null) throw new NullPointerException(); this.callable = callable; } }这里有几个细节值得说。outcome用Object而不是V,是因为它既要存正常结果,又要存异常对象,用Object可以统一处理。runner记录当前执行任务的线程,主要是为了支持cancel(true)时能中断它。state用volatile修饰,保证多线程之间的可见性。
3.2 内部 Sync 类的设计
Sync继承AbstractQueuedSynchronizer,只需要重写两个方法:
private final class Sync extends AbstractQueuedSynchronizer { // 共享模式获取:state != NEW 表示任务已完成,可以获取 @Override protected int tryAcquireShared(int arg) { return (getState() == NORMAL || getState() == EXCEPTIONAL || getState() == CANCELLED) ? 1 : -1; } // 共享模式释放:把 state 设为终态,返回 true 触发唤醒 @Override protected boolean tryReleaseShared(int arg) { setState(NORMAL); return true; } }tryAcquireShared返回1表示获取成功,返回-1表示需要排队等待。tryReleaseShared返回true表示释放成功,AQS 会负责唤醒队列中所有等待的共享节点。
提示:真实的
FutureTask源码里,tryAcquireShared的判断更复杂,会区分NORMAL、EXCEPTIONAL、CANCELLED、INTERRUPTED等多种终态。这里为了简化,只保留了最核心的几种。
3.3 run 方法的实现
run()方法是任务的入口,它负责调用Callable、写入结果、更新状态、唤醒等待者:
@Override public void run() { // 只有 NEW 状态才能执行,防止重复执行 if (state != NEW || !sync.compareAndSetState(NEW, COMPLETING)) { return; } runner = Thread.currentThread(); try { V result = callable.call(); setOutcome(result); sync.releaseShared(0); // 唤醒所有等待 get() 的线程 } catch (Throwable t) { setOutcome(t); sync.releaseShared(0); } finally { runner = null; } } private void setOutcome(Object result) { outcome = result; // 这里用 CAS 把 COMPLETING 改成 NORMAL // 简化处理,实际源码用 UNSAFE 或 VarHandle state = NORMAL; }这里有个容易踩的坑:compareAndSetState(NEW, COMPLETING)这一步很关键。它保证了同一个任务不会被两个线程同时执行。如果两个线程同时拿到这个FutureTask并调用run(),只有一个能成功把状态从NEW改成COMPLETING,另一个直接返回。
4. get 方法的阻塞与唤醒:AQS 共享模式的实战
4.1 get 方法的整体逻辑
get()方法的核心就一句话:如果任务没完成,就排队等待;如果完成了,就返回结果或抛出异常。用 AQS 的语言来说,就是调用acquireSharedInterruptibly(0):
@Override public V get() throws InterruptedException, ExecutionException { int s = state; if (s <= COMPLETING) { // 任务未完成,进入 AQS 队列等待 s = sync.acquireSharedInterruptibly(0); } if (s == CANCELLED) { throw new CancellationException(); } return report(s); }acquireSharedInterruptibly是 AQS 提供的模板方法,它会先调用我们重写的tryAcquireShared,如果返回负数,就把当前线程包装成共享节点加入 CLH 队列,然后park自己。当任务完成时,releaseShared会唤醒队列中的节点,被唤醒的线程再次尝试tryAcquireShared,这次返回正数,于是成功返回。
4.2 为什么 get 方法要区分“快路径”和“慢路径”
注意上面代码里的if (s <= COMPLETING)判断。这是一个快路径优化:如果任务已经完成了(state是NORMAL或EXCEPTIONAL),根本不需要去碰 AQS 队列,直接读结果就行。只有任务还没完成时,才走 AQS 的排队逻辑。
这个设计的意义在于:get()方法在任务完成后被调用的频率可能非常高(比如轮询场景),如果每次都走 AQS 的acquire流程,即使不阻塞,也会有 CAS 和队列操作的开销。快路径用一个简单的volatile读就搞定了,性能好很多。
实操心得:我在自己实现的时候,一开始把
if判断去掉了,直接调acquireSharedInterruptibly。结果发现任务完成后调用get()依然会走一遍完整的 AQS 流程,虽然不会阻塞,但吞吐量明显下降。加上快路径判断后,性能提升非常明显。这个细节在源码里很容易被忽略,但它是真实存在的优化。
4.3 report 方法:结果与异常的分离处理
get()最终返回结果时,需要区分“正常结果”和“异常”两种情况:
private V report(int s) throws ExecutionException { Object x = outcome; if (s == NORMAL) { return (V) x; } if (s == CANCELLED) { throw new CancellationException(); } throw new ExecutionException((Throwable) x); }这里用state来判断outcome里存的是结果还是异常。如果是NORMAL,outcome就是正常结果;如果是EXCEPTIONAL,outcome就是异常对象,需要包装成ExecutionException抛出。这种“用一个字段存两种东西,靠状态区分”的做法,在 JDK 源码里很常见,好处是省内存,坏处是可读性稍差。
4.4 唤醒传播:共享模式的精髓
当run()方法执行完,调用sync.releaseShared(0)时,AQS 内部会做这几件事:
- 调用
tryReleaseShared,把state设为NORMAL,返回true。 - 检查 CLH 队列的
head节点,如果head.next是共享节点,就唤醒它。 - 被唤醒的节点尝试
tryAcquireShared,成功后继续唤醒它的后继节点(这就是“传播”)。
这个传播机制保证了所有等待get()的线程都会被依次唤醒,而不是只唤醒一个。如果用的是独占模式,release只会唤醒一个节点,其他线程还得继续等,这显然不符合FutureTask的语义。
我画一个简化的流程帮助理解(用文字描述):
- 线程 A、B、C 都调用了
get(),任务未完成,三个线程依次入队。 - 任务执行完毕,
releaseShared被调用。 - AQS 唤醒队列中第一个节点(线程 A)。
- 线程 A 的
tryAcquireShared返回 1,成功获取,同时触发传播,唤醒线程 B。 - 线程 B 同样成功获取并唤醒线程 C。
- 三个线程都拿到结果返回。
整个过程不需要额外的notifyAll,AQS 的共享模式天然支持这种“一对多”的唤醒语义。
5. 实操中容易踩的坑与排查技巧
5.1 状态判断用 == 还是 <=
在get()方法里,判断任务是否完成时,我用的是s <= COMPLETING。为什么不是s == NEW?因为COMPLETING也是一个“未完成”的中间态——此时任务逻辑已经跑完,但结果还没写入outcome。如果此时让get()返回,可能读到旧值或空值。所以COMPLETING也必须走等待流程。
这个细节很容易写错。我见过有人在实现时用s == NEW判断,结果在COMPLETING窗口期调用get()会直接返回null,造成偶发的空指针问题。这种 bug 极难复现,因为COMPLETING窗口非常短。
5.2 异常处理的顺序问题
run()方法里,setOutcome和releaseShared的调用顺序不能反。必须先写结果,再释放同步状态。如果先releaseShared,等待线程被唤醒后可能读到还没写入的outcome,拿到null。
同理,setOutcome内部也要先写outcome,再改state。这就是为什么需要COMPLETING这个中间态——它保证了“写结果”和“改状态”之间的顺序性。
5.3 中断响应的处理
acquireSharedInterruptibly会在等待过程中响应中断。如果线程在get()时被中断,会抛出InterruptedException。但要注意,此时任务可能还在执行,FutureTask本身不会因为get()被中断而取消任务。这是符合Future接口语义的——get()的中断只影响等待行为,不影响任务执行。
如果需要“中断等待的同时取消任务”,得自己额外调用cancel(true)。这一点在实际使用中经常被混淆,值得注意。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| get() 一直阻塞不返回 | run() 没被调用,或 state 没更新 | 检查任务是否提交到线程池,检查 run() 里的 CAS 是否成功 |
| get() 返回 null | COMPLETING 状态被误判为完成 | 检查状态判断是否用了 <= COMPLETING |
| 多个 get() 只有一个被唤醒 | 用了独占模式而非共享模式 | 检查 tryAcquireShared 的返回值,确认是共享模式 |
| 任务被执行两次 | run() 缺少 CAS 状态检查 | 检查 compareAndSetState(NEW, COMPLETING) 是否存在 |
| get() 抛出的异常类型不对 | report 方法状态判断有误 | 检查 NORMAL/EXCEPTIONAL/CANCELLED 的分支 |
避坑技巧:调试 AQS 相关问题时,可以在
tryAcquireShared和tryReleaseShared里加日志,打印当前线程名和 state 值。AQS 的问题往往出在状态判断上,把状态流转打出来,一眼就能看出问题。
6. 从简化版到真实源码:还差哪些细节
6.1 状态变更的原子性保障
我上面的简化版里,setOutcome直接用了state = NORMAL赋值。但真实源码里,这一步是用 CAS 完成的(通过UNSAFE.compareAndSwapInt或VarHandle)。为什么要用 CAS?因为COMPLETING到NORMAL的转换虽然只有一个线程会执行(run()里已经保证了),但cancel()方法可能并发地把状态改成CANCELLED。用 CAS 可以保证只有一个操作成功,避免状态被覆盖。
6.2 cancel 方法的实现要点
cancel(boolean mayInterruptIfRunning)的逻辑比get()复杂一些:
@Override public boolean cancel(boolean mayInterruptIfRunning) { if (!(state == NEW && sync.compareAndSetState(NEW, CANCELLED))) { return false; } if (mayInterruptIfRunning) { Thread t = runner; if (t != null) { t.interrupt(); } } sync.releaseShared(0); // 唤醒等待的 get() 线程 return true; }关键点:取消成功后也要调用releaseShared,否则等待get()的线程会一直阻塞。这是一个很容易遗漏的地方——很多人只记得任务完成时唤醒,忘了取消时也要唤醒。
6.3 泛型与类型转换的处理
真实源码里,outcome的类型转换用了(V)强转,编译器会给出 unchecked 警告。JDK 源码里用@SuppressWarnings("unchecked")压制。自己实现时如果不想看到警告,也可以这样做。但要注意,强转的安全性是由state保证的——只有NORMAL状态下outcome才是V类型,其他状态下可能是Throwable。
6.4 与线程池的配合
FutureTask通常和线程池配合使用。ThreadPoolExecutor.submit()内部就是把Callable包装成FutureTask,然后调用execute()。理解这一点后,就能明白为什么FutureTask要实现Runnable——因为线程池只接受Runnable。
自己实现时,可以写一个简单的测试:
public static void main(String[] args) throws Exception { SimpleFutureTask<String> task = new SimpleFutureTask<>(() -> { Thread.sleep(2000); return "done"; }); new Thread(task).start(); System.out.println("等待结果..."); String result = task.get(); System.out.println("结果: " + result); }运行后应该看到“等待结果...”先打印,2 秒后才打印“结果: done”。如果把get()换成get(1, TimeUnit.SECONDS),应该抛出TimeoutException。
7. 几个值得深挖的延伸问题
7.1 为什么不用 synchronized + wait/notify
有人可能会问:用synchronized加wait/notifyAll也能实现类似效果,为什么非要用 AQS?原因有几个:
- 性能:AQS 的 CLH 队列在竞争激烈时比
wait/notifyAll更高效,因为notifyAll会唤醒所有等待线程,然后它们一起竞争锁,造成“惊群效应”。AQS 的共享模式虽然也是唤醒所有,但每个节点获取成功后直接返回,不需要重新竞争锁。 - 可中断性:AQS 提供了
acquireSharedInterruptibly,天然支持中断响应。用wait/notify需要自己处理InterruptedException。 - 超时支持:AQS 提供了
tryAcquireSharedNanos,实现带超时的get()很方便。
7.2 共享模式与独占模式的本质区别
| 维度 | 独占模式 | 共享模式 |
|---|---|---|
| 获取方法 | tryAcquire | tryAcquireShared |
| 返回值 | boolean | int(负数失败,非负数成功) |
| 唤醒行为 | 只唤醒后继一个节点 | 唤醒后继节点并传播 |
| 典型应用 | ReentrantLock | CountDownLatch、Semaphore、FutureTask |
理解这个区别后,再看FutureTask的Sync实现,就会觉得非常自然——它需要的就是“任务完成后通知所有等待者”,这正是共享模式的语义。
7.3 超时 get 的实现思路
带超时的get(long timeout, TimeUnit unit)用的是tryAcquireSharedNanos,它在acquireSharedInterruptibly的基础上增加了超时判断。如果超时还没获取到,就返回负数,然后get()抛出TimeoutException。实现时需要注意:超时后要把节点从队列中移除,否则会造成内存泄漏。AQS 内部已经处理了这一点,自己实现时如果不用 AQS,就得手动清理。
8. 我个人的一些实践体会
自己动手实现一遍FutureTask之后,我对 AQS 共享模式的理解上了一个台阶。以前看CountDownLatch的源码,总觉得tryAcquireShared返回1和-1的语义有点绕,现在明白了:返回非负数表示“可以继续”,返回负数表示“需要等待”。这个约定贯穿了所有共享模式的实现。
另外有一个小技巧分享:如果你在调试 AQS 相关代码时发现线程莫名其妙地卡住,可以在park前后加日志,或者用jstack看线程状态。AQS 的等待线程状态是WAITING (parking),如果看到大量这种状态的线程,基本可以确定是某个release没被调用。
还有一个容易忽略的点:FutureTask的get()在任务被取消后调用,会抛出CancellationException,而不是ExecutionException。这个异常是RuntimeException的子类,不需要显式捕获。很多人第一次遇到时会困惑为什么catch (ExecutionException e)没接住,其实就是异常类型搞错了。
最后说一个扩展方向:如果你对 AQS 感兴趣,可以试着把FutureTask改造成支持“多个任务依赖”的版本——比如任务 B 依赖任务 A 的结果,A 完成后自动触发 B。这其实就是CompletableFuture的雏形。理解了FutureTask的同步机制,再看CompletableFuture的Completion链式结构,会容易很多。