JAVA · Vol.II · DAY 10 · 并发编程
线程创建五种方式:Thread、Runnable、Callable、线程池、CompletableFuture
从一道高频面试题和一场线上事故开始
面试官放下简历,抬起头问你:
大多数候选人会脱口而出:"两种!继承 Thread 类和实现 Runnable 接口。"好一点的会补上 Callable。面试官微微点头,然后在心里给你打了个"初级"标签。
先别急着背答案,看一个真实事故,你就明白为什么这道题值得认真对待:
某活动系统要给 10 万用户批量推送站内信。开发图省事,在 for 循环里给每个用户 new Thread(task).start()。上线当晚:服务线程数瞬间冲到 10 万+,CPU 100%、频繁 Full GC,短信网关被并发打挂,最后服务 OOM 重启。排查定位到那行代码时,所有人都沉默了。
10 万线程是什么概念?HotSpot 中每个线程默认栈约 1MB(-Xss 参数,Linux x64 默认值),10 万线程光栈就要消耗百 GB 量级的地址空间;线程创建本身是 native 系统调用,要分配内核栈、线程控制块;而 10 万线程抢几十个 CPU 核,光上下文切换(Context Switch)就能把机器拖垮。这就是"直接 new Thread"最典型的一种死法。
实际上,Java 里能创建线程的方式远不止两种:从 JDK 1.0 的 extends Thread / implements Runnable,到 JDK 5 的 Callable + FutureTask 和线程池,再到 JDK 8 的 CompletableFuture.supplyAsync,一共 5 种生产级方式。面试官想听的从来不是数字,而是你对每种方式的适用场景、局限性、工程选型的理解。
读完这篇文章,你能回答:
- 为什么企业代码里几乎看不到
extends Thread? Callable和Runnable的本质区别是什么?Future.get()有哪些坑?- 为什么说"别直接 new Thread"?线程池到底强在哪?
runAsync和supplyAsync怎么选?- 生产环境线程池的线程,为什么都起了一个"正经名字"?
方式一:继承 Thread —— start() 和 run() 的天壤之别
这是教科书上的第一种方式——继承 Thread 类,重写 run() 方法,然后调用 start():
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程运行中: " + Thread.currentThread().getName());
}
}
// 启动线程
MyThread t = new MyThread();
t.start(); // 注意:不是 t.run()!run() 只是普通方法调用
这里藏着面试官最爱挖的第一个坑:start() 和 run() 到底有什么区别?
start() 内部会调用 native 方法 start0(),由 JVM 向操作系统申请创建一条新的内核线程,然后在新线程里回调你的 run()。而直接调用 run(),只是把 run() 当成一个普通方法,在当前线程里同步执行——根本没创建新线程。这是初学者最常犯的错误,也是面试官判断你是否真懂线程的第一道关卡。
用一段代码立刻验证:
public class StartVsRun {
public static void main(String[] args) {
Thread t = new Thread(() ->
System.out.println("干活的是: " + Thread.currentThread().getName()));
t.run(); // 输出"干活的是: main" —— 主线程自己把活干了
t.start(); // 输出"干活的是: Thread-0" —— 真的新开了一条线程
}
}
再追问一层:为什么 start() 只能调用一次?因为线程对象和操作系统线程是一一绑定的,start() 成功后线程进入 NEW → RUNNABLE 状态转换,第二次调用 start() 会直接抛 IllegalThreadStateException。
还有一个源码细节值得知道:Thread.run() 的默认实现是这样的——
@Override
public void run() {
if (target != null) { // target 就是构造时传入的 Runnable
target.run();
}
}
也就是说:继承 Thread 重写 run(),本质是覆盖掉"委托给 target"这段默认逻辑;而 new Thread(runnable) 是把任务存进 target,由默认 run() 转调。殊途同归,但后者更优雅——这就是下一站的主题。
- 单继承限制:Java 类只能继承一个父类,继承了 Thread 就再也没法继承业务基类;
- 耦合度高:任务逻辑与 Thread 类绑死,无法复用给其它线程;
- 无法返回结果:
run()的签名是 void; - 无法抛出受检异常:
run()的签名没有 throws,受检异常只能吞掉或包成 RuntimeException; - start() 只能调一次:重复调用抛 IllegalThreadStateException。
方式二:实现 Runnable —— 把"活"和"人"分开
为了解决单继承和耦合的问题,JDK 1.0 提供了 Runnable 接口。它只有一个抽象方法 run(),职责非常纯粹:描述"要做什么",至于"谁来做、怎么做线程",交给 Thread 去管:
public class MyTask implements Runnable {
@Override
public void run() {
System.out.println("Runnable 任务运行中: " + Thread.currentThread().getName());
}
}
// 创建并启动线程
Thread t = new Thread(new MyTask());
t.start();
// Runnable 是函数式接口(@FunctionalInterface),JDK 8+ 可以写 Lambda
Thread t2 = new Thread(() -> System.out.println("Lambda 写法"));
t2.start();
用一个类比帮你建立直觉:
Runnable 就像一张写好的任务单("把这三箱货搬到二楼"),Thread 是工人。任务单本身不干活,得有人拿着它去干;一个人拿不同的任务单能干不同的活,同一张任务单也可以让好几个人轮流干。继承 Thread 的写法,相当于"把任务单贴在工人脑门上"——这个工人就只能干这一种活,也没法再当别的角色的工人了。
Runnable 优于继承 Thread,具体有三个理由:
1. 解耦:任务逻辑(Runnable)与线程机制(Thread)分离,任务可以被任意载体执行
2. 灵活:实现接口的同时还能继承其它类,突破单继承限制
3. 共享:同一个 Runnable 实例可以传给多个 Thread,实现任务共享
第 3 点要提醒一句:任务共享是"同一份任务逻辑多处执行",如果 Runnable 里有可变字段,多个线程同时跑就会产生竞态。比如一个 Runnable 里有个 count 计数器,两个线程同时 count++,结果就不可控了——这是并发编程第一篇就要警惕的事。
但 Runnable 仍然有两个硬伤:没有返回值(run() 是 void),也无法抛出受检异常(Checked Exception,编译器强制要求处理的异常),因为接口签名里没有 throws。如果你的任务需要返回计算结果,就需要第三种方式。
只能自己 try-catch 吞掉,或者包装成 RuntimeException 往上抛——异常信息就这样"丢了",调用方根本拿不到。这也是 Runnable 只适合"干完就完"的简单任务的原因。想要异常也能完整地传回调用方,用下一站的 Callable。
方式三:Callable + FutureTask —— 有返回值的任务
JDK 5 引入了 Callable<V> 接口,专门解决 Runnable 的两大痛点:有返回值、能抛异常。
import java.util.concurrent.*;
// 1. 实现 Callable,指定返回类型;call() 可以 throws Exception
public class CalcTask implements Callable<Integer> {
@Override
public Integer call() throws Exception {
Thread.sleep(2000); // 模拟耗时计算,受检异常直接往外抛
return 42;
}
}
// 2. 用 FutureTask 包装 Callable(FutureTask 本身实现了 Runnable)
FutureTask<Integer> futureTask = new FutureTask<>(new CalcTask());
// 3. 传给 Thread 并启动(Thread 的构造器只收 Runnable,而 FutureTask 正是 Runnable)
new Thread(futureTask).start();
// 4. 阻塞等待结果
Integer result = futureTask.get(); // 阻塞当前线程,直到计算完成
System.out.println("计算结果: " + result);
核心关系链是:Callable 定义任务 → FutureTask 包装它 → 传给 Thread → get() 阻塞拿结果。为什么中间非要隔一个 FutureTask?
Thread 的构造函数只接受 Runnable,而 Callable 不是 Runnable。FutureTask 就是那个"翻译官"(适配器,Adapter):它实现了 RunnableFuture<V> 接口,而 RunnableFuture 同时继承了 Runnable 和 Future——
FutureTask implements RunnableFuture extends Runnable + Future
所以 FutureTask 既能当 Runnable 扔给线程/线程池执行,又能当 Future 查结果、查状态、取消任务。在 JDK 8 之前,这就是"提交一个能拿结果的任务"的标准姿势;线程池的 submit() 方法内部,干的也是"把 Callable 包成 FutureTask"这件事。
Callable 和 Runnable 的差异,用一张表说清(这是面试高频对比):
| 维度 | Runnable | Callable<V> |
|---|---|---|
| 抽象方法 | void run() | V call() throws Exception |
| 返回值 | 无 | 有(泛型 V) |
| 受检异常 | 不能抛,只能自己吞 | 可以抛,调用方从 Future 拿 ExecutionException |
| 函数式接口 | 是(JDK 8 起可写 Lambda) | 是(JDK 8 起可写 Lambda) |
| 引入版本 | JDK 1.0 | JDK 5 |
| 典型用途 | 异步执行,不关心结果 | 异步计算,需要拿结果或异常 |
Future 的 API 细节:get() 的三种死法
Future 接口一共 5 个方法,面试常考的是前 4 个:
| 方法 | 作用 | 注意 |
|---|---|---|
get() | 阻塞等待结果 | 永久阻塞,等到天荒地老 |
get(timeout, unit) | 限时等待结果 | 超时抛 TimeoutException |
isDone() | 任务是否完成 | 完成含"正常结束 / 抛异常 / 被取消" |
isCancelled() | 是否已取消 | 配合 cancel() 使用 |
cancel(boolean) | 请求取消任务 | mayInterruptIfRunning 决定是否中断运行中的线程 |
Future.get() 是阻塞调用——任务没算完,调用方线程就挂在那一直等。这里有一个生产里非常常见的坑:
某系统用线程池 submit 了一个查询任务,然后直接 future.get() 等结果。结果下游服务雪崩、任务永远不返回,而 get() 没有超时——所有请求线程全部阻塞在 get() 上,线程池被占满,接口整体不可用,故障一直持续到下游恢复才缓解。正确姿势是给 get() 加超时:
Future<String> future = executor.submit(() -> doRemoteCall());
try {
// 限时等待:最多等 3 秒,超时立刻抛 TimeoutException,别干等
String result = future.get(3, TimeUnit.SECONDS);
} catch (InterruptedException e) {
// ① 当前线程在等待时被中断
} catch (ExecutionException e) {
// ② 任务内部抛了异常,包在 ExecutionException 里(getCause() 才是真异常)
} catch (TimeoutException e) {
// ③ 超时了 —— 通常要配合 future.cancel(true) 把任务取消掉
}
三个细节值得背下来:
- ExecutionException 是"包装盒":任务里抛的任何异常,get() 时都会被包成 ExecutionException,真正的异常在
e.getCause()里——这是"Runnable 吞异常、Callable 能还你异常"的关键机制。 - isDone() 不等于"成功":任务抛异常、被取消,isDone() 都返回 true。想区分,得靠 isCancelled() 或捕获 ExecutionException / CancellationException。
- cancel(true) 只是"请求"中断:它调用线程的 interrupt(),如果任务不响应中断(比如在死循环里不检查中断标志),线程照样跑完。别指望 cancel 一定能停。
最后总结 Future 的三大局限——它们正是催生 CompletableFuture 的原因:
- 阻塞获取结果:只能
get()傻等,无法注册回调("算完了通知我");用 isDone() 轮询又是浪费 CPU 的忙等; - 无法组合:多个异步任务串联(A 完成 → 用 A 的结果启动 B)需要手动嵌套、层层 get();
- 取消困难:
cancel()只是设置标志位/发中断,不保证线程能停下来。
这些痛点,一部分被线程池解决(复用、管理),剩下的被 CompletableFuture 解决(回调、组合)。
为什么别直接 new Thread:四个理由
前三种方式都是手动 new Thread——每来一个任务就创建一条线程,用完就销毁。第 1 站的线上事故已经用 10 万线程的代价演示了后果。把"为什么别直接 new Thread"归纳成四个理由,面试时逐条讲:
理由一:创建销毁开销大
Java 的平台线程(Platform Thread)与操作系统线程是 1:1 映射的,new Thread 底层是 native 系统调用:要分配线程栈(默认约 1MB 地址空间)、创建内核线程控制块,用完还得销毁。一次两次无所谓,高频次、大批量创建时,这个开销会实打实吃掉 CPU 和内存,还会加剧 GC 压力。
理由二:无法复用
线程是"一次性的":跑完 run() 就进入 TERMINATED,不能再次 start()。任务多的时候就只能反复创建、销毁、创建、销毁——把最贵的两件事重复做了 N 遍。
理由三:数量无上限
没人限制你 new 多少条线程。任务一多,线程数可以无限膨胀,直到内存耗尽(OOM)或者把 CPU 拖进上下文切换的泥潭。第 1 站的事故就是最典型的例子。
理由四:无法统一管理、监控、优雅关闭
散落的 Thread 对象没人统计:并发量多少?队列积压多少?谁能优雅地通知所有线程停止?出了问题 jstack 一看,几百条 Thread-xxx,连哪个业务起的都不知道。线程池则天生自带这些能力。
直接 new Thread,就像一家餐厅每来一桌客人就现招一个临时工:忙起来大堂能站 500 个临时工,互相撞来撞去(上下文切换),用完还得当场结算遣散(销毁)。线程池则是固定编制的员工 + 排队叫号:后厨就 10 个灶台(核心线程),客人多了先排队(阻塞队列),实在忙不过来再临时加几个帮厨(最大线程),再有客人直接婉拒或让老板自己上(拒绝策略)。资源有上限、节奏可控、账目清晰——这就是工程化的力量。
把"new Thread"和线程池放一起对比:
| 维度 | new Thread | 线程池(ThreadPoolExecutor) |
|---|---|---|
| 线程复用 | ❌ 用完即毁 | ✅ 常驻复用 |
| 数量上限 | ❌ 无上限,可无限膨胀 | ✅ 核心数 + 最大数可控 |
| 任务排队 | ❌ 没有队列,直接创建 | ✅ 阻塞队列缓冲 |
| 统一管理 | ❌ 无法统计、无法限流 | ✅ 可监控活跃数/队列/完成数 |
| 优雅关闭 | ❌ 只能等线程自己结束 | ✅ shutdown() / awaitTermination() |
| 适合场景 | Demo、一次性简单任务 | 几乎一切生产场景 |
生产代码里,"创建线程"这个动作本身就应该交给线程池,你要做的是"提交任务"。这就是五种方式里生产最常用的一种——方式四:线程池提交。
方式四:线程池提交(生产标准)
线程池(Thread Pool)的思路是:提前创建好一组线程常驻待命,任务来了直接提交,线程复用,用完了也不销毁。这是企业项目的标准做法。
import java.util.concurrent.*;
// 不要用 Executors 工厂(原因见第 8 站),用 ThreadPoolExecutor 手动建
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // corePoolSize 核心线程数
4, // maximumPoolSize 最大线程数
60L, TimeUnit.SECONDS, // keepAliveTime 非核心线程空闲存活时间
new LinkedBlockingQueue<>(100), // workQueue 任务队列(生产建议有界)
new NamedThreadFactory("biz-pool"), // threadFactory 线程工厂(命名,见第 9 站)
new ThreadPoolExecutor.CallerRunsPolicy() // handler 拒绝策略
);
// execute:提交 Runnable,没有返回值
pool.execute(() -> System.out.println("Runnable 任务"));
// submit:提交 Callable/Runnable,返回 Future 可以拿结果、拿异常
Future<String> future = pool.submit(() -> {
Thread.sleep(1000);
return "线程池返回的结果";
});
System.out.println(future.get());
// 用完关闭(生产里通常是应用关闭钩子里做两阶段优雅关闭)
pool.shutdown();
这一站先记住两个最核心的点,7 大参数的细节和调优公式在《线程池 ThreadPoolExecutor 深挖》那篇展开:
execute(Runnable):只执行,没有返回值;任务异常会直接抛给工作线程,处理不当可能被吞掉(该话题详见线程池篇);submit(Callable/Runnable):返回Future,异常会被捕获并塞进 Future,等get()时以 ExecutionException 抛给你——想拿结果、想优雅处理异常,就用 submit。
先跑核心线程 → 核心满了进阻塞队列排队 → 队列满了才扩到最大线程 → 还是满就触发拒绝策略
注意一个反直觉的点:线程池不是"任务来了就加线程",而是优先让任务排队。核心线程满 → 队列满 → 才创建新线程到最大线程数。很多人以为 maximumPoolSize 是"并发上限",其实它是"队列也扛不住之后的最后防线"。完整决策树、拒绝策略选型和调优公式,见线程池专题篇。
- 线程池提交是生产默认姿势:execute() 无返回值,submit() 返回 Future 可拿结果/异常;
- 执行顺序一句话:核心线程 → 队列 → 最大线程 → 拒绝策略;
- 7 大参数、决策树、调优公式,见《线程池 ThreadPoolExecutor 深挖》篇;
- 别用 Executors 工厂建池,原因下一站展开。
Executors 工厂:方便,但生产禁用
JDK 5 顺手给了 Executors 工具类,一行代码就能建线程池:Executors.newFixedThreadPool(10)。为什么阿里规约强制禁用?因为它把关键参数藏起来了,而藏起来的默认值恰好都是"毒药":
| 工厂方法 | 队列 | 最大线程 | 隐患 |
|---|---|---|---|
newFixedThreadPool(n) | LinkedBlockingQueue(无界) | n | 任务无限堆积 → 内存 OOM |
newSingleThreadExecutor() | LinkedBlockingQueue(无界) | 1 | 同上,单线程串行 + 堆积 OOM |
newCachedThreadPool() | SynchronousQueue | Integer.MAX_VALUE | 线程无限创建 → 线程爆炸 OOM |
newScheduledThreadPool(n) | DelayedWorkQueue(无界) | Integer.MAX_VALUE | 延迟任务堆积 + 线程无上限 |
无界队列:fixed 线程池核心线程只有 n 个,任务进来全往无界队列里塞。队列是纯内存结构,塞 1000 万个任务就是 1000 万个对象——内存先爆(OOM),而且任务永远得不到执行,还查不出原因。线程爆炸:cached 线程池来一个任务建一个线程,最大线程数是 Integer.MAX_VALUE(约 21 亿),第 1 站那种"10 万线程"事故,用 cached 池一样能复现。
阿里 Java 开发手册原文:【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
生产代码一律 new ThreadPoolExecutor(...) 手动创建,把 7 个参数全部显式写清楚(见第 7 站模板),并用有界队列(如 ArrayBlockingQueue 或指定容量的 LinkedBlockingQueue)+ 明确的拒绝策略,让"队列满"这个状态能被感知、被处理,而不是悄悄堆积到 OOM。
生产细节:线程工厂(ThreadFactory)与线程命名规范
这一站是"面试不会单独考、但生产天天用"的加分项:给线程起名字。
想象一下排障现场:线上出问题,你 jstack 一下,看到 200 条 Thread-17、Thread-88……完全不知道它们是哪个业务的。而如果线程名叫 order-push-pool-1、metrics-collector-2,一眼就能定位是哪个模块在跑什么。线程池的默认线程工厂只会按 pool-1-thread-1 递增命名,所以生产里几乎都要自定义 ThreadFactory:
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class NamedThreadFactory implements ThreadFactory {
private final AtomicInteger seq = new AtomicInteger(1);
private final String prefix;
public NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + seq.getAndIncrement());
t.setDaemon(false); // 按业务需要设置是否守护线程
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
}
// 使用:线程池的第五个参数传入
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 4, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new NamedThreadFactory("order-push"), // 线程名: order-push-1, order-push-2...
new ThreadPoolExecutor.CallerRunsPolicy()
);
命名规范,行业内比较通用的是"业务模块-具体用途":比如订单推送线程池叫 order-push-pool、日志采集叫 log-collector、指标上报叫 metrics-sender。如果项目里引了 Guava,可以直接用 ThreadFactoryBuilder 一行搞定:
import com.google.common.util.concurrent.ThreadFactoryBuilder;
ThreadFactory named = new ThreadFactoryBuilder()
.setNameFormat("order-push-%d") // %d 会自动替换为序号
.setDaemon(false)
.build();
- 排障:jstack / Arthas 一眼认出线程属于哪个业务,不用猜;
- 监控:按线程名前缀聚合指标,能看清每个池的活跃线程数;
- 协作:别人接手你的代码,看到线程名就懂了这个池的用途。
方式五:CompletableFuture —— runAsync 与 supplyAsync
JDK 8 引入的 CompletableFuture 是 Future 的全面升级:非阻塞、可链式组合、支持回调(Callback)。作为"创建异步任务"的方式,它提供了两个静态工厂方法,对应"要不要返回值"两种需求:
| 方法 | 入参 | 返回 | 场景 |
|---|---|---|---|
runAsync(Runnable) | Runnable(无返回值) | CompletableFuture<Void> | 只执行不关心结果:发日志、埋点、异步刷缓存 |
supplyAsync(Supplier<U>) | Supplier<U>(有返回值) | CompletableFuture<U> | 异步计算要拿结果:并行查询、异步汇总 |
两个方法都有带线程池的重载:runAsync(task, executor)、supplyAsync(task, executor)。默认不传时用的是 ForkJoinPool.commonPool()。先看基本用法:
import java.util.concurrent.*;
// 1. runAsync:没有返回值,干完拉倒(默认在 ForkJoinPool.commonPool 执行)
CompletableFuture<Void> f1 = CompletableFuture.runAsync(() -> {
// 异步刷缓存、写日志之类,不需要结果
cache.refresh("hot-key");
});
// 2. supplyAsync:有返回值,像 Callable 一样能拿到结果
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return "查询完成";
});
// 3. 拿结果:join() 和 get() 类似,但抛的是非受检的 CompletionException
System.out.println(f2.join());
跟 Runnable / Callable 的选择同一个道理:需要返回值或后续要"接着算",用 supplyAsync;只是"干一件事、不关心产出",用 runAsync。另外注意一个细节:无论哪个,默认线程池都是 ForkJoinPool.commonPool()——它是全 JVM 共享的公共池,并行度默认只有"CPU 核数 - 1",一旦被某个阻塞任务占满,会影响所有用它的异步代码。所以生产环境必须传自定义线程池,这一点第 12 站会演示。
CompletableFuture 强在哪:回调与链式编排
前面说 Future 有三大局限,CompletableFuture 逐个击破。核心思想就一句话:别用 get() 阻塞等着,把"结果到了之后干什么"提前注册好——这叫回调(Callback)。
CompletableFuture.supplyAsync(() -> "查询完成")
// 拿到结果后继续转换(有参有返回)
.thenApply(result -> result + ",已处理")
// 消费结果,不再返回(有参无返回)
.thenAccept(finalResult -> System.out.println(finalResult))
// 异常兜底:任何一环抛异常都会走到这里
.exceptionally(ex -> { System.out.println("出错了: " + ex); return null; });
// 组合两个独立的异步任务
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> 20);
cf1.thenCombine(cf2, (a, b) -> a + b)
.thenAccept(sum -> System.out.println("合计: " + sum)); // 30
这一站点到为止——thenApply / thenAccept / thenCombine / allOf / anyOf 的完整对比、thenCompose 扁平化、以及从"回调地狱"到"流式编排"的演进,是《CompletableFuture 异步编程》那篇的专题,这里只记住它与 Future 的本质差异:
| 特性 | Future | CompletableFuture |
|---|---|---|
| 获取结果 | get() 阻塞等待 | thenAccept() 回调,非阻塞 |
| 任务组合 | 不支持,需手动嵌套 | thenApply / thenCompose / thenCombine |
| 异常处理 | 只能 try-catch get() | exceptionally / handle 回调 |
| 多任务编排 | 需 CountDownLatch 等辅助 | allOf / anyOf 原生支持 |
supplyAsync / runAsync 默认使用 ForkJoinPool.commonPool(),生产必须传自定义线程池:CompletableFuture.supplyAsync(() -> doWork(), myExecutor);
一个需求两种写法:Future 时代 vs CompletableFuture 时代
用一个真实常见的需求串起全文:商品详情页要并行查询价格、库存、评价三个数据源,最后汇总展示,并且整体不能超过 3 秒。
先看"Future 时代"的写法(JDK 5 风格,线程池 submit + get 超时):
ThreadPoolExecutor pool = buildPool("detail-query"); // 自定义线程池
// 三个查询并行提交,拿到三个 Future
Future<BigDecimal> priceF = pool.submit(() -> queryPrice(id));
Future<Integer> stockF = pool.submit(() -> queryStock(id));
Future<String> reviewF = pool.submit(() -> queryReview(id));
// 逐个限时取结果:任何一个超过 1 秒都不等它
BigDecimal price = priceF.get(1, TimeUnit.SECONDS);
Integer stock = stockF.get(1, TimeUnit.SECONDS);
String review = reviewF.get(1, TimeUnit.SECONDS);
// 问题:三个 get 是串行阻塞的!price 等 1 秒、stock 又等 1 秒……最坏总耗时 3 秒相加
注意上面的坑:三个 get() 是依次阻塞的,最坏情况总耗时是 3 个 1 秒相加,而不是并行 1 秒。Future 时代想真正并行等待,得靠 CountDownLatch 之类的辅助类硬凑,代码越写越绕。
再看 CompletableFuture 时代(JDK 8 风格):
// pool:同上一段的自定义线程池(生产必须传,别用 commonPool)
CompletableFuture<BigDecimal> priceF = CompletableFuture.supplyAsync(() -> queryPrice(id), pool);
CompletableFuture<Integer> stockF = CompletableFuture.supplyAsync(() -> queryStock(id), pool);
CompletableFuture<String> reviewF = CompletableFuture.supplyAsync(() -> queryReview(id), pool);
// 三个任务并发执行,全部完成后触发回调(真正的并行等待)
CompletableFuture.allOf(priceF, stockF, reviewF)
.thenApply(v -> "价格 " + priceF.join() + ",库存 " + stockF.join()
+ ",评价 " + reviewF.join())
.orTimeout(3, TimeUnit.SECONDS) // 整体超时 3 秒,到点抛 TimeoutException
.exceptionally(ex -> "查询超时,返回兜底数据")
.thenAccept(System.out::println);
同样一个需求,后者把"并行提交、全部等待、超时兜底、异常降级"四个诉求用一条链表达清楚,可读性高一个量级。这就是 CompletableFuture 成为现代 Java 异步标配的原因。
- 并行任务 永远传自定义线程池,别用 commonPool(并行度只有核数 - 1,还被全 JVM 共享);
- 涉及外部调用,必加超时:CompletableFuture 用
orTimeout/completeOnTimeout(注意:这俩是 JDK 9+ 才提供的,JDK 8 上要么用get(timeout, unit)+ 手动兜底,要么引入第三方实现);Future 则用get(timeout, unit); - 用
join()还是get()?join 抛非受检的 CompletionException(代码清爽),get 抛受检异常(调用方必须处理)。生产里如果异常有统一兜底,常用 join。
五种方式全景对比:一张表回答面试题
把五种方式放一起,按面试官最关心的维度对比:
| 方式 | 能否返回值 | 能否抛受检异常 | 是否复用线程 | 适用场景 | 引入版本 |
|---|---|---|---|---|---|
| ① 继承 Thread | 否 | 否 | 否(一次性) | 教学 Demo;生产不推荐(单继承 + 耦合) | JDK 1.0 |
| ② 实现 Runnable | 否 | 否 | 否(需配合 Thread) | 简单异步任务;配合线程池 execute() 使用 | JDK 1.0 |
| ③ Callable + FutureTask | 是 | 是 | 否(一次性,需配合 Thread/线程池) | JDK 8 之前"要结果"的异步任务;理解 Future 机制 | JDK 5 |
| ④ 线程池提交(execute/submit) | execute 否 / submit 是 | submit 可捕获 | 是(核心价值) | 生产标配:高频任务、资源受限、需要管理监控 | JDK 5 |
| ⑤ CompletableFuture.supplyAsync | 是(runAsync 否) | 是(exceptionally 兜底) | 是(配合线程池) | 复杂异步编排:并行聚合、链式回调、超时降级 | JDK 8 |
三条结论,背下来面试就稳了:
1. 单任务、要结果、JDK 5~7 老项目 → Callable + 线程池 submit
2. 多任务并行、要编排、JDK 8+ → CompletableFuture + 自定义线程池
3. 任何"重复提交任务"的生产场景 → 线程池(永远别裸 new Thread)
选型决策树:一张图看清怎么选
把上面的口诀画成决策树,面试时照着这个思路说,逻辑就是完整的:
决策逻辑一句话
- 不需要返回值 → 看并发量:低并发简单任务用 Runnable,生产场景一律线程池 execute;
- 需要返回值 → 看编排复杂度:简单"提交-等待"用 Callable + 线程池 submit,复杂链式编排用 CompletableFuture;
- 继承 Thread 全程淘汰。
面试 30 秒总结与连环追问
30 秒版标准答案
Q:Java 有几种创建线程的方式?
"严格来说有五种:最基础的是继承 Thread 类和实现 Runnable 接口,但 Thread 受单继承限制,实际开发倾向 Runnable;JDK 5 引入 Callable + FutureTask,可以获取返回值和异常,FutureTask 实现了 RunnableFuture,既是 Runnable 又是 Future;生产环境我们通过 ThreadPoolExecutor 提交任务,复用线程、避免反复创建销毁,execute 无返回值、submit 返回 Future;JDK 8 之后推荐 CompletableFuture.supplyAsync,它支持非阻塞回调和链式编排,适合复杂异步场景。总结:简单任务用 Runnable,生产用线程池,复杂异步编排用 CompletableFuture,永远不要直接 new Thread。"
面试官大概率会顺着你的回答连环追问,提前备好:
| 追问 | 参考答案 |
|---|---|
| start() 调用两次会怎样? | 抛 IllegalThreadStateException——线程已启动,不能二次 start |
| 直接调用 run() 会怎样? | 不创建新线程,在当前线程(如 main)里同步执行,等于普通方法调用 |
| 为什么 Runnable 的 run() 不能抛受检异常? | 接口签名 void run() 没有 throws;受检异常只能内部处理 |
| FutureTask 是 Runnable 还是 Future? | 都是——它实现 RunnableFuture,RunnableFuture 继承 Runnable 和 Future |
| Future.get() 一直等不到结果会怎样? | 永久阻塞;必须用 get(timeout, unit) 限时等待并处理 TimeoutException |
| execute 和 submit 的区别? | execute 无返回值、异常可能被吞;submit 返回 Future,异常在 get() 时以 ExecutionException 抛出 |
| 为什么禁止用 Executors 建线程池? | 默认队列无界(OOM)或最大线程无上限(线程爆炸);阿里规约要求手动 ThreadPoolExecutor + 有界队列 |
| runAsync 和 supplyAsync 的区别? | 前者无返回值(Runnable),后者有返回值(Supplier);生产都要传自定义线程池 |
番外:JDK 21 虚拟线程 —— 线程创建的新生态
2023 年 9 月,JDK 21(LTS)正式发布了虚拟线程(Virtual Thread,Project Loom 的成果),这是"线程创建"这个主题上十年一遇的大变化,面试聊到前沿可以加分。
前面说过:平台线程(Platform Thread)与操作系统线程 1:1,所以创建 10 万线程会把机器打爆。虚拟线程的思路是让 JVM 自己调度海量轻量级线程,挂载在少量平台线程(载体线程)上——创建开销极小,百万级虚拟线程是常态。创建方式:
// 方式一:工厂方法,等价于"每条虚拟线程跑一个 Runnable"
Thread vt = Thread.ofVirtual().name("vt-1").start(() -> doWork());
// 方式二:配合 Executors 新工厂(虚拟线程版"线程池",几乎无限量)
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
pool.submit(() -> doWork());
pool.submit(() -> doWork());
} // try-with-resources 关闭时会等所有任务结束(JDK 19+ 支持)
- 解决:IO 密集 + 阻塞多的任务(HTTP 调用、RPC、JDBC),以前要手调线程池大小,现在虚拟线程"要多少有多少",阻塞不再浪费平台线程;
- 没解决:CPU 密集计算不会更快(还是那几核);synchronized 阻塞仍可能钉住载体线程(需要把 synchronized 换成 ReentrantLock 等);共享可变状态的线程安全问题一个都没少;
- 迁移注意:ThreadLocal 在百万虚拟线程下开销被放大,官方建议谨慎使用。
一句话定位:虚拟线程是"创建线程"的终极形态——把线程从稀缺资源变成廉价商品,但本文讲的选型思路(任务与执行分离、结果怎么拿、异常怎么传)依然适用,只是把"线程池大小怎么调"这个问题省掉了。
这一篇你掌握了什么
核心知识点回顾
- 五种方式:继承 Thread(单继承、耦合,不推荐)→ 实现 Runnable(任务与线程解耦,简单场景)→ Callable + FutureTask(有返回值、能抛异常,FutureTask 实现 RunnableFuture)→ 线程池提交(复用、上限、管理,生产标配)→ CompletableFuture.supplyAsync(非阻塞回调、链式编排,现代异步首选);
- start() vs run():start() 通过 native start0() 创建 OS 线程并回调 run(),只能调一次(否则 IllegalThreadStateException);直接调 run() 只是当前线程里同步执行;
- Callable vs Runnable:有无返回值、能否抛受检异常;Future 的 get() 阻塞、get(timeout) 超时抛 TimeoutException、isDone() 不等于成功、ExecutionException 里的 getCause() 才是真异常;
- 为什么别 new Thread:创建销毁开销大、无法复用、数量无上限、无法统一管理与监控 → 交给线程池(execute 无返回值 / submit 返回 Future);
- runAsync vs supplyAsync:无返回值 vs 有返回值;两者默认 commonPool,生产必须传自定义线程池并加超时;
- 生产规范:Executors 工厂禁用(无界队列/线程无上限)、ThreadFactory 命名(jstack 可读)、异常与超时兜底、JDK 21 虚拟线程是"线程廉价化"的新方向。
一句话总结
创建线程的演进史,就是一部"把线程从'一次性消耗品'变成'可复用资源',再把'手动等待'变成'自动回调'"的历史:new Thread 是最初级的"要什么自己造",线程池是工程化的"造好了按需复用",CompletableFuture 是声明式的"我要什么你异步算好告诉我",而虚拟线程则是把"造线程"这个成本本身降到了零。
👉 下一篇预告:《Java 学习笔记 · 卷二》线程状态与转换 —— 6 种状态、状态机与 jstack 实战。
Comments · 评论