Lesson 10 · 并发编程
线程创建五种方式:Thread、Runnable、Callable、线程池、CompletableFuture
从一道高频面试题开始
面试官放下简历,抬起头问你:
大多数候选人会脱口而出:"两种!继承 Thread 类和实现 Runnable 接口。"好一点的会补上 Callable。面试官微微点头,然后在心里给你打了个"初级"标签。
但实际上,Java 中创建线程有 5 种生产级方式,从最早的 extends Thread 到现代的 CompletableFuture.supplyAsync,跨越了 JDK 1.0 到 JDK 8+ 的演进。面试官想听到的不是数字,而是你对每种方式适用场景、局限性、工程选型的理解。
这篇文章,我们从最基础的方式讲起,一路走到生产环境的最佳实践。读完之后,你能回答:
- 为什么企业代码里几乎看不到
extends Thread? - Callable 和 Runnable 的本质区别是什么?
- 线程池提交和直接 new Thread 有什么根本差异?
- CompletableFuture 比 Future 强在哪里?
方式一:继承 Thread 类
这是教科书上的第一种方式——继承 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() 会调用 native 方法让操作系统创建新线程,然后在新线程中执行 run()。直接调用 run() 只是在当前线程同步执行,没有创建新线程。这是初学者最常犯的错误。
这种方式简单直接,但有一个致命缺陷——Java 是单继承。一旦继承了 Thread,你的类就不能再继承其他类了。在实际项目中,这几乎不可接受。
- 单继承限制:无法再继承业务基类
- 耦合度高:线程执行逻辑与 Thread 类绑定在一起
- 无法返回结果:
run()返回 void - 无法抛出受检异常:
run()签名不允许 throws
方式二:实现 Runnable 接口
为了解决单继承的问题,Java 提供了 Runnable 接口。它只有一个方法 run(),将"要做什么"和"怎么做线程"解耦:
public class MyTask implements Runnable {
@Override
public void run() {
System.out.println("Runnable 任务运行中: " + Thread.currentThread().getName());
}
}
// 创建并启动线程
Thread t = new Thread(new MyTask());
t.start();
// Lambda 简写(JDK 8+)
Thread t2 = new Thread(() -> System.out.println("Lambda 写法"));
t2.start();
Runnable 优于继承 Thread 的三个原因:
1. 解耦:任务逻辑(Runnable)与线程机制(Thread)分离
2. 灵活:实现接口的同时还可以继承其他类
3. 共享:同一个 Runnable 实例可传给多个 Thread,实现任务共享
但 Runnable 仍然有两个硬伤:没有返回值(run() 是 void),也无法抛出受检异常。如果你的任务需要返回计算结果,就需要第三种方式。
方式三:Callable + Future
JDK 5 引入了 Callable<V> 接口,解决了 Runnable 的两大痛点:有返回值、可抛异常。
import java.util.concurrent.*;
// 1. 实现 Callable 接口,指定返回类型
public class CalcTask implements Callable<Integer> {
@Override
public Integer call() throws Exception {
// 模拟耗时计算
Thread.sleep(2000);
return 42;
}
}
// 2. 用 FutureTask 包装 Callable
FutureTask<Integer> futureTask = new FutureTask<>(new CalcTask());
// 3. 传给 Thread 并启动
new Thread(futureTask).start();
// 4. 阻塞等待结果
Integer result = futureTask.get(); // 阻塞当前线程,直到计算完成
System.out.println("计算结果: " + result);
核心关系链是:Callable 定义任务 → FutureTask 包装它(FutureTask 实现了 Runnable) → 传给 Thread → get() 阻塞拿结果。
Future.get() 是阻塞调用——线程没算完,调用方就一直等。此外还可以通过 get(timeout, unit) 设置超时,避免无限等待。
- 阻塞获取结果:只能
get()傻等,无法注册回调 - 无法组合:多个异步任务的串联(A 完成后用结果启动 B)需要手动嵌套
- 取消困难:
cancel()只是设置标志位,不保证线程能停下来
这些痛点催生了线程池和 CompletableFuture。
方式四:线程池提交(生产标准)
前三种方式都是手动 new Thread——每来一个任务就创建一个线程,用完就销毁。线程的创建和销毁是昂贵的(涉及操作系统调用、内存分配),在并发量大时会成为性能瓶颈。
线程池的思路是:提前创建好一组线程,任务来了直接提交给线程池复用已有线程。这是企业项目的标准做法。
import java.util.concurrent.*;
// 创建自定义线程池(不要用 Executors 工具类,后面会讲为什么)
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(100), // 任务队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
// 提交 Runnable(无返回值)
pool.execute(() -> System.out.println("Runnable 任务"));
// 提交 Callable(有返回值)
Future<String> future = pool.submit(() -> {
Thread.sleep(1000);
return "线程池返回的结果";
});
System.out.println(future.get());
// 用完关闭
pool.shutdown();
newFixedThreadPool/newSingleThreadPool:队列长度为Integer.MAX_VALUE,任务堆积时 OOMnewCachedThreadPool:最大线程数为Integer.MAX_VALUE,可能创建过多线程导致 OOMnewScheduledThreadPool:延迟任务队列无界,同样有 OOM 风险- 阿里 Java 开发手册明确要求:使用 ThreadPoolExecutor 手动创建线程池
方式五:CompletableFuture.supplyAsync
JDK 8 引入了 CompletableFuture,它是 Future 的全面升级:非阻塞、可链式组合、支持回调。
import java.util.concurrent.*;
// 1. 基本用法:supplyAsync 提交有返回值的任务
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
// 默认在 ForkJoinPool.commonPool() 中执行
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return "查询完成";
});
// 2. 链式处理:拿到结果后继续转换(非阻塞)
cf.thenApply(result -> result + ",已处理")
.thenAccept(finalResult -> System.out.println(finalResult))
.exceptionally(ex -> { System.out.println("出错了: " + ex); return null; });
// 3. 组合多个异步任务
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
CompletableFuture 与 Future 的核心对比:
| 特性 | Future | CompletableFuture |
|---|---|---|
| 获取结果 | get() 阻塞等待 | thenAccept() 回调,非阻塞 |
| 任务组合 | 不支持,需手动嵌套 | thenApply / thenCompose / thenCombine |
| 异常处理 | 只能 try-catch get() | exceptionally / handle 回调 |
| 多任务编排 | 需 CountDownLatch 等辅助 | allOf / anyOf 原生支持 |
supplyAsync 默认使用 ForkJoinPool.commonPool(),生产环境应传入自定义线程池:CompletableFuture.supplyAsync(() -> doWork(), myExecutor);
五种方式全景对比
让我们用一张表把五种方式放在一起看:
| 方式 | 返回值 | 抛异常 | 可复用 | 生产可用 | 引入版本 |
|---|---|---|---|---|---|
| 继承 Thread | 否 | 否 | 否(单继承) | 不推荐 | JDK 1.0 |
| 实现 Runnable | 否 | 否 | 是 | 简单场景 | JDK 1.0 |
| Callable + Future | 是 | 是 | 是 | 需返回值时 | JDK 5 |
| 线程池提交 | 可选 | 可选 | 是 | 推荐 | JDK 5 |
| CompletableFuture | 是 | 回调处理 | 是 | 强烈推荐 | JDK 8 |
面试回答模板
Q:Java 有几种创建线程的方式?
"严格来说有五种常用方式。最基础的是继承 Thread 类和实现 Runnable 接口,但 Thread 受限于单继承,所以实际开发中更倾向 Runnable。JDK 5 引入 Callable + Future,可以获取返回值和异常。生产环境中,我们通常通过 ThreadPoolExecutor 提交任务,避免反复创建销毁线程。JDK 8 之后推荐用 CompletableFuture.supplyAsync,它支持链式调用和非阻塞回调,适合复杂的异步编排场景。总结就是:简单任务用 Runnable,生产环境用线程池,复杂异步用 CompletableFuture。"
- 继承 Thread:最简单但受单继承限制,实际项目不推荐
- Runnable:解耦任务与线程,Lambda 写法简洁,适合简单场景
- Callable + Future:支持返回值和异常,
get()阻塞等待 - 线程池:生产标准做法,复用线程、控制并发、防止 OOM
- CompletableFuture:JDK 8 引入,非阻塞链式组合,现代异步编程首选