创建线程有哪几种方式?Runnable 和 Callable 有什么区别?
简化版
创建线程本质上就两种:继承 Thread 或 实现 Runnable/Callable 再交给线程执行,其中实现接口更好(Java 单继承,实现接口更灵活)。Runnable 和 Callable 的区别:Runnable 的 run() 无返回值、不能抛受检异常;Callable 的 call() 有返回值、能抛异常,配合 Future 拿结果。实际开发不手动 new Thread,用线程池。
详细版
常见的几种「创建线程」方式:
// ① 继承 Thread,重写 run
class MyThread extends Thread {
public void run() { }
}
new MyThread().start();
// ② 实现 Runnable,传给 Thread(推荐,因为不占用单继承)
new Thread(() -> System.out.println("run")).start();
// ③ 实现 Callable,配合 FutureTask 拿返回值
FutureTask<Integer> task = new FutureTask<>(() -> 1 + 1);
new Thread(task).start();
System.out.println(task.get()); // 拿到返回值 2
// ④ 用线程池(实际开发首选)
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(() -> 1 + 1);
严格说,真正「创建线程」的方式只有继承 Thread 和 实现 Runnable 两类;Callable、线程池、
FutureTask本质都是对 Runnable/Thread 的封装。但面试通常把它们都列出来。
Runnable vs Callable:
| 维度 | Runnable | Callable |
|---|---|---|
| 方法 | run() | call() |
| 返回值 | 无(void) | 有(泛型 V) |
| 抛受检异常 | 不能直接抛出 | 可以抛出 |
| 出现版本 | JDK 1.0 | JDK 5(配合 Future) |
完整版教学
一、为什么「实现接口」优于「继承 Thread」
extends Thread 有个硬伤:Java 单继承,继承了 Thread 就不能再继承别的类了,太浪费。而 implements Runnable 只是实现一个接口,类还能继承别的、实现别的接口,更灵活。
更深一层:继承 Thread 是「类本身就是一个线程」,实现 Runnable 是「任务和执行分离」——Runnable 描述「要做什么任务」,Thread/线程池负责「用哪个线程去跑」。任务和执行解耦,才有了线程池复用线程、同一任务提交给不同执行器的灵活性。所以优先实现 Runnable/Callable。
二、Runnable 和 Callable 的核心差异:能不能拿结果
Runnable.run() 返回 void,还不能往外抛受检异常——它适合「干完就完、不关心结果」的任务。
Callable.call() 有返回值、能抛异常——它适合「要拿计算结果」的任务。但线程是异步执行的,结果不会立刻有,所以要配合 Future:
Future<Integer> future = pool.submit(() -> {
Thread.sleep(1000);
return 42;
});
// future.get() 会阻塞,直到任务完成拿到结果(或超时/异常)
Integer result = future.get();
Future 是「未来的结果」的凭证:get() 拿结果(会阻塞等待)、isDone() 查是否完成、cancel() 取消。这就是 Callable 相比 Runnable 多出来的「有返回、可获取、可感知异常」的能力。
三、FutureTask:连接 Runnable 和 Callable 的桥梁
FutureTask 很巧妙——它同时实现了 Runnable 和 Future:
- 作为 Runnable,能被 Thread 或线程池执行;
- 作为 Future,能
get()拿到里面 Callable 的返回值。
所以「用 Thread 执行 Callable」得靠它包一层:new Thread(new FutureTask<>(callable))。它内部把 call() 的返回值存起来,供 get() 取用。
四、实际开发:别手动 new Thread
上面的方式主要用于理解原理,生产代码几乎不直接 new Thread(),而是用线程池(ThreadPoolExecutor):
- 复用线程,避免频繁创建销毁的开销;
- 控制并发数,防止无限创建线程耗尽资源;
- 统一管理任务队列、拒绝策略、监控。
线程池的 execute(Runnable) 执行无返回任务,submit(Runnable/Callable) 返回 Future 拿结果(详见「线程池的核心参数」那道题)。阿里规范也明确要求线程资源必须通过线程池提供、禁止显式 new Thread。
补充:Java 21 的虚拟线程又提供了「一请求一(虚拟)线程」的新模型,适合海量 IO 并发(详见「虚拟线程」那道题)。
五、平台线程、线程池与虚拟线程怎样选
Thread、Runnable、Callable 描述的是执行载体与任务抽象;工程选型还要考虑平台线程和 JDK 21 正式提供的虚拟线程。平台线程通常映射操作系统线程,数量较大时栈内存和调度成本明显;虚拟线程由 JVM 调度,适合拥有大量“阻塞等待型、彼此独立”的任务。
假设服务同时有 10,000 个请求,每个请求计算 5 ms、等待数据库 195 ms,单任务约 97.5% 时间在等待。少量平台线程会让请求在池中排队,虚拟线程可以让“一请求一线程”的同步代码获得更高并发度,但数据库连接池仍可能只有 100 个连接,不能因此绕过下游容量。
| 场景 | 常见选择 | 关键边界 |
|---|---|---|
| 固定 CPU 密集计算 | 有界平台线程池 | 并发度接近可用核心数并压测 |
| 大量独立阻塞 I/O | 虚拟线程或异步模型 | 下游连接、限流仍需有界 |
| 需要返回结果 | Callable/Future、CompletableFuture | 处理异常、取消和超时 |
| 长生命周期后台线程 | 明确管理的平台线程 | 退出、监控与关闭策略 |
虚拟线程不让 CPU 计算变快,也不应该放进传统固定大小池做人为复用。遇到会 pin 载体线程的场景或本地调用,还要结合目标 JDK 的诊断结果评估。
六、任务取消与异常传播不能忽略
Future.get() 会把 Callable 抛出的异常包装在 ExecutionException 中;cancel(true) 只是请求通过中断取消,任务必须正确响应中断才会及时退出。直接启动线程时,未捕获异常可交给 UncaughtExceptionHandler;在线程池中使用 submit 时,异常通常保存在 Future 中,如果从不 get() 就可能被忽略。
Future<Integer> future = executor.submit(task);
try {
return future.get(200, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw e;
}
这段代码给等待设置 200 ms 上限,但取消是否成功取决于任务是否处理中断和底层 I/O 是否可中断。生产代码还要在 finally 或生命周期钩子中关闭执行器。
记忆钩子:创建线程只是“怎么跑起来”,完整并发设计还必须回答谁管理、怎样限流、结果怎么拿、异常去哪、超时如何取消以及进程如何关闭。
七、常见误区与追问
- 误区:直接调用
run()会创建新线程。run()只是当前线程中的普通方法调用,start()才请求 JVM 启动新线程并回调run()。 - 误区:Callable 可以直接传给 Thread 构造器。 Thread 直接接受 Runnable;Callable 可由 FutureTask 适配,或提交给 ExecutorService。
- 误区:虚拟线程越多,数据库吞吐就一定越高。 它降低线程等待成本,不会扩大连接池、数据库 CPU 或外部服务容量。
- 追问:Runnable 与 Callable 的核心区别是什么? Callable 有返回值并可声明受检异常,Runnable 的
run()返回 void 且不能直接声明受检异常。 - 追问:为什么业务代码通常不手动
new Thread? 执行器或虚拟线程工厂更便于统一限流、命名、监控、异常和关闭策略。 - 追问:
cancel(true)能强制杀死任务吗? 不能,它主要发送中断请求;任务若吞掉中断或阻塞点不可中断,仍可能继续执行。 - 追问:线程能调用两次
start()吗? 不能,同一 Thread 实例再次启动会抛IllegalThreadStateException。
八、加强记忆
把这题按“任务、执行、结果、生命周期”串联:Runnable 和 Callable 描述任务,Thread、线程池或虚拟线程负责执行,Future/FutureTask 承接结果、异常和取消。start() 才创建新的执行路径,直接 run() 不会切换线程;Callable 不能直接交给 Thread,需要适配或执行器。平台线程适合受控并发,虚拟线程适合大量阻塞任务,但两者都必须服从 CPU、连接池和下游容量。工程回答最后补上超时、中断、异常观察与 shutdown,才不是只会背三种创建方式。