☰
FutureTask原理剖析:Callable适配Runnable与结果获取
2026/9/29 17:15:42 网站建设 项目流程

FutureTask 原理剖析:如何将 Callable 适配为 Runnable 并获取结果

在 Java 并发编程里,FutureTask是我见过最耐看的一个类。它本身不是用来干活的,而是用来“接活儿”的:一边接住Callable这种能返回计算结果的任务,一边把它包装成Runnable,塞给 Thread 或线程池去执行,最后再让你通过 get() 把结果取回来。这个类我翻过好几遍源码,每次都有新收获,尤其它内部那套状态机和线程等待唤醒机制,设计得相当精巧。这篇博客我就拿实际代码和源码片段,把 FutureTask 的核心原理掰开揉碎讲清楚,重点是它到底怎么把 Callable 适配成 Runnable,以及 get() 背后的阻塞与唤醒逻辑。不搞虚的,适合所有写 Java 并发代码、尤其是用线程池时想优雅拿返回值的同学。

相信不少朋友刚接触并发时都有过这样的困惑:Thread 只认 Runnable,可 Runnable 的 run() 方法偏偏没有返回值,业务里又有太多场景需要拿到一个异步计算的结果。早期要不就得自己写回调,要不就得搞一个共享变量加等待通知机制,麻烦且容易出错。FutureTask 的意义就在于,它把这两件事封装成了标准组件,你只要写好 Callable,剩下“异步执行、结果存取、超时控制、取消任务”这些脏活累活,它全包了。

1. 为什么需要 FutureTask:从 Runnable 的短板说起

1.1 Runnable 与 Callable 的根本差异

先看最基础的接口定义。Runnable 的 run() 长得特别朴素:没有返回值,也不能抛受检异常。Callable 的 call() 则有一个泛型返回值,throws Exception 直接声明了可以抛出异常。

@FunctionalInterface public interface Runnable { public abstract void run(); } @FunctionalInterface public interface Callable<V> { V call() throws Exception; }

就这俩接口的差异,决定了它们在并发体系里各自的定位。Runnable 适合“只关心过程,不关心结果”的任务,比如打日志、刷缓存、发通知;Callable 适合“必须拿到结果”的任务,比如远程接口调用、复杂计算、批量查询。Thread 类的构造函数只接受 Runnable,线程池的 execute() 方法也只接受 Runnable,而 submit() 才能收 Callable。为什么?因为底层要拿到结果并封装成 Future,就是 FutureTask 在做的事。

这里有个容易忽略的细节:Runnable 的 run() 是“不能抛受检异常”的,但 Callable 的 call() 是“可以抛任何异常”的。所以当 FutureTask 把 Callable 适配为 Runnable 之后,run() 内部必须捕获 call() 抛出的异常,不能让它直接冒出去。否则就会破坏 Runnable 对异常处理的基本约定,线程池也会不知所措。捕获之后怎么办?存起来,等 get() 的时候再以 ExecutionException 的形式吐出来。这是整个适配逻辑里最关键的一个点。

1.2 适配器模式在 FutureTask 中的体现

FutureTask 干的事,本质上就是适配器模式。它把一个 Callable 适配成了 Runnable,类声明就写得很直白:FutureTask implements RunnableFuture,RunnableFuture 又同时继承了 Runnable 和 Future。所以它既是 Runnable 又是 Future,既能交给线程执行,又能让调用方查询和获取结果。

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

从设计模式角度看,FutureTask 这里用的不是简单的对象适配器,而是“接口合并”的实现方式。它同时拥有两种身份:作为 Runnable,它能被 Thread 直接接收;作为 Future,它提供了 get()、cancel()、isDone()、isCancelled() 这些方法。调用方视角根本不用关心它内部怎么适配的,只需要持有 FutureTask 引用,提交给执行器,然后 get() 就完了。

还记得代码里有个不引人注意的内部类 RunnableAdapter,这才是最原始的适配工具。早期 JDK 里 Executors.callable(Runnable) 会把 Runnable 包成 Callable,FutureTask 构造器也直接支持传入 Runnable 加固定结果。后来 JDK 8 的 FutureTask 调整了实现方式,直接在构造器里把 Callable 包成 RunnableAdapter,内部通过一个 callable 字段持有任务本体。而你传入 Runnable 时,会被包装成Executors.callable(runnable, result),本质上还是 Callable。

// Executors 中的适配实现(JDK 8) public static <T> Callable<T> callable(Runnable task, T result) { if (task == null) throw new NullPointerException(); return () -> { task.run(); return result; }; }

这段代码灵气十足。它把 Runnable 的 run() 执行动作放进 Callable.call() 里,执行完成后返回预设的 result。这样 FutureTask 的构造器就可以统一处理 Callable 了。你传 Callable,直接用;你传 Runnable,就包一层再存起来。构造器里还有一个针对 Callable 特殊情况的优化:如果传入的 Callable 本身是 FutureTask,就会尝试直接取出它的 callable。这个优化很少被注意到,但它避免了一层无意义的包装嵌套。

2. FutureTask 的骨架设计:状态字段撑起整个生命周期

2.1 状态机:从 NEW 到终态的流转

FutureTask 内部最核心的就是一个volatile int state字段,它记录了任务从创建到结束的完整状态变化。这个字段的值从 0 到 6,含义分别是:

状态值状态名含义
0NEW新建状态,任务尚未执行
1COMPLETING正在设置结果或异常
2NORMAL正常完成,结果已保存
3EXCEPTIONAL任务执行抛异常
4CANCELLED任务被取消(未中断)
5INTERRUPTING正在中断执行任务的线程
6INTERRUPTED中断完成

状态流转只有三条主线:正常路径是从 NEW 到 COMPLETING 再到 NORMAL;异常路径是 NEW 到 COMPLETING 再到 EXCEPTIONAL;取消路径是 NEW 到 CANCELLED,如果选择中断线程,则经过 INTERRUPTING 再到 INTERRUPTED。这三条路径在代码里分别由 set()、setException()、cancel() 驱动。

为什么要在 NORMAL 之前设计一个 COMPLETING 中间态?这是为了允许“正在唤醒等待线程”的时候,get() 的调用方能够根据状态提前判断:如果已经不在 NEW 了,直接读结果或抛异常,不用再入队等待。COMPLETING 是一个极短暂的过渡状态,它主要起到“并发可见性屏障”的作用。不过说实话,源码里对 COMPLETING 的使用并不强制 CAS,只是简单地赋值,因为执行 set 的只有一个线程,while 循环里用 state 变量保持可见性就够了。

2.2 关键字段:outcome, runner 与 waiters

除了 state,FutureTask 还有三个关键字段:

private Callable<V> callable; // 包装后的任务本体 private Object outcome; // 执行结果或异常对象 private volatile Thread runner; // 正在执行任务的线程 private volatile WaitNode waiters; // 等待结果的线程链表头

callable 负责真正干活;outcome 负责存结果或异常;runner 在任务执行期间被设为当前线程,主要服务于 cancel() 的中断逻辑;waiters 则是一个 Treiber 栈结构的链表头,所有调用 get() 且任务未完成的线程都会封装成一个 WaitNode 挂到链上。

outcome 的类型是 Object 而不是 V,这一点也可以理解,因为异常同样是需要保存的“结果”。当任务正常完成时,outcome 保存计算结果;任务异常时,outcome 保存异常对象;任务被取消时,outcome 不会被赋值。get() 内部会根据 state 判断:如果是 NORMAL,就把 outcome 强转为 V 返回;如果是 EXCEPTIONAL,就把 outcome 包装成 ExecutionException 抛出;如果是 CANCELLED 或 INTERRUPTED,则抛 CancellationException。

runner 字段最妙的一点在于,它不是仅仅用于记录,而是和 CAS 操作绑定。任务执行的 run() 方法第一步就是通过 Unsafe CAS 把 runner 从 null 修改为当前线程,如果 CAS 失败,说明已经有线程在执行这个任务了,当前线程直接 return。这就是 FutureTask 保证“同一时刻只有一个线程执行任务”的手段。

2.3 构造器的隐藏细节

FutureTask 有两个构造器:

public FutureTask(Callable<V> callable) { if (callable == null) throw new NullPointerException(); this.callable = callable; this.state = NEW; // 显式赋值,保证可见性 } public FutureTask(Runnable runnable, V result) { this.callable = Executors.callable(runnable, result); this.state = NEW; }

第二个构造器就是“Runnable 适配为 Callable”的捷径,传入 Runnable 和预设结果值,内部转成一个返回固定值的 Callable。这意味着 FutureTask 不是只能配合 Callable 使用,也能完美兼容老代码里的 Runnable。有很多线程池封装老代码的场景会用到这个构造器,比如new FutureTask<>(task, null),拿到一个不关心结果但想控制取消的任务。state 字段在构造器里被显式赋为 NEW。虽然它是 volatile 的,默认值就是 0,但显式赋值是给 JIT 和读线程一个更明确的信号,避免重排序带来的边界问题。

3. 核心适配逻辑:run() 到底做了什么

3.1 run() 方法的完整时序

前面铺垫了这么多,终于到核心了。FutureTask 的 run() 本身作为 Runnable 适配入口,被 Thread 或线程池调用。方法关键代码大致是这样:

public void run() { if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, 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); } }

这段代码信息量很大。首先它检查 state 是不是 NEW,再用 CAS 把 runner 从 null 设置成当前线程。如果 CAS 失败,说明任务已经在其他线程上运行了,直接退出,避免了重复执行。这里还考虑到了任务被取消的情况:如果 state 在设置为 NEW 之后被 cancel() 改成 CANCELLED,或者进入 INTERRUPTING,run() 方法会看到state != NEW然后返回。但这里有个微妙的点:由于 CAS runner 发生在 check state 之后,存在一个微小的时间窗口。

接下来调用c.call(),如果正常返回,就调用 set(result) 记录结果;如果抛出异常,就调用 setException(ex) 记录异常和状态。这两个 set 方法只负责设置 outcome 和改变状态,真正“唤醒等待线程”的动作在 finishCompletion() 里统一处理。异常被捕获得极其宽泛,catch 的是 Throwable 而不是 Exception,因为未来 JVM 可能出现其他 Error 类型,不能让 Error 破坏 FutureTask 的状态一致性。

最后 finally 块里有三个动作:把 runner 置空,读取当前状态,如果状态是 INTERRUPTING 就调用handlePossibleCancellationInterrupt(s)。这一步是为了处理“线程正在被中断,但中断尚未完成”的中间状态。做什么呢?实际上是自旋让出 CPU,等待 cancel() 线程把状态推进到 INTERRUPTED。这个细节极其冷门,我在读源码时差点忽略,但它保证了认知一致性:如果任务被要求中断,必须等中断彻底完成,否则线程可能重新开始执行任务,引发状态混乱。

3.2 结果保存与线程唤醒的拆分设计

set 和 setException 的内部实现逻辑很像,核心都是修改状态然后触发 finishCompletion。

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

注意这里的次序:先 CAS 从 NEW 到 COMPLETING,然后赋值 outcome,再 putOrderedInt 从 COMPLETING 改为 NORMAL 或 EXCEPTIONAL。putOrderedInt 是弱形式的 volatile 写,不保证立刻对其他线程可见,但保证不会重排序到 outcome 赋值之前。这种写法的性能比普通 volatile 写略好,因为不需要强制内存屏障。对于读线程来说,只要能看到 state 变成 NORMAL,就一定能看到前面的 outcome 赋值。

finishCompletion() 做的事情是遍历 waiters 链表,唤醒每一个节点上阻塞的线程,然后清空 waiter 链表。这个方法还顺带把 callable 置空,让任务对象不再持有底层的 Callable,方便 GC 回收。从线程安全角度看,它做了两层处理:第一层通过自旋 CAS 反复尝试获取 waiters 头节点并将链表头设为 null,防止并发 get() 线程不断往链表里添加新节点;第二层通过同步块保证同一个节点不会被重复唤醒。

3.3 手写一个简化版 FutureTask 理解适配本质

源码看多了容易晕,我建议你亲手写一个极简版本,只保留最核心的“适配 + 结果存取 + 等待唤醒”。下面这个简化模型去掉了很多并发优化,但足够还原核心流程:

public class SimpleFutureTask<V> implements Runnable { private final Callable<V> callable; private volatile V result; private volatile boolean done; public SimpleFutureTask(Callable<V> callable) { this.callable = callable; } @Override public void run() { try { result = callable.call(); } catch (Exception e) { throw new RuntimeException(e); } finally { done = true; synchronized (this) { notifyAll(); } } } public V get() throws InterruptedException { synchronized (this) { while (!done) { wait(); } } return result; } }

这个版本没有状态机、没有 CAS、没有超时控制,但它把 FutureTask 的最小原型讲清楚了:run() 把 callable 包起来执行,get() 拿到结果。实际 FutureTask 做的事情完全围绕着这个模型,只是把同步和状态控制做到了极致的细粒度,以便在超高性能并发下依然出色。

4. get() 方法与阻塞唤醒机制到底怎么运作

4.1 get() 的两种形态

public V get() throws InterruptedException, ExecutionException { int s = state; if (s <= COMPLETING) s = awaitDone(false, 0L); return report(s); } public V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { if (unit == null) throw new NullPointerException(); int s = state; if (s <= COMPLETING && (s = awaitDone(true, unit.toNanos(timeout))) <= COMPLETING) throw new TimeoutException(); return report(s); }

无参 get() 会无限期等待直到任务完成;带超时的 get() 会在等待超时后抛出 TimeoutException。判断条件都是state <= COMPLETING,也就是任务还处于 NEW、COMPLETING 这两个未完成状态。为什么连 COMPLETING 也要等待?因为 COMPLETING 只是设置结果的瞬间,结果可能还没完全写入 memory,立刻读取可能读到旧值。这属于状态机给内存可见性上的保险。

awaitDone 是阻塞的核心方法,它是整个 FutureTask 里最复杂的部分,我把它拆成三个关键点讲。

4.2 等待链表:WaitNode 的组织方式

FutureTask 的等待结构是 Treiber Stack,一个无锁并发栈。每个调用 get() 且任务未完成的线程,会被封装成一个 WaitNode,通过 CAS 压入栈顶:

private int awaitDone(boolean timed, long nanos) throws InterruptedException { long deadline = timed ? System.nanoTime() + nanos : 0L; WaitNode q = null; boolean queued = false; for (;;) { if (Thread.interrupted()) { removeWaiter(q); throw new InterruptedException(); } int s = state; if (s > COMPLETING) { if (q != null) q.thread = null; return s; } else if (s == COMPLETING) Thread.yield(); else if (q == null) q = new WaitNode(); else if (!queued) queued = UNSAFE.compareAndSwapObject(this, waitersOffset, q.next = waiters, q); else if (timed) { nanos = deadline - System.nanoTime(); if (nanos <= 0L) { removeWaiter(q); return state; } LockSupport.parkNanos(this, nanos); } else LockSupport.park(this); } }

这里有几个设计亮点值得单独说。

第一,首轮循环先检查 Thread.interrupted(),区别于 isInterrupted(),它会清除中断标志位。如果发现当前线程被中断,它会把等待节点从链表上移除,并抛出 InterruptedException。这是 FutureTask 对中断语义的严格实现:等待 get() 的线程可以被中断退出。

第二,如果状态是 COMPLETING,即另一个线程正在设置结果,这里不会入队,而是执行 Thread.yield() 让出 CPU。因为 COMPLETING 是瞬时状态,让出 CPU 等对方写完比入队唤醒的代价更小。这个优化非常精细。

第三,等待通过 LockSupport.park / parkNanos 完成,而不是 Object.wait。LockSupport 不需要持有锁,不会和 synchronized 争用,配合 Treiber 栈的 CAS 入队,就组成了一个完全无锁的等待唤醒模型。只有当任务达到终态并调用 finishCompletion 时,才会通过 UNSAFE.unpark 去唤醒链表上的线程。

4.3 唤醒之后:report() 如何转换结果

当一个等待线程被唤醒,awaitDone 的循环会重新读取 state,如果发现状态大于 COMPLETING,就返回这个状态。然后 get() 调用 report(s):

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); }

这里的逻辑一眼就能看明白:正常完成,直接强转并返回结果;被取消,抛 CancellationException;其他异常情况,把 outcome 强转为 Throwable,包进 ExecutionException 抛出。

很多同学第一次看到 ExecutionException 会一头雾水:实际业务报错信息明明在 cause 里,为什么外层还要包一层?因为 get() 本身无法区分“任务执行失败”和“获取结果过程失败”,干脆约定:只要任务是异常终止的,不管什么原因,统一抛 ExecutionException,真正的异常放在 cause 里。这样调用方的异常处理逻辑就不用关心具体异常类型,只需要通过 getCause() 去拆解。建议业务代码里统一这样处理:

try { Object result = futureTask.get(); } catch (CancellationException e) { // 任务被取消 } catch (ExecutionException e) { // 业务异常,解包处理 Throwable cause = e.getCause(); log.error("Task failed: ", cause); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }

还有个细节,InterruptedException 抛出前,awaitDone 已经通过 Thread.interrupted() 清除了中断标记。所以在 catch 里重新执行Thread.currentThread().interrupt()恢复中断标记是很有必要的,否则外层调用方会丢失“当前线程曾被中断”这个重要信息,这也是容易被忽略的坑。

5. 实操演示:线程池 + FutureTask 的标准用法

5.1 不使用线程池的直接执行

FutureTask 最常见的两种用法,第一种是配合 Thread 直接执行。这种方式的优点是不需要线程池,代码简单直接;缺点是线程无法复用,只适合任务量少的场景。

FutureTask<String> task = new FutureTask<>(() -> { TimeUnit.SECONDS.sleep(2); return "Result from thread"; }); new Thread(task, "worker-thread").start(); String result = task.get(); System.out.println(result);

new Thread 接收的是 Runnable,FutureTask 正好实现了 Runnable,所以可以直接传入。这里就看出了适配模式的价值:Thread 完全不认识 Callable 和 Future,但它认识 Runnable,而 FutureTask 是 Runnable 的合法实现。

5.2 配合线程池获取返回值

第二种是配合线程池使用。ExecutorService.submit(Callable) 内部其实就是把 Callable 包装成 FutureTask 再交给 execute() 执行。不过这里有个值得一提的点,看源码会发现它用的是newTaskFor(callable),而不是直接 new FutureTask:

protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) { return new FutureTask<T>(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 是 protected 方法,意味着子类可以重写它,替换成自定义的 RunnableFuture 实现。很多框架就是通过继承 ThreadPoolExecutor 并重写 newTaskFor 来扩展任务跟踪、添加监听回调等功能。比如你可以重写它,给每个任务包装一层审计信息,再交给真正执行器。

在实际项目里,我更推荐直接使用 submit() 返回的 Future,而不是自己手动 new FutureTask 再交给线程池 execute()。因为 submit() 还顺带帮你做了泛型类型推断和异常封装的统一处理。但如果你的场景是需要同时控制任务的生命周期和传递同一个 FutureTask 给多个地方,手动创建 FutureTask 就有优势。

5.3 综合示例:多任务并行计算并汇总结果

放一个稍微贴近业务实际的例子。假设要并行调用三个外部接口,最后把结果拼接起来:

ExecutorService executor = Executors.newFixedThreadPool(3); List<FutureTask<String>> tasks = new ArrayList<>(); tasks.add(new FutureTask<>(() -> queryOrder())); tasks.add(new FutureTask<>(() -> queryUser())); tasks.add(new FutureTask<>(() -> queryProduct())); for (FutureTask<String> task : tasks) { executor.submit(task); } List<String> results = new ArrayList<>(); for (FutureTask<String> task : tasks) { try { results.add(task.get(3, TimeUnit.SECONDS)); } catch (TimeoutException e) { results.add("timeout"); task.cancel(true); } catch (ExecutionException e) { results.add("error: " + e.getCause().getMessage()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } }

提交时直接用 execute(task) 或 submit(task) 都可以,因为 FutureTask 已经是 RunnableFuture 了。这里我给 get 加了 3 秒超时,防止某个接口拖慢整个流程。如果超时了,就取消任务,避免它继续占用线程资源。这种“先提交所有任务,再逐个 get”的模式在并行调用场景里非常常用。

6. 常见问题与排查技巧实录

6.1 get() 一直阻塞不返回怎么办

这是使用 FutureTask 最高频的问题。任务本身可能因为网络等待、死锁、死循环等原因长时间不结束,而 get() 在主线程里无限等待,导致整个应用卡住。

解决办法有两条。第一,能用带超时的 get 就别用无参 get。get(3, TimeUnit.SECONDS)至少能保证你有退出机会。第二,超时后根据业务决定要不要 cancel(true)。注意 cancel(true) 只是给执行线程发送中断信号,如果任务不响应中断(比如纯计算任务,或者 catch 了 InterruptedException 后继续执行),任务还会继续跑下去。cancel(true) 之后,get() 会抛 CancellationException,但线程池里那个 worker 线程依然被占用着,直到任务真正返回。

我在一个实际案例里遇到过这种情况:任务内部调用了另一个服务的 connection 方法,socket 超时设置成了 5 分钟,而业务端 get 超时只有 3 秒。3 秒后 get 超时返回,主线程继续往下走,但 worker 线程还卡在那次连接上。排查时线程池的线程数量一直在增长,最后撑爆了连接池。后来把注意力放在梳理任务内部的外部调用超时时间上,而不是单纯靠在 get() 这里做文章。

6.2 任务抛异常后,get() 的表现

有同学以为任务里 try-catch 住异常,get 就不会抛。其实恰恰相反,只要 Callable.call() 里 throw 了一个异常(即使你内部 catch 住再抛一个新的),FutureTask 都会把它记为异常状态,get() 时统一抛 ExecutionException。所以如果你希望任务发生业务异常时 get 不抛错,就要在 Callable 内部吞掉异常并返回一个降级值。

想拿到真正的异常链,必须用 getCause() 逐层解包。习惯性打印异常堆栈的话也要注意,直接e.printStackTrace()出来的是 ExecutionException 本身,看多了就知道还是要看 cause。

6.3 cancel() 之后任务还能执行吗

cancel(false) 只把状态从 NEW 改成 CANCELLED,任务如果还没开始执行,就永远不会执行;如果已经执行了,它也停不下来。cancel(true) 会额外中断执行线程。但这里有个大坑:如果任务是FutureTask.run()恰好还没进入call()方法,cancel(true) 会先通过 CAS 把状态改成 INTERRUPTING,然后拿到 runner 线程调用 interrupt()。如果在中断信号发出之前,任务已经开始执行了,很大概率会被中断生效。如果中断信号发出之后任务才进入 call(),那根本感知不到中断。所以 cancel(true) 对“任务尚未开始”的场景是有效的阻止执行,对“任务已经开始”的场景只是尽力而为。

我在线上系统遇到过 cancel 和 run 并发触发导致状态紊乱的情况,那是在比较老的 JDK 版本上。后来的版本增加了 handlePossibleCancellationInterrupt 来处理中断未完成的情况。这个问题比较深,绝大多数业务场景不需要关心。

6.4 FutureTask 在线程池里的内存保留问题

如果不取消任务,它自己执行完,outcome 会保存结果对象,直到有线程调用 get() 取走为止。如果任务执行完但结果一直没人取,FutureTask 会保留 outcome 引用,造成对象迟迟无法被 GC。在批量提交任务的场景下,如果每个任务都返回一个较大的对象,而调用方并没有及时 get(),内存里堆积的对象可能相当可观。

优化办法是:提交任务后,要么在 finally 里统一 get() 取走结果;要么确实不需要返回值,就提交 Runnable 而不是 Callable。当然,JDK 8 后的 CompletableFuture 在结果消费上更灵活,也引入了依赖回调的机制,不会出现“结果存着没人要”的尴尬局面。

6.5 FutureTask 与 CompletableFuture 的取舍

现在很多新项目已经逐渐转向 CompletableFuture,它提供了任务编排、异步回调、异常恢复等更高级的能力。但 FutureTask 仍然有自己的生态位:它简单,轻量,没有回调地狱,也没有 ForkJoinPool 的底层依赖。在只想“拿到一个异步结果 + 支持取消”的场景,FutureTask 反而是最直接的工具。

我的建议是:单任务异步取结果,用 FutureTask 或 Future 即可;有多任务编排、依赖传递、异常恢复需求,再考虑 CompletableFuture。不要为了用 CompletableFuture 而硬把简单逻辑搞复杂。

6.6 一个小技巧:futureTask.get() 与锁的关系

最后分享一个容易踩的小坑。FutureTask 的 get() 会阻塞等待任务完成,如果这个 FutureTask 被提交到了一个线程池,而这个线程池又被某把锁挡住,比如池里的线程都在等待一个永远不释放的锁,那 get() 就会永远等不到结果。这种情况本质上是死锁,但表现形式像是 get() 卡住。排查并发问题时候,不要只盯着 get(),要结合线程 dump 看整个调用链,搞清楚是任务本身没结束,还是线程池整体被堵住了。

我在实际项目里排查过一个“看起来像 FutureTask 卡死”的问题,线程 dump 之后发现所有 worker 线程都阻塞在一个数据库连接池的获取上,而连接池已经被占满。那根本不是 FutureTask 的问题,而是对线程池容量的评估失准。所以说,FutureTask 只是工具,工具本身很少出错,真正容易错的,是我们对并发场景的理解是否足够全面。

6.7 从源码中能学到的设计思路

读 FutureTask 源码最大的收获不只是会用 get(),而是欣赏它在状态设计上的严谨:一个 int 字段既承载了用户可感知的状态,又充当了线程安全的通知信号;CAS 和 volatile 的组合保证了不同线程之间对结果和状态的可见性;LockSupport 的 park/unpark 避免了锁竞争的额外开销;Treiber 栈则让大量等待线程的入队和唤醒操作变成无锁操作。这几套并发原语配合起来,整个类的内部几乎看不到一个 synchronized 块(除了 finishCompletion 中的一小段),却能在多线程高竞争下保持正确的语义。

如果你还在纠结 FutureTask 的原理,我建议你找个周末坐下来,把 awaitDone 那个 for 循环自己画一遍执行线,再把 set/cancel/finishCompletion 的状态流转写在一张纸上。画完这张图,你对 Java 并发的理解会明显上一个台阶。我是把这张图贴在工位上方,之后再看线程池相关的很多问题,思路都顺了不少。

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

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

立即咨询