Lesson 10 · 并发编程

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

初级·🔥 极高·#并发·#基础

第 1 站

从一道高频面试题开始

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

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

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

但实际上,Java 中创建线程有 5 种生产级方式,从最早的 extends Thread 到现代的 CompletableFuture.supplyAsync,跨越了 JDK 1.0 到 JDK 8+ 的演进。面试官想听到的不是数字,而是你对每种方式适用场景、局限性、工程选型的理解。

这篇文章,我们从最基础的方式讲起,一路走到生产环境的最佳实践。读完之后,你能回答:

  • 为什么企业代码里几乎看不到 extends Thread
  • Callable 和 Runnable 的本质区别是什么?
  • 线程池提交和直接 new Thread 有什么根本差异?
  • CompletableFuture 比 Future 强在哪里?
第 2 站

方式一:继承 Thread 类

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

MyThread.java
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 方法让操作系统创建新线程,然后在新线程中执行 run()。直接调用 run() 只是在当前线程同步执行,没有创建新线程。这是初学者最常犯的错误。

这种方式简单直接,但有一个致命缺陷——Java 是单继承。一旦继承了 Thread,你的类就不能再继承其他类了。在实际项目中,这几乎不可接受。

继承 Thread 的局限
  • 单继承限制:无法再继承业务基类
  • 耦合度高:线程执行逻辑与 Thread 类绑定在一起
  • 无法返回结果run() 返回 void
  • 无法抛出受检异常run() 签名不允许 throws
第 3 站

方式二:实现 Runnable 接口

为了解决单继承的问题,Java 提供了 Runnable 接口。它只有一个方法 run(),将"要做什么"和"怎么做线程"解耦:

MyTask.java
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 的三个原因:

Runnable 的优势
1. 解耦:任务逻辑(Runnable)与线程机制(Thread)分离
2. 灵活:实现接口的同时还可以继承其他类
3. 共享:同一个 Runnable 实例可传给多个 Thread,实现任务共享

但 Runnable 仍然有两个硬伤:没有返回值run() 是 void),也无法抛出受检异常。如果你的任务需要返回计算结果,就需要第三种方式。

第 4 站

方式三:Callable + Future

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

CallableDemo.java
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) → 传给 Threadget() 阻塞拿结果。

为什么不能直接把 Callable 传给 Thread?因为 Thread 的构造函数只接受 Runnable。FutureTask 充当了适配器——它实现了 RunnableFuture 接口,而 RunnableFuture 同时继承了 Runnable 和 Future。

Future.get()阻塞调用——线程没算完,调用方就一直等。此外还可以通过 get(timeout, unit) 设置超时,避免无限等待。

Future 的局限性
  • 阻塞获取结果:只能 get() 傻等,无法注册回调
  • 无法组合:多个异步任务的串联(A 完成后用结果启动 B)需要手动嵌套
  • 取消困难cancel() 只是设置标志位,不保证线程能停下来

这些痛点催生了线程池和 CompletableFuture。

第 5 站

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

前三种方式都是手动 new Thread——每来一个任务就创建一个线程,用完就销毁。线程的创建和销毁是昂贵的(涉及操作系统调用、内存分配),在并发量大时会成为性能瓶颈。

线程池的思路是:提前创建好一组线程,任务来了直接提交给线程池复用已有线程。这是企业项目的标准做法。

ThreadPoolDemo.java
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();
为什么禁用 Executors 工具类
  • newFixedThreadPool / newSingleThreadPool:队列长度为 Integer.MAX_VALUE,任务堆积时 OOM
  • newCachedThreadPool:最大线程数为 Integer.MAX_VALUE,可能创建过多线程导致 OOM
  • newScheduledThreadPool:延迟任务队列无界,同样有 OOM 风险
  • 阿里 Java 开发手册明确要求:使用 ThreadPoolExecutor 手动创建线程池
第 6 站

方式五:CompletableFuture.supplyAsync

JDK 8 引入了 CompletableFuture,它是 Future 的全面升级:非阻塞、可链式组合、支持回调

CompletableFutureDemo.java
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

CompletableFutureFuture 的核心对比:

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

五种方式全景对比

让我们用一张表把五种方式放在一起看:

方式 返回值 抛异常 可复用 生产可用 引入版本
继承 Thread 否(单继承) 不推荐 JDK 1.0
实现 Runnable 简单场景 JDK 1.0
Callable + Future 需返回值时 JDK 5
线程池提交 可选 可选 推荐 JDK 5
CompletableFuture 回调处理 强烈推荐 JDK 8
线程创建方式选型决策图 需要创建线程? 需要返回值或异步编排? 高并发 / 大量任务? Runnable + Lambda 简单场景 / Demo ThreadPoolExecutor 生产标配 / 资源可控 需要链式组合? Callable + 线程池 需要返回值,简单异步 CompletableFuture 现代异步 / 链式编排 / 回调 extends Thread 单继承限制,不推荐
图 1根据是否需要返回值、是否高并发、是否需要链式组合来选择线程创建方式

面试回答模板

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 引入,非阻塞链式组合,现代异步编程首选