news 2026/10/12 23:15:21

从零手写FutureTask:深入AQS共享模式实现get阻塞与唤醒

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写FutureTask:深入AQS共享模式实现get阻塞与唤醒

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 内部会做这几件事:

  1. 调用tryReleaseShared,把state设为NORMAL,返回true。
  2. 检查 CLH 队列的head节点,如果head.next是共享节点,就唤醒它。
  3. 被唤醒的节点尝试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() 返回 nullCOMPLETING 状态被误判为完成检查状态判断是否用了 <= 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 共享模式与独占模式的本质区别

维度独占模式共享模式
获取方法tryAcquiretryAcquireShared
返回值booleanint(负数失败,非负数成功)
唤醒行为只唤醒后继一个节点唤醒后继节点并传播
典型应用ReentrantLockCountDownLatch、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链式结构,会容易很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 23:12:03

open-code-review:从形式主义走向可量化的代码审查工作流

很多团队的代码审查最终会变成这样&#xff1a;需求合并前一天&#xff0c;审查者花十几分钟快速扫一遍 diff&#xff0c;随手点个 approve&#xff0c;然后在群里说一句"代码没问题"。真正的问题——设计缺陷、边界遗漏、牵一发动全身的隐患——通常要到上线几周甚至…

作者头像 李华
网站建设 2026/10/12 23:11:44

高斯-勒让德求积公式:原理、Python实现与数值微分实战

做科学计算久了你会发现&#xff0c;微积分课上的求积分技巧到了工程现场基本靠不住——手里的被积函数通常来自实测数据&#xff0c;或者来自另一个数值程序&#xff0c;根本拿不出原函数。这个时候只能老老实实走数值积分。而在只拿到离散采样点、没有解析表达式的地方&#…

作者头像 李华
网站建设 2026/10/12 23:11:22

开源代码审查工具实战:内网部署、规则引擎与人工复核方案

做了这么多年开发&#xff0c;我越来越觉得代码审查是所有研发流程里最容易被做成“形式主义”的一个环节。你说它不重要吧&#xff0c;很多线上事故明明可以在提交阶段被拦下来&#xff1b;你说它重要吧&#xff0c;真正愿意逐行看别人代码的团队真没几个。我见过太多评审流于…

作者头像 李华
网站建设 2026/10/12 23:04:54

知识付费课程结构化设计:16个DeepSeek提示词模板实战指南

简介&#xff1a;本资源是一套专为知识付费从业者设计的AI提示词实战模板集&#xff0c;面向课程产品经理、内容创作者、营销人员及创业新手&#xff0c;解决课程开发效率低、内容结构松散、营销文案乏力、用户运营缺方法等核心痛点。文档《deepseek知识付费类提示词模板16个.p…

作者头像 李华
网站建设 2026/10/12 23:01:31

20 TOPS端侧AI如何逆袭2000 TOPS GPU?实战解析有效算力与场景选型

去年接手一个智能视觉检测项目&#xff0c;真正让我对这个话题改观的&#xff0c;是在某仓储园区的现场&#xff1a;一台峰值差不多2000 TOPS的大算力GPU服务器&#xff0c;被一块才20 TOPS左右的端侧AI小板子按在地上摩擦。这不是段子&#xff0c;是真实发生过的事情。很多人一…

作者头像 李华
网站建设 2026/10/12 23:00:43

SpringBoot仓储管理系统实战:库存建模、物料追溯与并发控制核心设计

1. 题目拆解&#xff1a;这个系统真正要解决的是什么很多同学拿到“SpringBoot仓储管理系统”这个题目时&#xff0c;第一反应是“做一个增删改查”。这么说吧&#xff0c;如果真按这个思路做完&#xff0c;答辩时大概率会被问住。因为毕设题目里的“智能仓库作业”“库存管控”…

作者头像 李华