最近在找工作,被迫把多线程重新拿出来复习。
谁家好人平时没事干,专门去看多线程啊?
结果看着看着,还真遇到了一个很有意思的问题:我明明把 CompletableFuture 完成了,甚至取消了,为什么后台任务还在勤勤恳恳地继续执行?
这篇文章就从一个小实验开始,聊清楚 complete()、cancel()、FutureTask 和线程中断之间到底是什么关系。
先把结论放在前面:
CompletableFuture主要管理的是"结果状态"和"异步编排",它通常并不拥有底层执行线程。取消一个结果,不等于终止产生这个结果的代码。
最近在找工作,被迫把多线程拿出来进行复习. 谁家好人平时没事干,去看多线程啊?
问题篇
1. complete() 为什么没有让后台任务停止?
先来看一个小 Demo。
java
import java.util.concurrent.*;
public class CompleteDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//初始化一个简单的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(5);
try {
//用于防止complete先运行
CountDownLatch started = new CountDownLatch(1);
//创建一个CompletableFuture对象
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
started.countDown();
try {
Thread.sleep(3_000);
} catch (InterruptedException e) {
//这里希望识别到 中断
Thread.currentThread().interrupt();
System.out.println("中断");
return "interrupted";
}
System.out.println("后台任务执行完了");
return "异步任务赋值";
}, threadPool);
started.await();
// 手动赋值
future.complete("手动赋值");
// 手动取消
boolean cancel = future.cancel(true);
// 打印结果 false
System.out.println("取消任务是否成功:" + cancel);
// 打印结果 手动赋值
System.out.println(future.join());
System.out.println("main线程结束");
} finally {
threadPool.shutdown();
threadPool.awaitTermination(5, TimeUnit.SECONDS);
}
}
}
输出结果
text
取消任务是否成功:false
手动赋值
main线程结束
后台任务执行完了
相信智慧的你很快就发现不对了
诶,我不是都调用 complete() 和 cancel(true) 了吗?我任务不应该取消了吗?
java
future.complete("手动赋值");
future.cancel(true);
这两句都应该会 发出一个 中断信号 interrupt 给线程 ,让线程不要继续跑了, 应该立刻结束吗?
应该要打印出中断 而不是 后台任务执行完了这一句啊.
这里发生的是:
text
后台任务继续执行
↓
CompletableFuture 被手动设置为"正常完成"
↓
调用方提前拿到"手动赋值"
↓
后台任务稍后返回的结果被忽略
结果已经下班了,但生产结果的代码还在加班。
2. 单独调用 cancel(true) 会中断后台线程吗?
我们看到之前的输出结果里面有一个
取消任务是否成功:false
这个false就很奇怪和刺眼 ,可以让他变成true吗?
可以的,兄弟,可以的。这里是false其实是由于future.complete("手动赋值"); 后CompletableFuture进入终态。
一个 CompletableFuture 一旦进入终态,就不能再次切换状态。上面的 complete() 已经成功,所以后面的 cancel(true) 只能返回 false。
其实如果屏蔽掉future.complete("手动赋值"); 不会影响整理结果.当然你的future.join()由于取消了没有值也要同步屏蔽
输出结果
text
取消任务是否成功:true
main线程结束
后台任务执行完了
对于普通 CompletableFuture,cancel(true) 会让 Future 进入取消状态,但不会中断已经运行的 任务。参数 mayInterruptIfRunning 在 CompletableFuture 的实现中没有实际的中断效果。
他只是调用发不再接受或等待这个结果。而无法让正在生产结果的代码尽快退出
3. get(timeout) 超时的也只是等待
那超时呢?
java
//超时处理
try {
String s = future.get(2, TimeUnit.SECONDS);
System.out.println(s);
} catch (TimeoutException e) {
System.out.println("任务超时取消");
throw new RuntimeException(e);
}
打印结果:
text
任务超时取消
Exception in thread "main" java.lang.RuntimeException: java.util.concurrent.TimeoutException
at org.example.Test3.main(Test3.java:39)
Caused by: java.util.concurrent.TimeoutException
...
后台任务执行完了
get(timeout) 超时的是调用线程的等待行为,不是后台任务的执行时间。
它表达的是:
我最多等你两秒,两秒后我先走。
它没有表达:
两秒后把后台任务停掉。
所以调用方拿到 TimeoutException 后,后台任务仍然可能继续执行。真正需要停止任务时,还要额外调用底层任务或客户端提供的取消能力。
4. anyOf() 返回以后,其他任务为什么还在跑?
再看任务编排。
java
import java.util.concurrent.*;
public class OrchestrationDemo {
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
try {
CompletableFuture<String> job1 = request("任务 A", 1_000, threadPool);
CompletableFuture<String> job2 = request("任务 B", 200, threadPool);
CompletableFuture<String> job3 = request("任务 C", 800, threadPool);
CompletableFuture<Object> first = CompletableFuture.anyOf(job1, job2, job3);
System.out.println("第一个结果:" + first.join());
// CompletableFuture.allOf(job1, job2, job3).join();
} finally {
threadPool.shutdown();
threadPool.awaitTermination(5, TimeUnit.SECONDS);
}
}
/**
* 模拟一个耗时请求
*
* @param name 任务名
* @param delayMillis 耗时
* @param executor 线程池
* @return
*/
private static CompletableFuture<String> request(String name, long delayMillis, ExecutorService executor) {
return CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(delayMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(name + " 中断");
throw new java.util.concurrent.CompletionException(e);
}
System.out.println(name + " 执行完成");
return name;
}, executor);
}
}
打印结果:
css
任务 B 执行完成
第一个结果:任务 B
任务 C 执行完成
任务 A 执行完成
anyOf() 只负责在任意一个参与者完成后确定聚合结果,它不会自动取消其余任务。
这种设计其实是合理的。在上面的示例中,anyOf() 后面还调用了:
java
CompletableFuture.allOf(job1, job2, job3).join();
这表示我们虽然希望先拿到一个结果,但后续仍然需要等待其他任务全部结束。如果 anyOf() 擅自取消其余任务,调用方就无法继续等待和使用它们的结果了。
因此这里其实存在两种不同的业务需求:
text
需要第一个结果,但其他结果后面仍然有用
→ 使用 anyOf() 提前响应,同时让其他任务继续执行
只需要第一个结果,其他结果已经没有价值
→ 得到聚合结果后,显式取消其余任务
针对第二种情况这就是很容易出现在真实业务里的场景:我们已经得到第一个结果后,后续任务依然可能被一个个取出执行。,结果早就不需要了,线程池却还忙得热火朝天,新任务继续在队列里排队。
所以这里有一个细节, 如果我们不在需要后续任务的结果了。需要单独显式取消每一个尚未开始的 CompletableFuture。
Java
CompletableFuture<Object> first =
CompletableFuture.anyOf(job1, job2, job3);
first.whenComplete((value, error) -> {
for (CompletableFuture<?> child : List.of(job1, job2, job3)) {
if (!child.isDone()) {
child.cancel(false);
}
}
});
这里使用 cancel(false) 是为了避免传达"它会中断执行线程"的错觉。对于普通 CompletableFuture,传入 true 或 false 都不会中断已经运行的 Supplier。
但我们在上文其实已经知道cancel(true)其实是无法取消掉正在运行的任务。
取消后,不同状态的任务表现并不一样:
- 尚未开始的
supplyAsync包装任务在出队时发现 CompletableFuture 已取消,通常会跳过 Supplier。 - 已经开始运行的 Supplier 不会被中断,仍可能继续执行。
- 被取消的包装任务未必会立即从执行器队列中删除,而是继续占据队列位置,直到被工作线程取出。
所以在队列已经非常紧张的场景中,仅仅取消 CompletableFuture 仍然不够。还需要考虑有界队列、提交背压、底层 Future 执行句柄,或者能够主动移除取消任务的调度机制。
解决篇
好了目前问题已经明确了。我们现在需要在使用CompletableFuture情况下,可以自由的取消掉这些运行中的Future.。但很遗憾的告知你,如果单纯靠CompletableFuture这个其实是不行的,原因我们放在思考篇细聊。
1. 同时保留结果句柄和执行句柄
既然 CompletableFuture 不掌握底层执行线程,那么最直接的办法就是把两种能力分开保存:
text
Future / FutureTask 负责执行控制:cancel(true)、尝试 interrupt runner
CompletableFuture 负责结果表达:组合、转换、异常处理
先看最直接的写法:
java
import java.util.concurrent.*;
public class Test4 {
public static void main(String[] args) throws Exception {
//初始化了一个线程池
ExecutorService executor = Executors.newFixedThreadPool(5);
//初始化一个CompletableFuture对象
CompletableFuture<String> result = new CompletableFuture<>();
//我们需要单独提交通过线程池来提交任务来获取到Future
Future<?> runningTask = executor.submit(() -> {
try {
String value = doExpensiveWork();
result.complete(value);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
result.cancel(false);
System.out.println("后台任务被中断");
} catch (Throwable e) {
result.completeExceptionally(e);
}
});
//主流程
try {
Thread.sleep(5_000);
// 先把结果设置为手动结果
boolean completed = result.complete("手动赋值");
// 再中断真正执行任务的线程
boolean cancelled = runningTask.cancel(true);
System.out.println("设置结果成功:" + completed);
System.out.println("取消任务成功:" + cancelled);
System.out.println(result.join());
Thread.sleep(5_000);
System.out.println("main线程结束");
} finally {
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
}
}
/**
* 模拟一个耗时任务
* @return
* @throws InterruptedException
*/ private static String doExpensiveWork() throws InterruptedException {
for (int i = 0; i < 100; i++) {
// CPU 密集任务必须主动检查中断
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("Task cancelled");
}
Thread.sleep(100);
// 执行一小部分计算
System.out.println("后台任务执行中..."+i);
}
System.out.println("后台任务执行完了");
return "任务设置";
}
}
输出结果:
arduino
后台任务执行中...43
后台任务执行中...44
后台任务执行中...45
后台任务被中断
设置结果成功:true
取消任务成功:true
手动赋值
main线程结束
这里的 execution.cancel(true) 才会尝试中断执行 doExpensiveWork() 的线程。
当然,中断仍然是协作式取消。如果任务是一个完全不检查中断的死循环,它照样可能继续执行。
2. 使用 FutureTask.done() 桥接结果
手动维护两个变量很容易散落得到处都是,可以封装成一个 CancellableTask:
java
import java.util.concurrent.*;
public final class CancellableTask<T> {
private final CompletableFuture<T> result;
private final FutureTask<T> execution;
private CancellableTask(
CompletableFuture<T> result,
FutureTask<T> execution
) {
this.result = result;
this.execution = execution;
}
public static <T> CancellableTask<T> submit(
Executor executor,
Callable<T> callable
) {
CompletableFuture<T> result = new CompletableFuture<>();
FutureTask<T> execution = new FutureTask<>(callable) {
/**
* 重写done() 方法就是把 FutureTask 的结果桥接到 CompletableFuture 上。
* 这个其实是一个取巧的方法
*/
@Override
protected void done() {
try {
result.complete(get());
} catch (CancellationException e) {
result.cancel(false);
} catch (ExecutionException e) {
result.completeExceptionally(e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
result.completeExceptionally(e);
}
}
};
executor.execute(execution);
return new CancellableTask<>(result, execution);
}
/**
* 返回CompletableFuture对象。
*/
public CompletableFuture<T> result() {
return result;
}
/**
* 调用 FutureTask 的 cancel() 方法来取消任务。
*/
public boolean cancel() {
return execution.cancel(true);
}
}
方法调用
java
import java.util.concurrent.*;
public class Test6 {
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
try {
CancellableTask<String> task =
CancellableTask.submit(threadPool, () -> {
try {
Thread.sleep(3_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("后台任务收到中断");
throw e;
}
System.out.println("后台任务执行完了");
return "异步任务赋值";
});
// 确保任务已经开始执行
Thread.sleep(1_000);
CompletableFuture<String> future = task.result();
// CompletableFuture 正常完成
boolean completed = future.complete("手动赋值");
// 中断真正执行任务的线程
boolean cancelled = task.cancel();
System.out.println("手动赋值是否成功:" + completed);
System.out.println("底层任务是否取消成功:" + cancelled);
System.out.println("最终结果:" + future.join());
} finally {
threadPool.shutdown();
threadPool.awaitTermination(5, TimeUnit.SECONDS);
}
System.out.println("main线程结束");
}
}
打印结果
text
后台任务收到中断
手动赋值是否成功:true
底层任务是否取消成功:true
最终结果:手动赋值
main线程结束
这里其实没有完全解决 采用了一个比较偷懒取巧的方式
就是通过 FutureTask.done() 将结果桥接到了 CompletableFuture身上,有重构了 cancel方法,让他调用了FutureTask的方法。借鉴了适配器思路。
3. 多任务编排才是真正麻烦的部分
单任务桥接完成以后,CompletableFuture 的结果编排仍然可以正常使用:
java
CancellableTask<User> userTask = CancellableTask.submit(threadPool, this::loadUser);
CancellableTask<Order> orderTask = CancellableTask.submit(threadPool, this::loadOrder);
CompletableFuture<UserPage> pageFuture =
userTask.result().thenCombine(
orderTask.result(),
UserPage::new
);
但取消 pageFuture 不会自动反向取消 userTask 和 orderTask。如果需要这种语义,就要显式定义取消传播:
java
public void cancelPage() {
userTask.cancel();
orderTask.cancel();
pageFuture.cancel(false);
}
不同编排方式对应不同的取消拓扑:
| 编排方式 | 组合任务取消后应该怎么处理 |
|---|---|
thenApply |
通常取消上游任务 |
thenCombine(a, b) |
取消 a 和 b |
allOf(tasks) |
取消所有子任务 |
firstSuccess |
保留获胜任务,取消其他任务 |
thenCompose |
取消上游,以及运行期间动态创建的下游任务 |
因此,更通用的封装不应该只保存一个 FutureTask,而应该保存一个可组合的取消动作:
arduino
final class CancellableStage<T> {
private final CompletableFuture<T> future;
private final Runnable cancelAction;
}
基础任务的 cancelAction 可以调用 FutureTask.cancel(true);组合任务的 cancelAction 则可以取消多个上游任务。
但这种组合有过于灵活
当组合结果不再需要时,究竟应该停止哪些底层任务?
4.一个开源实现:ThreadForge
研究这个问题时,我还读到了掘金社区 ThreadForge项目及其源码解读文章。
它没有使用 FutureTask.done(),而是在 Task<T> 中维护:
swift
private final CompletableFuture<T> future;
private final AtomicReference<Thread> runnerThread;
执行任务时记录当前 runner:
ini
task.markRunning(Thread.currentThread());
取消时则同时处理两层:
arduino
runner.interrupt();
future.cancel(true);
真正尝试中断执行线程的是 runner.interrupt(),不是 CompletableFuture.cancel(true)。
它的 ScopeJoiner 还会保存所有候选 Task,在 first-success、quorum 等条件满足后显式取消剩余任务。这和前面提到的"结果状态 + 执行句柄 + 取消拓扑"是同一种设计方向。
不过,它也没有神奇地解决所有 CompletableFuture 编排问题。例如普通的 Task.thenApply() 返回的仍然是原生 CompletableFuture,取消派生结果不会自动反向取消原始 Task。
但整体框架适合作为结构化并发和任务生命周期管理的学习案例。作者也出了3篇对应的源码解读,细读下来收益良多。
思考篇
1. Future、FutureTask 和 CompletableFuture 到底是什么关系?
我们经常说 CompletableFuture 是对 Future 的增强,很容易顺着这个说法产生一个误解:
FutureTask 可以取消执行线程,那么更新、更强的 CompletableFuture 应该更会取消才对。
问题在于,它们增强的方向并不相同。
Future<T> 描述的是一个未来结果的基本契约。
FutureTask<V> 实现 RunnableFuture<V>,而RunnableFuture<V> 同时继承 Runnable 和 Future<V>。因此它既是结果句柄,也是可以交给 Executor 执行的任务对象。它在运行期间会记录当前 runner,所以 cancel(true) 能够尝试中断执行线程。
但 FutureTask 代表的是任务,不是某个固定线程。任务可能还没开始、可能正在某个线程运行,也可能已经结束。
CompletableFuture<T> 则同时实现Future和CompletionStage:
它的增强重点是结果组合:
scss
thenApply(...)
thenCompose(...)
thenCombine(...)
exceptionally(...)
而不是Runnable
2. CompletableFuture 为什么不能确定应该中断谁?
一个 CompletableFuture 可以完全没有后台工作线程:
arduino
CompletableFuture<String> future = new CompletableFuture<>();
// 某个 HTTP 回调、消息监听器甚至 main 线程都可以完成它
future.complete("result");
它也可能由多个来源共同决定:
ini
CompletableFuture<Object> result =
CompletableFuture.anyOf(requestA, requestB, requestC);
当你取消 result 时,它应该中断谁?
requestA的执行线程?requestB和requestC也一起停掉?- 正在执行
thenApply的线程? - 如果结果来自 HTTP 回调,应该关闭哪个连接?
这些问题没有一个统一答案。
因此 CompletableFuture 更像一个可以组合的结果状态机,而不是底层执行资源的所有者。它实现 Future,主要是为了提供基本等待、状态查询和兼容已有 API,并不意味着它一定拥有某个可以中断的线程。
3. Java 中也不存在安全的"强制终止"
即使是 FutureTask.cancel(true),也只是尝试向 runner 发送中断请求。
任务能否停止仍然取决于:
- 是否处在
sleep()、wait()、join()等可中断阻塞点; - 是否主动检查
isInterrupted(); - HTTP、数据库或第三方客户端是否支持自己的取消机制;
- 任务是否吞掉了
InterruptedException并继续运行。
所以真正可靠的任务取消通常需要几层配合:
scss
结果层:Future / CompletableFuture 表达取消状态
执行层:FutureTask.cancel(true) 或 Thread.interrupt()
业务层:检查中断、停止后续落库、清理资源
资源层:HTTP Call.cancel()、连接超时、查询超时
尤其是 HTTP 请求,本地取消等待不代表远端一定停止处理。支付、下单等业务仍然需要幂等键和业务状态保护,不能把线程中断当成分布式回滚。
写在最后的话
对并发保持敬畏,尤其是在 AI Coding 越来越普遍的今天。代码可以生成得很快,但线程、中断、竞争和资源生命周期仍然需要人认真想清楚。
我很喜欢一句话:
当你无法驾驭自己的工具时,失控只是时间问题。
最后说一下,我确实正在找工作。成都8年Java后端开发经验,6年技术团队管理经验,有合适的内推或者机会欢迎交流,私聊,索取简历。说不定以后真能一起做点有趣的事情。