首页 / Java 学习笔记 / 10

JAVA · Vol.II · DAY 10 · 并发编程

线程创建五种方式:Thread、Runnable、Callable、线程池、CompletableFuture

初级极高#并发#基础
第 1 站

从一道高频面试题和一场线上事故开始

面试官放下简历,抬起头问你:

"Java 有几种创建线程的方式?"

大多数候选人会脱口而出:"两种!继承 Thread 类和实现 Runnable 接口。"好一点的会补上 Callable。面试官微微点头,然后在心里给你打了个"初级"标签。

先别急着背答案,看一个真实事故,你就明白为什么这道题值得认真对待:

真实线上事故:for 循环里 new Thread

某活动系统要给 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
  • CallableRunnable 的本质区别是什么?Future.get() 有哪些坑?
  • 为什么说"别直接 new Thread"?线程池到底强在哪?
  • runAsyncsupplyAsync 怎么选?
  • 生产环境线程池的线程,为什么都起了一个"正经名字"?
第 2 站

方式一:继承 Thread —— start() 和 run() 的天壤之别

这是教科书上的第一种方式——继承 Thread 类,重写 run() 方法,然后调用 start()

MyThread.java · 继承 Thread
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() vs run():一个开新线程,一个只是调方法

start() 内部会调用 native 方法 start0(),由 JVM 向操作系统申请创建一条新的内核线程,然后在新线程里回调你的 run()。而直接调用 run(),只是把 run() 当成一个普通方法,在当前线程里同步执行——根本没创建新线程。这是初学者最常犯的错误,也是面试官判断你是否真懂线程的第一道关卡。

t.run() —— 普通方法调用 在【当前线程】里同步执行 谁调用,谁执行(通常是 main) 没有任何新线程诞生 t.start() —— 真的开线程 内部调用 native start0() JVM 创建一条 OS 内核线程 新线程(Thread-0)里回调 run()
图 1run() 只是"就地执行",start() 才会真正创建一条新线程

用一段代码立刻验证:

StartVsRun.java · 亲手验证
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() 的默认实现是这样的——

Thread.java · run() 的默认实现(JDK 8 源码)
@Override
public void run() {
    if (target != null) {   // target 就是构造时传入的 Runnable
        target.run();
    }
}

也就是说:继承 Thread 重写 run(),本质是覆盖掉"委托给 target"这段默认逻辑;而 new Thread(runnable) 是把任务存进 target,由默认 run() 转调。殊途同归,但后者更优雅——这就是下一站的主题。

继承 Thread 的局限
  • 单继承限制:Java 类只能继承一个父类,继承了 Thread 就再也没法继承业务基类;
  • 耦合度高:任务逻辑与 Thread 类绑死,无法复用给其它线程;
  • 无法返回结果run() 的签名是 void;
  • 无法抛出受检异常run() 的签名没有 throws,受检异常只能吞掉或包成 RuntimeException;
  • start() 只能调一次:重复调用抛 IllegalThreadStateException。
第 3 站

方式二:实现 Runnable —— 把"活"和"人"分开

为了解决单继承和耦合的问题,JDK 1.0 提供了 Runnable 接口。它只有一个抽象方法 run(),职责非常纯粹:描述"要做什么",至于"谁来做、怎么做线程",交给 Thread 去管:

MyTask.java · 实现 Runnable
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 是"人"

Runnable 就像一张写好的任务单("把这三箱货搬到二楼"),Thread 是工人。任务单本身不干活,得有人拿着它去干;一个人拿不同的任务单能干不同的活,同一张任务单也可以让好几个人轮流干。继承 Thread 的写法,相当于"把任务单贴在工人脑门上"——这个工人就只能干这一种活,也没法再当别的角色的工人了。

Runnable 优于继承 Thread,具体有三个理由:

Runnable 的优势
1. 解耦:任务逻辑(Runnable)与线程机制(Thread)分离,任务可以被任意载体执行
2. 灵活:实现接口的同时还能继承其它类,突破单继承限制
3. 共享:同一个 Runnable 实例可以传给多个 Thread,实现任务共享

第 3 点要提醒一句:任务共享是"同一份任务逻辑多处执行",如果 Runnable 里有可变字段,多个线程同时跑就会产生竞态。比如一个 Runnable 里有个 count 计数器,两个线程同时 count++,结果就不可控了——这是并发编程第一篇就要警惕的事。

但 Runnable 仍然有两个硬伤:没有返回值run() 是 void),也无法抛出受检异常(Checked Exception,编译器强制要求处理的异常),因为接口签名里没有 throws。如果你的任务需要返回计算结果,就需要第三种方式。

追问:Runnable 的 run() 不能声明 throws,那任务里必须调一个抛受检异常的方法(比如 Thread.sleep)怎么办?
点破

只能自己 try-catch 吞掉,或者包装成 RuntimeException 往上抛——异常信息就这样"丢了",调用方根本拿不到。这也是 Runnable 只适合"干完就完"的简单任务的原因。想要异常也能完整地传回调用方,用下一站的 Callable。

第 4 站

方式三:Callable + FutureTask —— 有返回值的任务

JDK 5 引入了 Callable<V> 接口,专门解决 Runnable 的两大痛点:有返回值能抛异常

CallableDemo.java · Callable + FutureTask
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 包装它 → 传给 Threadget() 阻塞拿结果。为什么中间非要隔一个 FutureTask?

为什么不能直接把 Callable 传给 Thread?
FutureTask 的地位:一个"既是 Runnable 又是 Future"的适配器

Thread 的构造函数只接受 Runnable,而 Callable 不是 Runnable。FutureTask 就是那个"翻译官"(适配器,Adapter):它实现了 RunnableFuture<V> 接口,而 RunnableFuture 同时继承了 RunnableFuture——

FutureTask implements RunnableFuture extends Runnable + Future

所以 FutureTask 既能当 Runnable 扔给线程/线程池执行,又能当 Future 查结果、查状态、取消任务。在 JDK 8 之前,这就是"提交一个能拿结果的任务"的标准姿势;线程池的 submit() 方法内部,干的也是"把 Callable 包成 FutureTask"这件事。

Callable 和 Runnable 的差异,用一张表说清(这是面试高频对比):

维度RunnableCallable<V>
抽象方法void run()V call() throws Exception
返回值有(泛型 V)
受检异常不能抛,只能自己吞可以抛,调用方从 Future 拿 ExecutionException
函数式接口是(JDK 8 起可写 Lambda)是(JDK 8 起可写 Lambda)
引入版本JDK 1.0JDK 5
典型用途异步执行,不关心结果异步计算,需要拿结果或异常
第 5 站

Future 的 API 细节:get() 的三种死法

Future 接口一共 5 个方法,面试常考的是前 4 个:

方法作用注意
get()阻塞等待结果永久阻塞,等到天荒地老
get(timeout, unit)限时等待结果超时抛 TimeoutException
isDone()任务是否完成完成含"正常结束 / 抛异常 / 被取消"
isCancelled()是否已取消配合 cancel() 使用
cancel(boolean)请求取消任务mayInterruptIfRunning 决定是否中断运行中的线程

Future.get()阻塞调用——任务没算完,调用方线程就挂在那一直等。这里有一个生产里非常常见的坑:

真实场景:get() 没有超时,把请求线程全部挂死

某系统用线程池 submit 了一个查询任务,然后直接 future.get() 等结果。结果下游服务雪崩、任务永远不返回,而 get() 没有超时——所有请求线程全部阻塞在 get() 上,线程池被占满,接口整体不可用,故障一直持续到下游恢复才缓解。正确姿势是给 get() 加超时:

FutureGetDemo.java · 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 的原因:

Future 的局限性
  • 阻塞获取结果:只能 get() 傻等,无法注册回调("算完了通知我");用 isDone() 轮询又是浪费 CPU 的忙等;
  • 无法组合:多个异步任务串联(A 完成 → 用 A 的结果启动 B)需要手动嵌套、层层 get();
  • 取消困难cancel() 只是设置标志位/发中断,不保证线程能停下来。

这些痛点,一部分被线程池解决(复用、管理),剩下的被 CompletableFuture 解决(回调、组合)。

第 6 站

为什么别直接 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、一次性简单任务几乎一切生产场景
结论

生产代码里,"创建线程"这个动作本身就应该交给线程池,你要做的是"提交任务"。这就是五种方式里生产最常用的一种——方式四:线程池提交。

第 7 站

方式四:线程池提交(生产标准)

线程池(Thread Pool)的思路是:提前创建好一组线程常驻待命,任务来了直接提交,线程复用,用完了也不销毁。这是企业项目的标准做法。

ThreadPoolDemo.java · 自定义线程池(生产模板)
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 vs submit:一个不给你结果,一个把结果包进 Future
  • execute(Runnable):只执行,没有返回值;任务异常会直接抛给工作线程,处理不当可能被吞掉(该话题详见线程池篇);
  • submit(Callable/Runnable):返回 Future,异常会被捕获并塞进 Future,等 get() 时以 ExecutionException 抛给你——想拿结果、想优雅处理异常,就用 submit。
线程池收到任务的执行顺序(决策树一句话版)
先跑核心线程 → 核心满了进阻塞队列排队 → 队列满了才扩到最大线程 → 还是满就触发拒绝策略

注意一个反直觉的点:线程池不是"任务来了就加线程",而是优先让任务排队。核心线程满 → 队列满 → 才创建新线程到最大线程数。很多人以为 maximumPoolSize 是"并发上限",其实它是"队列也扛不住之后的最后防线"。完整决策树、拒绝策略选型和调优公式,见线程池专题篇。

本站要点
  • 线程池提交是生产默认姿势:execute() 无返回值,submit() 返回 Future 可拿结果/异常;
  • 执行顺序一句话:核心线程 → 队列 → 最大线程 → 拒绝策略;
  • 7 大参数、决策树、调优公式,见《线程池 ThreadPoolExecutor 深挖》篇;
  • 别用 Executors 工厂建池,原因下一站展开。
第 8 站

Executors 工厂:方便,但生产禁用

JDK 5 顺手给了 Executors 工具类,一行代码就能建线程池:Executors.newFixedThreadPool(10)。为什么阿里规约强制禁用?因为它把关键参数藏起来了,而藏起来的默认值恰好都是"毒药":

工厂方法队列最大线程隐患
newFixedThreadPool(n)LinkedBlockingQueue(无界n任务无限堆积 → 内存 OOM
newSingleThreadExecutor()LinkedBlockingQueue(无界1同上,单线程串行 + 堆积 OOM
newCachedThreadPool()SynchronousQueueInteger.MAX_VALUE线程无限创建 → 线程爆炸 OOM
newScheduledThreadPool(n)DelayedWorkQueue(无界)Integer.MAX_VALUE延迟任务堆积 + 线程无上限
无界队列 vs 线程爆炸,两条 OOM 路径

无界队列:fixed 线程池核心线程只有 n 个,任务进来全往无界队列里塞。队列是纯内存结构,塞 1000 万个任务就是 1000 万个对象——内存先爆(OOM),而且任务永远得不到执行,还查不出原因。线程爆炸:cached 线程池来一个任务建一个线程,最大线程数是 Integer.MAX_VALUE(约 21 亿),第 1 站那种"10 万线程"事故,用 cached 池一样能复现。

阿里 Java 开发手册原文:【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

最佳实践

生产代码一律 new ThreadPoolExecutor(...) 手动创建,把 7 个参数全部显式写清楚(见第 7 站模板),并用有界队列(如 ArrayBlockingQueue 或指定容量的 LinkedBlockingQueue)+ 明确的拒绝策略,让"队列满"这个状态能被感知、被处理,而不是悄悄堆积到 OOM。

第 9 站

生产细节:线程工厂(ThreadFactory)与线程命名规范

这一站是"面试不会单独考、但生产天天用"的加分项:给线程起名字

想象一下排障现场:线上出问题,你 jstack 一下,看到 200 条 Thread-17Thread-88……完全不知道它们是哪个业务的。而如果线程名叫 order-push-pool-1metrics-collector-2,一眼就能定位是哪个模块在跑什么。线程池的默认线程工厂只会按 pool-1-thread-1 递增命名,所以生产里几乎都要自定义 ThreadFactory

NamedThreadFactory.java · 带业务名的线程工厂
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 一行搞定:

Guava 版本 · 一行命名
import com.google.common.util.concurrent.ThreadFactoryBuilder;

ThreadFactory named = new ThreadFactoryBuilder()
    .setNameFormat("order-push-%d")   // %d 会自动替换为序号
    .setDaemon(false)
    .build();
为什么命名这么重要
  • 排障:jstack / Arthas 一眼认出线程属于哪个业务,不用猜;
  • 监控:按线程名前缀聚合指标,能看清每个池的活跃线程数;
  • 协作:别人接手你的代码,看到线程名就懂了这个池的用途。
第 10 站

方式五:CompletableFuture —— runAsync 与 supplyAsync

JDK 8 引入的 CompletableFutureFuture 的全面升级:非阻塞、可链式组合、支持回调(Callback)。作为"创建异步任务"的方式,它提供了两个静态工厂方法,对应"要不要返回值"两种需求:

方法入参返回场景
runAsync(Runnable)Runnable(无返回值)CompletableFuture<Void>只执行不关心结果:发日志、埋点、异步刷缓存
supplyAsync(Supplier<U>)Supplier<U>(有返回值)CompletableFuture<U>异步计算要拿结果:并行查询、异步汇总

两个方法都有带线程池的重载:runAsync(task, executor)supplyAsync(task, executor)。默认不传时用的是 ForkJoinPool.commonPool()。先看基本用法:

CompletableFutureDemo.java · runAsync 与 supplyAsync
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());
runAsync 和 supplyAsync 怎么选?
一句话选型

跟 Runnable / Callable 的选择同一个道理:需要返回值或后续要"接着算",用 supplyAsync;只是"干一件事、不关心产出",用 runAsync。另外注意一个细节:无论哪个,默认线程池都是 ForkJoinPool.commonPool()——它是全 JVM 共享的公共池,并行度默认只有"CPU 核数 - 1",一旦被某个阻塞任务占满,会影响所有用它的异步代码。所以生产环境必须传自定义线程池,这一点第 12 站会演示。

第 11 站

CompletableFuture 强在哪:回调与链式编排

前面说 Future 有三大局限,CompletableFuture 逐个击破。核心思想就一句话:别用 get() 阻塞等着,把"结果到了之后干什么"提前注册好——这叫回调(Callback)。

ChainDemo.java · 链式编排(非阻塞)
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 的本质差异:

特性FutureCompletableFuture
获取结果get() 阻塞等待thenAccept() 回调,非阻塞
任务组合不支持,需手动嵌套thenApply / thenCompose / thenCombine
异常处理只能 try-catch get()exceptionally / handle 回调
多任务编排需 CountDownLatch 等辅助allOf / anyOf 原生支持
生产铁律
supplyAsync / runAsync 默认使用 ForkJoinPool.commonPool(),生产必须传自定义线程池
CompletableFuture.supplyAsync(() -> doWork(), myExecutor);
第 12 站

一个需求两种写法:Future 时代 vs CompletableFuture 时代

用一个真实常见的需求串起全文:商品详情页要并行查询价格、库存、评价三个数据源,最后汇总展示,并且整体不能超过 3 秒

先看"Future 时代"的写法(JDK 5 风格,线程池 submit + get 超时):

FutureStyle.java · 线程池 + Future
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 风格):

CompletableStyle.java · CompletableFuture 并行 + 汇总 + 超时
// 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。
第 13 站

五种方式全景对比:一张表回答面试题

把五种方式放一起,按面试官最关心的维度对比:

方式 能否返回值 能否抛受检异常 是否复用线程 适用场景 引入版本
① 继承 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)
第 14 站

选型决策树:一张图看清怎么选

把上面的口诀画成决策树,面试时照着这个思路说,逻辑就是完整的:

线程创建方式选型决策图 需要创建线程? 需要返回值或异步编排? 高并发 / 大量任务? Runnable + Lambda 简单场景 / Demo ThreadPoolExecutor 生产标配 / 资源可控 需要链式组合? Callable + 线程池 需要返回值,简单异步 CompletableFuture 现代异步 / 链式编排 / 回调 extends Thread 单继承限制,不推荐
图 2根据是否需要返回值、是否高并发、是否需要链式组合来选择线程创建方式

决策逻辑一句话

  • 不需要返回值 → 看并发量:低并发简单任务用 Runnable,生产场景一律线程池 execute;
  • 需要返回值 → 看编排复杂度:简单"提交-等待"用 Callable + 线程池 submit,复杂链式编排用 CompletableFuture;
  • 继承 Thread 全程淘汰。
第 15 站

面试 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);生产都要传自定义线程池
第 16 站

番外:JDK 21 虚拟线程 —— 线程创建的新生态

2023 年 9 月,JDK 21(LTS)正式发布了虚拟线程(Virtual Thread,Project Loom 的成果),这是"线程创建"这个主题上十年一遇的大变化,面试聊到前沿可以加分。

前面说过:平台线程(Platform Thread)与操作系统线程 1:1,所以创建 10 万线程会把机器打爆。虚拟线程的思路是让 JVM 自己调度海量轻量级线程,挂载在少量平台线程(载体线程)上——创建开销极小,百万级虚拟线程是常态。创建方式:

VirtualThreadDemo.java · JDK 21 虚拟线程
// 方式一:工厂方法,等价于"每条虚拟线程跑一个 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 · 评论