1. Future的定位:从“线程只管跑”到“跑了还要有结果”
写Java并发代码绕不开一个场景:开了线程异步处理任务,主线程怎么知道任务做完了,结果又在哪里。Java 5之前,最朴素的做法是搞一个共享对象,任务线程往里塞结果,主线程轮询或者阻塞等待。轮询浪费CPU空转,阻塞等待又控制不好粒度,代码还容易脏,总之非常别扭。Future接口在java.util.concurrent里出现,就是为了解决“异步任务的结果怎么拿”这一件事。
先看Future的长相。它不像Stream那套API花哨,接口就五个方法:
boolean cancel(boolean mayInterruptIfRunning):尝试取消任务boolean isCancelled():任务是否被取消boolean isDone():任务是否执行完成V get():阻塞等待结果V get(long timeout, TimeUnit unit):带超时的等待
三种基本用法里,最常见的是配合线程池使用。ExecutorService的submit(Callable)方法返回一个Future对象,任务在后台线程跑,主线程拿到Future之后有两个选择:要么get()堵着等结果,要么先干别的事、后面需要结果时再get()。
ExecutorService executor = Executors.newFixedThreadPool(4); Future<String> future = executor.submit(() -> { Thread.sleep(2000); return "任务结果"; }); // 先干点别的 System.out.println("主线程继续执行"); // 需要结果时再取 String result = future.get(); // 阻塞最多2秒这段话看起来平淡,但它背后埋着一个核心区分:Runnable的run()方法没有返回值,一行错也没人知道,异常直接打到线程池里被吞掉;Callable的call()可以返回任意类型,还能抛出受检异常,Future把执行状态和结果串起来。所以凡是“异步执行并需要结果”的任务,应当首选Callable+Future,而不是自己搞回调标记。
很多刚接触并发的同学有个误解:Future等于异步。其实Future只是“异步任务结果的门票”,真正干活的还是线程池和后台线程。这张门票的意义在于,主线程可以在满足依赖条件的最晚时刻再决定是否等待,代码表达从“傻等”变成了“按需等”,这是并发编程思维上的第一层进化。
我还会强调一个容易被忽略的点:Future并不只属于线程池。它本质是一个“异步结果容器”,只要实现了Future接口,背后可以对接任何异步执行模型。你完全可以用一个自定义线程、一个事件循环甚至一个消息队列去填充Future的结果。理解了这一层,后面看CompletableFuture和响应式编程里的各种Publisher,就会顺很多。
2. 原生Future的五个方法:每一个都有它的脾气
2.1 get的阻塞逻辑
get()是整个Future最重要的方法,也是坑最多的地方。它没有参数的重载版本会无限期阻塞,直到任务完成或者被取消;带超时参数的重载则最多等待指定时间。很多人觉得带超时无非是多传两个参数,但在真实系统里,这两个参数决定了你的服务在突发故障时能不能活下来。
try { String result = future.get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { // 3秒没出结果 future.cancel(true); throw new BusinessException("任务超时"); }这里有一个重要细节:get抛出的TimeoutException并不会让Future进入完成状态。也就是说,即使你已经因为超时放弃了,后台任务可能还在跑,线程还在占着。你必须在超时分支里主动cancel,否则任务的后续影响(数据库连接、文件句柄、锁)全都悬空。我见过不少线上事故,就是因为超时后忘了取消,任务在后台继续执行,等缓存过期或者队列堆积之后,全部结果同时涌出,直接把下游打挂。
异常处理上,get()会抛出三类异常:
| 异常类型 | 触发条件 | 处理建议 |
|---|---|---|
InterruptedException | 当前等待线程被中断 | 恢复中断标记,向上传递 |
ExecutionException | 任务内部抛出任何异常 | 解包getCause(),区分业务异常与系统异常 |
TimeoutException | 等待超过指定时间 | 主动取消,做降级/重试 |
2.2 cancel的协作式取消
cancel(boolean mayInterruptIfRunning)不是强杀线程,这一点必须讲明白。Java的线程取消是协作式的,cancel(true)会对执行任务的线程发起中断(Thread.interrupt),但任务里的代码如果不去响应中断标记、没有检查线程中断状态,那cancel就等于白喊口号,任务会继续跑完。
Future<?> future = executor.submit(() -> { while (!Thread.currentThread().isInterrupted()) { doWork(); } }); // 另一个线程调用 future.cancel(true);如果任务是阻塞型调用(比如等待锁、Thread.sleep),中断能让阻塞调用直接抛InterruptedException;如果任务是纯计算密集且没判断中断位,那只能等它跑完。所以设计异步任务时,循环体里加中断检查是基本素养,相当于给“取消”留了一扇门。
再说cancel的返回值。只有任务还在“未开始”或“运行中”状态时,cancel才返回true;如果任务已经正常完成、抛出异常或已被取消,返回false。很多人拿它当“是否取消成功”的判断,实际语义是“本次请求是否把状态改成了取消”,返回值不代表任务真的停了。
2.3 isDone与isCancelled的判断组合
isDone()判断的是执行状态是否进入终态,终态包括正常完成、异常结束和取消。所以future.isDone()为true时,结果未必是一份正常结果,你得配合isCancelled()和get()的异常情况一起判断:
if (future.isDone()) { if (future.isCancelled()) { // 被取消了 } else { try { V result = future.get(); // 正常结果 } catch (ExecutionException e) { // 任务里炸了 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }这套判断在轮询场景里常见,但我要劝大家少用轮询,因为轮询意味着主线程要持续唤醒和检查,本质上又回到了空转的老路。更好的方式是任务完成时回调(CompletableFuture),或者用get(timeout)一次性阻塞。后面章节详谈。
有一种情况必须轮询:你需要同时等待多个任务,并且希望“先到的先处理”。此时逐个get()会按提交顺序等待,明明后面的任务先完成了你也拿不到。这种场景不去上CompletableFuture的anyOf,就要自己维护Future列表循环检查,代码会非常啰嗦。
3. FutureTask:一个既能跑又能等的任务包装器
Future接口只是定义了“怎么取结果”,真正把Runnable/Callable变成Future的核心实现,是FutureTask。你直接new FutureTask(callable),就可以得到一个任务对象,它同时实现了Runnable和Future两个接口。这意味着三件事:
- 它能直接丢给
Thread构造器或者ExecutorService执行 - 执行线程在
run()内部会把结果或者异常填充进去 - 别的线程可以通过FutureTask的
get()拿结果
FutureTask<String> futureTask = new FutureTask<>(() -> { Thread.sleep(1000); return "结果"; }); Thread t = new Thread(futureTask); t.start(); String result = futureTask.get();如果只是“提交任务拿结果”,FutureTask看起来有些多此一举,毕竟线程池的submit()已经干了同样的事。但FutureTask有一个杀手级用途:你可以在任意线程里手工控制结果就绪的时机。
比如做模拟项目X的本地缓存时,我常用FutureTask做“单线程计算一次、多个请求共享等待”的语义。还是老问题:多个线程同时请求缓存,缓存里没有值,如果不对计算做约束,同一个值会被并发算很多遍。
private final ConcurrentHashMap<String, FutureTask<String>> cache = new ConcurrentHashMap<>(); public String getValue(String key) throws Exception { FutureTask<String> task = cache.get(key); if (task != null) { return task.get(); } FutureTask<String> newTask = new FutureTask<>(() -> computeValue(key)); FutureTask<String> oldTask = cache.putIfAbsent(key, newTask); if (oldTask == null) { newTask.run(); // 只有一个线程真正执行计算 } return cache.get(key).get(); }这段代码利用putIfAbsent做到了“多线程抢任务,赢家执行计算,输家共享结果”。FutureTask在run()执行前是一个未完成状态,其他线程get()时都会阻塞等待;计算完成之后,所有等待线程被唤醒,拿到的都是同一个结果。这就是一种“单计算,多等待”的共享模型,性能好且代码易读。
再看FutureTask内部的关键字段,理解之后很多行为就都能解释:
state:初始为NEW,依次可流转到COMPLETING、NORMAL、EXCEPTIONAL、CANCELLED、INTERRUPTING、INTERRUPTEDoutcome:存放结果或异常runner:当前执行任务的线程,用CAS保证同一个FutureTask只能被一个线程执行
run()为什么只能执行一次?因为它内部用CAS把runner从null改成当前线程,谁先抢到谁执行,之后执行的直接返回。这个设计保证了即使有人误把一个FutureTask提交给两个线程,也只有一个线程真正跑任务,结果稳定。
FutureTask还有一个常见用法是配合AbstractExecutorService实现自己的线程池或者任务队列。如果你要写一个带优先级或延迟执行的调度器,把FutureTask子类化或组合进去就会很顺手,因为它的状态管理和结果通知机制都已经是现成的。
我个人测试下来,FutureTask的开销非常小,状态流转和结果填充都在微秒级,主要耗时还是业务计算本身,所以用到它的时候不用太心疼性能。
4. 从Future到CompletableFuture:当单个结果满足不了业务时
原生Future的问题,用着用着就冒出来了:多个异步任务之间需要编排——B要等A的结果才能跑,C要把A和B的结果合起来,任何一个失败另一路就要取消。你要是拿原生Future硬写,代码很快就变成一坨回调嵌套或者手动状态同步的烂摊子。
Java 8的CompletableFuture就是来治这个病的。它实现了Future和CompletionStage两个接口,既保留了“阻塞拿结果”的老用法,又增加了“结果就绪后自动触发下一段逻辑”的能力。
CompletableFuture.supplyAsync(() -> queryOrder(orderId)) .thenApply(order -> enrichOrder(order)) .thenAccept(order -> sendNotify(order)) .exceptionally(e -> { log.error("处理失败", e); return null; });这个链式写法最直观的改变是:你不再靠线程阻塞来等待结果,而是把“当结果来了做什么”提前声明好。任务A完成后,框架自动把结果送给thenApply里的函数,跑完再送给thenAccept。注意这些步骤默认都会交给ForkJoinPool.commonPool()执行,线程池选择是CompletableFuture最容易踩坑的地方,后面会专门说。
除链式回调外,CompletableFuture还提供了一批组合方法,这些方法解决的是原生Future完全做不到的编排问题:
| 方法 | 语义 | 典型场景 |
|---|---|---|
thenCombine | 两个任务并行执行,结果合并 | 同时查库存和查价格,合成商品详情 |
thenCompose | 第一个任务结果作为第二个任务的入参 | 先查用户,再查用户订单 |
allOf | 等待所有任务完成 | 批量推送之前的集中等待 |
anyOf | 任意一个任务完成即可 | 多数据源择优返回 |
applyToEither | 用先完成任务的结果继续处理 | 主备接口的快速返回 |
举个例子,模拟一个读扩散页面:需要用户信息、推荐列表、横幅配置三个数据源并行拉取,之后拼装响应。原生Future你只能提交三个任务再依次get,总耗时为三者最慢;CompletableFuture可以声明式地合并:
CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(() -> userService.getUser(uid), bizPool); CompletableFuture<List<Long>> recFuture = CompletableFuture.supplyAsync(() -> recService.getRecommend(uid), bizPool); CompletableFuture<BannerConf> bannerFuture = CompletableFuture.supplyAsync(() -> bannerService.getBanner(uid), bizPool); CompletableFuture.allOf(userFuture, recFuture, bannerFuture) .thenApply(v -> new PageVO(userFuture.join(), recFuture.join(), bannerFuture.join())) .join();这里有个细节:join()和get()的区别在于join()不抛受检异常,它在任务异常时抛的是CompletionException。如果你希望代码里统一用异常捕获处理,选join()更省事;如果你需要将InterruptedException显式传递给上层,用get()。
再强调一下exceptionally和handle的区别,这俩都作用于异常路径,但返回类型策略不同。exceptionally只接收异常,返回正常同类型的替代值;handle接收结果和异常两个参数,既能做降级也能在成功路径上继续转换。业务上我习惯用exceptionally做兜底值,用handle做有条件的后续分发。
CompletableFuture还有一个能力是手工完成。当你对接的消息系统回调迟到时,你可以先创建一个CompletableFuture返回给上游,等消息到了再调用complete填充结果,这样上游代码始终是同一套等待逻辑。
CompletableFuture<String> pending = new CompletableFuture<>(); // 把pending交给上层 // 在消息回调里:pending.complete(payload);这个方法在异步网关、回调适配这类场景非常灵,相当于把“外部事件”翻译成了“Future结果”,上游不用感知你到底是用线程还是用消息实现异步。
5. 实战中的四个经典坑与排查思路
5.1 线程池没关,Future一直阻塞
有同事在模拟项目X里写过一个批量导出功能,主线程把任务交给Executors.newFixedThreadPool(10),然后循环future.get(),跑完功能就结束了,但线程池没有shutdown()。当时测试环境里没什么问题,进了生产之后导出的请求慢慢堆积,直到JVM连new线程都失败,接口全部超时。
排查路径从线程栈看起:大量线程卡在FutureTask.get()上,队列里排了一堆任务。再一看线程池,workQueue无限增长,执行线程全被前面的慢任务占着。根因不是Future的问题,而是线程池的生命周期没有被管理。
结论是:凡是每请求创建的线程池,用完务必在finally块里shutdown();如果是全局共享的线程池,要考虑任务堆积的上限,线程池要么给队列设容量,要么用有界队列加拒绝策略。Future只是门的钥匙,门背后的房间如果无限堆积,再好的钥匙也打不开。
5.2 get()长时间阻塞与排查技巧
遇到主线程卡死,先别怀疑死锁。用jstack导出线程栈,锁定卡在sun.nio.ch或者FutureTask.get的线程。如果是Future引起的,看两个东西:执行线程池是否还有活跃线程,以及任务内部的阻塞点在哪。
有一种很隐蔽的情况:任务里又调用了另一个Future的get(),外层的future在等待,内层的future也在等待,两个线程池互相等。这就是Future版的“回调地狱”,排查起来痛苦不堪。遇到跨线程等待,优先考虑给每一层get都加上超时时间;没有超时的get等于裸奔。
我实际排查过的某个服务里,任务超时配置在RPC客户端是3秒,但Future的get没设超时,结果RPC已经超时失败了,Future还在傻等。后来把Future的get统一包了一层超时工具方法,问题才根治。所以给Future加超时不是可选项,而是强制项,尤其是面向外部依赖的异步任务。
5.3 ExecutionException吞掉了业务异常原始堆栈
看这段:
try { future.get(); } catch (ExecutionException e) { throw new RuntimeException("任务失败", e); }ExecutionException的cause是业务代码里抛出的异常,但在日志系统里,如果你只打印e而不打印e.getCause(),你会看到一条没有根因的“任务失败”。很多开发者排查半天,最后发现原始异常一直藏在cause里。
我自己会写一个小的异常解包工具,把ExecutionException和CompletionException一层层解出来,再统一打印完整堆栈。这个工具很小,但排查效率和可读性提升明显。
public static Throwable unwrap(Throwable t) { while (t instanceof ExecutionException || t instanceof CompletionException) { t = t.getCause(); } return t; }5.4 全都用ForkJoinPool.commonPool的风险
CompletableFuture不传线程池时,默认用ForkJoinPool.commonPool()。这个池的大小等于CPU核心数减一,如果业务代码里的任务含阻塞IO,很快会把commonPool占满;一旦占满,所有依赖它的异步任务都排队等,接口延迟急剧升高,还没有明显的报错。
排查时看jstack,线程名是ForkJoinPool.commonPool-worker-N。如果一大堆任务Blocked在进出数据库的调用上,基本确认是这个坑。应对办法是给CompletableFuture明确传入自定义业务线程池,并为该线程池设置合理大小、队列、拒绝策略。
private final ThreadPoolExecutor bizPool = new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() );我还想提醒一点:supplyAsync第一段如果用了自定义池,后续的thenApply默认会沿用上一段的线程池;但如果中间出现了thenCompose,新的CompletableFuture又可能回到默认池。如果逻辑复杂,最好每个关键节点显式传入池子,不要赌默认行为。
6. 除了API,还要设计异步任务的语义
写了这么多年Future和CompletableFuture,我越来越觉得API只是表层的部分,真正决定代码质量的,是异步任务的语义设计。同一个Future,在你心里是“必须等待的强依赖”,还是“可选加速的弱依赖”,直接决定了你后续怎么编排。
强依赖任务:比如下单后查优惠、扣库存、生成订单号,这些环节的Future结果必须拿到,异常必须处理,超时必须重试或熔断,所以get要带超时,exceptionally要设计降级。弱依赖任务:比如记录访问日志、异步刷新缓存、发送通知,这类任务的Future常常是“拿不到就算了”。
弱依赖的设计尤其重要。常见错误是主流程已经结束了,异步任务还在半路跑,进程一退出结果丢了,或者异常一股脑打印到日志里没人管。此时我习惯给弱依赖任务单独的小线程池,并且池里的任务全部有try-catch兜底,让异常只记录、不影响主线程。
另一个语义问题是Future的粒度。粒度太细,每访问一个字段就异步一次,线程切换和结果等待的损耗可能盖过收益;粒度太粗,任务内部一堆串行阻塞,异步就失去了意义。一般我会把“一次IO等待”作为一个任务边界,比如一次RPC调用、一次数据库查询、一次文件读取,都封装成独立的异步操作。如果任务内部还有多个串行阻塞步骤,我会先把任务拆成多个CompletableFuture,再用thenCompose接起来,让并发度尽量高。
毫秒级等待要不要异步?我的经验是:如果任务本身不到5毫秒,异步开销(任务提交、线程切换、结果等待)可能比直接执行还慢,反而不划算;如果目标任务是几十毫秒以上的IO或RPC,Future的价值就很明显了。这个阈值虽然不是绝对,但可以帮你避开“为了异步而异步”的陷阱。
Future的设计哲学对系统架构也有启发:它把“发起操作”和“等待结果”解耦开,让调用方可以按自己的节奏控制时间线。凡是需要这种解耦的地方,不止是Java,任何语言里都可以借鉴同一个模式。看懂了Future,后续学虚拟线程的StructuredTaskScope、学响应式编程的Mono/Flux,会发现它们都在解决相似的问题,只是在编排粒度和错误传播方式上有不同的侧重。
另外给已经上了JDK 17或更高版本的同学提一句:Java 19引入了结构化并发(Structured Concurrency),StructuredTaskScope能把一批子任务的生命周期绑定在一起,要么全部完成要么全部取消,错误传播也更严谨。这个API目前还在孵化阶段,但设计思路非常值得提前关注。它解决的正是原生Future“一个任务挂了其他任务还在跑”的痛点,和CompletableFuture的编排思想一脉相承又更加结构化。等它正式转正,Future家族就又多了一位很有性格的成员。