Skip to content

Thread / Runnable / Callable / Future ​

在总览中的位置:Java 并发模型总览 §1
相关主题:ExecutorService / 线程池 · JMM:happens-before / 安全发布

一句话定位 ​

Thread 是执行载体,Runnable / Callable 是任务描述,Future 是异步结果句柄;这组概念的核心是把“谁来跑、何时拿结果、如何取消、哪里会阻塞”拆开理解。

代码索引 ​

主题Lab 说明源码
submit() / Future.get() / 拒绝策略 / CallerRunsPolicythread-pool-basicsThreadPoolBasics.java

当前仓库还没有“Thread / Future 专属单文件 lab”;本专题先复用 thread-pool-basics 中 submit()、Future.get()、任务提交与回收逻辑做联动学习。

为什么需要 ​

很多工程师会写 new Thread(),但真实工程里更容易栽在以下问题上:

  • Android:主线程不能阻塞,页面退出后后台任务还在跑,结果回调回来时页面已经销毁。
  • Backend:一个请求往往会并行打多个下游;如果没有返回值建模、超时控制和取消语义,所谓“并发”最后只是更乱的阻塞。
  • Java/Kotlin 混合项目:协程上层看起来优雅,但底层 SDK、老库、线程池、Future 仍然是线上问题来源。
  • Flutter / Web 对照:Flutter isolate 和 Web event loop 默认不共享内存,思维负担更偏“消息传递”;Java 共享内存并发则必须显式考虑任务边界、阻塞点和结果可见性。

最常见的误区是把这些概念混成一句话:

  • Thread 不是任务;它只是跑任务的“车”。
  • Runnable / Callable 不是线程;它们只是“车上装的活”。
  • Future 不是异步魔法;它只是“领结果、查状态、请求取消”的票据。

一个最小对照:

java
// ❌ 直接 new Thread:执行了,但结果、取消、统一治理都要自己补
new Thread(() -> userRepository.refresh()).start();

// ✅ 把任务交给执行器:结果、取消、状态观察都有了统一入口
Future<User> future = pool.submit(() -> api.loadUser(userId));
User user = future.get(300, TimeUnit.MILLISECONDS);
  • 可能执行顺序:第一段直接起线程异步刷新;第二段把任务提交给线程池后立即返回 Future;调用方在 get() 处阻塞,直到任务完成、超时或抛异常。
  • 可能输出 / 预期现象:直接 new Thread() 代码“能跑”,但没有统一结果回收入口;Future.get(...) 成功时拿到 User,失败时可能抛超时或执行异常。
  • 观察重点:阻塞点其实在 get() 而不是 submit();需要结果、取消、异常传播时,Future 比裸线程更容易治理。

底层机制 ​

1. Thread:最底层执行载体 ​

Thread 代表 JVM 映射到 OS 调度实体的执行单元。直接 new Thread() 意味着你在手动承担:

  • 创建成本:线程栈、调度、上下文切换
  • 生命周期:启动、结束、命名、守护线程属性
  • 异常边界:线程里抛错如何记录、如何上报
  • 取消语义:如何响应中断、如何收尾

最小示例:

java
Thread worker = new Thread(() -> {
    System.out.println("run on " + Thread.currentThread().getName());
}, "profile-loader");
worker.start();
worker.join();
  • 可能执行顺序:主线程创建 worker → 调 start() 交给 JVM 调度 → worker 在线程 profile-loader 中打印 → 主线程在 join() 处等待它结束。
  • 可能输出:run on profile-loader
  • 预期现象:打印发生在新线程而不是主线程;join() 返回后才能保证工作线程已结束。
  • 观察重点:把 start() 换成 run() 后就不会并发;join() 是显式阻塞点,调用线程会停在这里等结果。

要点:

  • start() 才会真正创建新的执行路径。
  • run() 只是普通方法调用;直接调 run() 不会并发。
  • join() 是一个显式阻塞点;调用线程会等目标线程结束。

什么时候可以直接用 Thread?

  • 最小复现 bug
  • 非常底层的并发教学 / 原语实验
  • 少量需要手动命名、守护属性、优先级控制的基础设施代码

什么时候不该直接用?

  • 日常业务任务
  • 一批短任务
  • 需要统一取消、超时、线程命名、监控的场景

2. Runnable:无返回值任务描述 ​

Runnable 解决的是“把要做的事描述出来”,而不是“由谁执行”。

java
Runnable warmupTask = () -> cache.preload();
new Thread(warmupTask, "cache-warmup").start();
pool.execute(warmupTask);
  • 可能执行顺序:同一个 Runnable 可以先被单独线程执行,也可以被线程池中的工作线程执行;两次提交彼此独立,谁先跑完取决于调度与任务耗时。
  • 预期现象:任务体本身不关心由谁执行,只描述“要做什么”;如果 cache.preload() 有副作用,两次提交就会执行两遍。
  • 观察重点:Runnable 只描述动作,不自带结果句柄;是否允许重复执行,要由上层控制提交次数。

适合:

  • fire-and-forget 任务
  • 结果不需要回收
  • 结果通过其他机制异步通知(如消息、回调、状态流)

边界:

  • 没有返回值
  • 不能抛受检异常
  • 如果你开始依赖外部共享变量回传结果,说明它可能已经不适合继续用 Runnable

3. Callable:带返回值、可抛异常的任务抽象 ​

Callable<V> 更像“异步函数”:

java
Callable<String> tokenTask = () -> {
    String token = authApi.refreshToken();
    if (token == null) {
        throw new IllegalStateException("empty token");
    }
    return token;
};
  • 可能执行顺序:任务被执行后先刷新 token;拿到非空值就正常返回;拿到空值则抛异常并结束。
  • 可能输出 / 预期现象:调用方若通过 Future.get() 回收结果,会要么拿到 token,要么收到包装后的执行异常,而不是静默失败。
  • 观察重点:Callable 适合把“结果 + 失败”一起上抛;比起往共享变量里塞结果,更容易统一超时、取消和异常处理。

适合:

  • 有明确返回值
  • 调用方要知道成功/失败
  • 需要把异常留给上层统一处理

工程上,Callable 比 Runnable 更适合:

  • 后台查库 / 查网络返回结果
  • 并行 fan-out 聚合
  • 单次计算任务(压缩、签名、转换)

4. Future:异步结果句柄,不是执行器 ​

Future 的职责只有四类:

  • get():等待结果
  • get(timeout, unit):有超时地等待结果
  • cancel(mayInterruptIfRunning):请求取消
  • isDone() / isCancelled():观察状态
java
Future<String> future = pool.submit(() -> {
    Thread.sleep(200);
    return "done";
});

if (!future.isDone()) {
    System.out.println("still running");
}

String result = future.get(500, TimeUnit.MILLISECONDS);
System.out.println(result);
  • 可能执行顺序:提交后后台任务先睡眠约 200ms;主线程第一次检查 isDone() 大概率还是 false;随后在 get(500ms) 处阻塞,任务完成后打印结果。
  • 可能输出:
    • still running
    • done
  • 预期现象:isDone() 只是瞬时状态;真正的等待发生在 get(),而且这里 500ms 超时大于任务耗时,所以通常能顺利返回。
  • 观察重点:如果把超时改得比任务更短,会抛 TimeoutException;如果任务内部抛错,get() 会以异常形式把失败带回调用方。

必须记住两件事:

  1. Future 是结果句柄,不是任务调度器。
  2. get() 是阻塞点;放错位置就会把异步重新变回同步。

5. submit()、execute()、start() 的差异 ​

调用你交出去的是什么有没有返回句柄常见误区
thread.start()一个 Thread没有以为 run() 跟 start() 一样
executor.execute(r)Runnable没有任务抛异常后没有统一结果句柄
executor.submit(r/c)Runnable / CallableFuture以为 submit() 自动让错误可见;实际上要 get() 才能显式感知任务异常

选型建议:

  • 只需要扔出去执行、不需要结果:execute() / Runnable
  • 需要返回值、超时、取消、异常观察:submit() / Callable
  • 只有在做底层控制或最小实验时才直接 new Thread()

6. 中断与取消:cancel(true) 不是“强制杀线程” ​

java
Future<?> future = pool.submit(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        doOneChunk();
    }
});

future.cancel(true);
  • 可能执行顺序:任务启动后不断处理 chunk;外部线程调用 cancel(true) 发送中断请求;任务在下一次检查到中断标记后跳出循环。
  • 预期现象:如果 doOneChunk() 很短且循环持续检查中断,任务通常会较快结束;如果任务不检查中断或卡在不可中断阻塞里,就可能“状态已取消但线程还在跑”。
  • 观察重点:cancel(true) 不是强杀;取消是否生效,取决于任务代码是否合作响应中断。

cancel(true) 的语义是:

  • 尽量给运行中的线程发中断信号
  • 能否停下来取决于任务本身是否检查中断、是否调用可中断阻塞 API

如果任务代码从不检查中断,或者把 InterruptedException 吃掉不处理,那么“取消成功”往往只是表面成功。

Android / Flutter / Web / Backend 对照 ​

维度Java 线程 / FutureAndroidFlutterWebBackend
执行载体Thread / ExecutorService主线程 + 线程池 + HandlerThreadmain isolate + child isolateevent loop + Workerrequest thread / 业务线程池
任务描述Runnable / CallableRunnable、协程桥接到线程池Future、compute()、Isolate.spawnpromise / task / callbackCallable、CompletableFuture
结果回收Future.get()严禁在主线程直接 get()await 非阻塞主 isolate 调度await 回调到 event loop聚合多个下游结果
取消方式cancel(true) + 中断配合页面销毁 / ViewModel 清理时取消StreamSubscription.cancel / isolate killAbortController请求超时、熔断、取消传播
心智负担共享内存 + 阻塞点管理额外叠加生命周期 / ANR更偏消息隔离更偏事件顺序更偏资源治理、超时和尾延迟

常见场景 ​

1. Android 页面退出后取消后台任务 ​

比如详情页里点“导出图片元数据”,任务会跑几百毫秒到几秒。如果页面已经返回,继续计算再回调 UI 就没有意义了。

java
class DetailPresenter {
    private Future<?> exportFuture;

    void export(ExecutorService pool) {
        exportFuture = pool.submit(() -> exporter.exportCurrentItem());
    }

    void onDestroy() {
        if (exportFuture != null) {
            exportFuture.cancel(true);
        }
    }
}
  • 可能执行顺序:页面发起导出后后台开始执行;若页面先销毁,则在 onDestroy() 请求取消;任务若及时响应中断,就会尽快停止而不再继续做无效工作。
  • 预期现象:页面退出后不应再有旧任务继续长时间占 CPU,更不该晚到回调覆盖已销毁页面状态。
  • 观察重点:要验证的不只是 cancel(true) 有没有被调用,还要看导出任务内部是否真正停下。

排障点:

  • 如果取消后 CPU 还在跑,先检查任务内部是否响应中断。
  • 如果 cancel(true) 后仍然写文件,说明循环体 / IO 路径没有及时检查中断标记。

2. Backend 并行调用多个下游再聚合 ​

java
Future<User> userFuture = pool.submit(() -> userApi.queryUser(uid));
Future<OrderSummary> orderFuture = pool.submit(() -> orderApi.querySummary(uid));

User user = userFuture.get(200, TimeUnit.MILLISECONDS);
OrderSummary summary = orderFuture.get(200, TimeUnit.MILLISECONDS);
  • 可能执行顺序:两个下游任务先并行提交;谁先完成不固定;主线程分别在两个 get(200ms) 处等待各自结果。
  • 预期现象:如果两个下游都在时限内返回,总耗时接近“较慢那个”而不是两者串行相加;任一超时或失败都会在对应 get() 处暴露。
  • 观察重点:并行提交不等于完全非阻塞,聚合线程最终仍要在 get() 处承担等待成本;小线程池里不要让池内任务再互相 get()。

适合:

  • 两个下游彼此独立
  • 你需要总超时和降级策略

边界:

  • 不要在同一个很小的固定线程池里“任务 A 再等任务 B”,很容易做成线程池饥饿。
  • 对多依赖聚合场景,超过 2~3 条链后,CompletableFuture 或协程通常比裸 Future 更易维护。

3. 底层 SDK / 老 Java 模块里的一次性初始化任务 ​

当项目还没完全迁到 Kotlin 协程、或者你在维护 Java SDK / Java 服务端模块时,经常需要把一个预热任务交给后台跑,但不想把“任务是什么”与“线程怎么建”写死在一起。

java
Runnable preload = () -> dictionary.loadFromDisk();
pool.execute(preload);
  • 可能执行顺序:上层只负责把预热任务提交出去,真正何时开始、由哪个工作线程执行,由线程池调度决定。
  • 预期现象:任务描述和执行策略解耦;同一个 Runnable 可以在测试、串行池、并行池之间复用,而不用改任务本体。
  • 观察重点:这里没有返回值句柄;如果后续开始依赖执行结果,就该升级为 Callable / Future。

这时 Runnable 的价值不在语法,而在于:

  • 可以在测试中替换执行器
  • 可以统一打线程名、超时、监控
  • 可以在上层切换为串行池 / 并行池而不用改任务本体

常见坑 ​

坑现象修法
把 run() 当成 start()代码“能跑”,但其实还在当前线程串行执行需要并发时必须调 start() 或交给执行器
主线程直接 Future.get()Android 卡顿、ANR;桌面 UI 卡死改成回调、超时等待、后台线程聚合
在线程池任务内部互相 get()固定线程池看起来“卡死”拆池、扩大并发模型、避免池内互等
只会 cancel(true),任务却不响应中断状态显示取消了,CPU / IO 还在跑任务里检查 isInterrupted(),妥善处理 InterruptedException
用共享可变字段传结果结果竞争、可见性混乱需要结果时优先用 Callable / Future
submit() 后不 get() 也不记日志任务异常悄悄丢在后台统一包装日志,或在关键路径显式 get() / 超时处理
大量直接 new Thread()线程数失控,排障困难换成统一线程池治理

与相近概念对比 ​

概念它是什么解决什么问题不解决什么
Thread执行载体真正把代码跑起来任务治理、结果回收、资源控制
Runnable无返回值任务描述动作返回值、受检异常
Callable<V>有返回值任务描述一次异步计算调度策略
Future<V>结果句柄等待/取消/查状态线程创建、任务编排
FutureTask<V>Runnable + Future 桥接实现需要手动包一层可执行 Future复杂异步编排
CompletableFuture<V>可组合的 future多任务编排、then/apply/exceptionally线程池参数治理本身

对应实验 ​

Lab说明源码
thread-pool-basicsthread-pool-basicsThreadPoolBasics.java

当前仍缺:Thread / Runnable / Callable / Future 专属实验(例如 run() vs start()、submit() vs execute()、Future.cancel(true) 响应中断演示、池内互相 get() 饥饿复现)。

复习检查题 ​

  1. Thread、Runnable、Callable、Future 各自扮演什么角色?

    答:Thread 是执行载体;Runnable 是无返回值任务描述;Callable 是有返回值且可抛异常的任务描述;Future 是任务提交之后的结果句柄,用来等待、取消和查状态。

  2. 为什么说 Future.get() 是并发代码里的危险阻塞点?

    答:因为它会让当前线程阻塞等待结果。如果放在 Android 主线程、请求关键路径、或者小线程池内部互等路径里,就会把异步重新变成卡顿、超时或线程池饥饿问题。

  3. cancel(true) 为什么经常“看起来成功,任务却没停”?

    答:因为 cancel(true) 只是发中断请求,不会强杀线程。任务必须自己检查中断标记,或者在可中断阻塞点上正确处理 InterruptedException,才能真正停下来。

  4. 什么场景下 Runnable 不够用,应该换成 Callable?

    答:当任务需要返回结果、把异常显式传回调用方、或者调用方需要用 Future 做超时/取消/结果回收时,应该优先使用 Callable。

  5. run() 和 start() 的根本差异是什么?

    答:run() 只是普通方法调用,仍在当前线程执行;start() 才会让 JVM 创建新的执行路径并异步调度 run()。

速记 ​

  • Thread 是车,Runnable/Callable 是活,Future 是领结果的票。
  • get() 是阻塞点;主线程和小线程池里最危险。
  • 需要结果/异常/取消就上 Callable + Future;只扔任务可用 Runnable。
  • cancel(true) 依赖任务自己响应中断。

站点构建时间:2026/8/24 23:43:17