创建线程的几种方式

候选人小张在面试快手 P6 时,面试官看了一眼简历上"有丰富多线程开发经验",直接问道:

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

小张不假思索:"三种!继承 Thread、实现 Runnable、实现 Callable。"

面试官追问:"那你用 lambda 表达式创建 Runnable 算哪一种?线程池呢?"

小张愣了一下:"应该算实现 Runnable..."面试官继续:"Thread 的 start() 和 run() 方法有什么区别?为什么必须用 start() 而不是直接调用 run()?"

小张开始擦汗...

一、核心问题:创建线程的几种方式 🔴

1.1 问题拆解

这道题表面上是考 API 背诵,实际上考察的是候选人对"线程是什么"和"方法调用 vs 线程启动"的本质理解。面试官的追问链往往从最简单的入口切入,逐步深入:

第一层:API 列举(怎么用?)
  "Java 中有哪些方式可以创建线程?"
  考察点:能否完整列举,不遗漏线程池

第二层:底层差异(原理对比)
  "Thread、Runnable、Callable 三者有什么区别?"
  考察点:是否有返回值、是否能抛出异常、是否共享 target

第三层:start() vs run()(易错细节)
  "为什么必须调用 start()?直接调用 run() 会怎样?"
  考察点:是否理解线程和普通方法调用的本质差异

第四层:线程池与线程复用(工程实践)
  "线程池创建的线程和直接 new Thread() 有什么区别?"
  考察点:是否有资源复用意识,是否理解线程池工作原理

1.2 ❌ 错误示范

候选人原话 A:"有三种方式:继承 Thread、实现 Runnable、实现 Callable。"

问题诊断:遗漏了线程池、ExecutorService、CompletableFuture、 ForkJoinPool 等实际生产中最重要的方式。只知道学校教的"三种方式",说明没有实际用过线程池。

候选人原话 B:"start() 和 run() 是一样的,都是执行线程。"

问题诊断:大错特错。start() 创建一个新线程并在新线程中执行 run(),而直接调用 run() 是在当前线程中同步执行 run() 方法体。多线程变成了单线程,这个 bug 在生产中极难排查。

候选人原话 C:"实现 Runnable 比继承 Thread 好,因为 Java 是单继承。"

问题诊断:这个理由太教科书了。真正的原因在于:Runnable 实现了逻辑与线程生命周期的解耦——同一个 Runnable 可以被多个 Thread 实例使用,实现了线程间共享目标对象。

1.3 标准回答

P5 级别:准确列举所有方式

Java 中创建线程的方式主要有以下几种:

  1. 继承 Thread 类,重写 run() 方法
  2. 实现 Runnable 接口,重写 run() 方法
  3. 实现 Callable 接口,配合 FutureTask 或线程池
  4. 线程池Executors.newFixedThreadPool()newCachedThreadPool() 等)
  5. 匿名内部类或 lambda 表达式(本质是 Runnable)
  6. ForkJoinPool(JDK 7 引入,适合分治任务)
  7. CompletableFuture(JDK 8 异步编程)

其中,生产环境推荐使用线程池,因为直接 new Thread() 的问题在于:每次创建新线程都有创建/销毁的开销(Thread 对象、栈空间、内核对象),线程数量不可控会耗尽系统资源。

这个回答展示了完整的知识面,已经比大多数候选人强。

P6 级别:深入原理与对比

Thread vs Runnable 的本质区别

// 继承 Thread:每个线程对象是一个独立的执行单元
class MyThread extends Thread {
    @Override
    public void run() {
        // 线程体
    }
}
new MyThread().start();

// 实现 Runnable:逻辑与线程生命周期解耦
class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程体
    }
}
new Thread(new MyRunnable()).start();

Runnable 的优势在于:

  • 可以继承其他类(类结构更灵活)
  • 多个线程可以共享同一个 Runnable 实例(实现"同一任务多人并行")
  • 便于线程池管理

start() vs run() 的核心差异

Thread t = new Thread(() -> System.out.println("Hello"));
t.start();  // 创建新线程,在新线程中异步执行 run()
t.run();    // 在当前线程中同步执行 run(),无新线程创建

start() 底层做了什么?

  1. 检查线程状态(必须是 NEW,否则抛 IllegalThreadStateException
  2. 通过本地方法 registerNatives() 向 JVM 注册线程
  3. JVM 创建 OS 线程(调用 pthread_create()
  4. JVM 调用 Thread.run() 在新线程中执行

Callable vs Runnable 的关键区别

// Runnable 的缺陷:无返回值,不能抛出受检异常
interface Runnable {
    void run();
}

// Callable 的改进:有返回值,支持泛型,能抛出受检异常
interface Callable<V> {
    V call() throws Exception;
}

// FutureTask 是 Runnable 和 Callable 之间的桥梁
Callable<Integer> task = () -> {
    // 计算逻辑
    return 42;
};
FutureTask<Integer> future = new FutureTask<>(task);
new Thread(future).start();
Integer result = future.get();  // 阻塞等待结果

这个回答展示了原理理解和代码对比,已经达到 P6 要求。

P7 级别:线程池与生产实践

生产环境的正确做法——线程池

// 不推荐:直接 new Thread
for (int i = 0; i < 1000; i++) {
    new Thread(() -> doWork()).start();  // 1000 个线程直接爆炸
}

// 推荐:线程池
ExecutorService pool = new ThreadPoolExecutor(
    8,                      // corePoolSize
    16,                     // maximumPoolSize
    60L, TimeUnit.SECONDS, // 空闲线程存活时间
    new LinkedBlockingQueue<>(1000), // 任务队列
    new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);

for (int i = 0; i < 1000; i++) {
    pool.execute(() -> doWork());  // 复用线程,资源可控
}

线程池的本质:线程是稀有资源,创建和销毁成本高(OS 线程的创建涉及内核态调用、栈空间分配、ThreadLocal 初始化)。线程池通过"预创建"和"复用"策略,将线程创建成本均摊到大量任务上。

不同线程池的选择场景

线程池适用场景风险
FixedThreadPool(n)CPU 密集型,线程数固定队列无界可能导致 OOM
CachedThreadPoolI/O 密集型,任务短小线程数无限,高并发下线程爆炸
SingleThreadPool任务需要串行执行单点故障,无并发
ScheduledThreadPool定时任务队列无界风险
ForkJoinPool分治任务(并行归约)工作窃取机制复杂

【面试官心理】 这道题我能问到 P7 级别,是因为创建线程看似简单,但背后涉及 JVM 线程模型、OS 线程创建成本、线程池设计哲学。我最想看到的是候选人有"资源意识"——知道无节制创建线程的危害,知道用线程池管理线程。如果他还能提到 CompletableFuture 替代回调地狱,或者提到 JDK 21 的虚拟线程(Virtual Threads),那说明他有技术前沿的关注。

1.4 追问升级

追问 1:ThreadLocal 在 new Thread() 和线程池中有什么区别?

每个线程有独立的 ThreadLocalMap,Thread.start() 创建新线程时,ThreadLocalMap 是全新的。但在线程池中,线程被复用——上一次任务的 ThreadLocal 值可能残留到下一次任务,导致数据污染。生产中必须在线程复用场景下显式调用 ThreadLocal.remove(),或在 afterExecute() 中清理。

追问 2:为什么线程池不推荐使用 Executors 工厂方法?

阿里的 Java 规范明确禁止使用 Executors.newFixedThreadPool(n)Executors.newCachedThreadPool() 的原因:FixedThreadPoolLinkedBlockingQueue 默认是 Integer.MAX_VALUE,任务堆积会导致 OOM;CachedThreadPoolmaximumPoolSize 无上限,高并发下可能创建数万个线程。应该使用 new ThreadPoolExecutor(...) 显式配置参数。

追问 3:CompletableFuture 是什么?它和 Thread 的关系是什么?

CompletableFuture 是 JDK 8 引入的异步编程工具,不是线程创建工具。它默认使用 ForkJoinPool.commonPool() 执行异步任务(线程数 = CPU 核心数 - 1)。它解决的问题是:回调地狱(多层嵌套)和结果聚合(thenCombinethenCompose)。注意CompletableFuture.supplyAsync() 可以指定 Executor,不指定则用默认线程池。

二、lambda 与匿名内部类的坑 🟡

2.1 lambda 的本质

new Thread(() -> System.out.println("hello")) 实际上是一个只有一个抽象方法的接口(SAM 接口)实例化,编译器将其转换为 invokedynamic 字节码指令,在首次执行时动态生成一个 Runnable 实现类。

关键点:lambda 不创建新的类文件(对比匿名内部类),而是运行时动态生成类,所以 lambda 的性能通常更好(但差异微乎其微)。

2.2 闭包陷阱

// 陷阱:lambda 捕获了外部变量,该变量必须是 effectively final
int count = 0;
for (int i = 0; i < 10; i++) {
    new Thread(() -> System.out.println(count++)).start(); // 编译错误!
}

// 正确做法:使用数组或 AtomicInteger
AtomicInteger count = new AtomicInteger(0);
for (int i = 0; i < 10; i++) {
    new Thread(() -> System.out.println(count.getAndIncrement())).start();
}

三、生产避坑

3.1 线程池配置不当导致服务雪崩

场景:某服务使用 Executors.newCachedThreadPool() 接收外部请求,外部系统故障后请求堆积,线程池无限制创建线程,最终导致 OOM。

根因:高并发 + 无界队列 + 无上限线程数 = 服务爆炸。

正确配置

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    10, 50,
    60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),
    new ThreadPoolExecutor.AbortPolicy()  // 拒绝时抛异常,便于发现
);

3.2 ForkJoinPool 的错误使用

ForkJoinPool 适合"分治后归约"场景(如并行排序),不适合"大量独立短任务"场景。强行用它做普通多线程,效果反而不如普通线程池。

💡

面试加分点:能说出"JDK 21 虚拟线程(Virtual Threads)彻底改变了线程的创建成本。虚拟线程的栈是堆内存分配的(默认 1MB,可动态扩容),创建成本接近普通对象的创建,而不是 OS 线程的创建"。这说明你关注了 JDK 的演进方向。

⚠️

面试陷阱:被问到"start() 能否被调用两次"时,很多候选人会说"可以"。错!第二次调用 start() 会抛出 IllegalThreadStateException,因为线程状态已经从 NEW 变为其他状态。start() 方法中有一行 threadStatus != 0 的检查。