Java 线程模型:执行单元如何被抽象
Java 线程到底是什么?它和操作系统线程是什么关系?为什么不能无限制地创建线程?从
Thread到CompletableFuture,创建线程的方式经历了怎样的演进?线程在 JVM 内部有哪些状态,它们之间如何转换?本章将逐一拆解这些问题,帮你建立对 Java 线程模型的完整认知。
1. 线程是什么
1.1 进程与线程:两个维度的"执行"
在操作系统中,进程(Process) 和 线程(Thread) 是两个经常被混淆但本质不同的概念。
| 维度 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本执行单位 |
| 内存空间 | 独立的虚拟地址空间 | 共享所属进程的地址空间 |
| 通信方式 | IPC(管道、Socket、共享内存等) | 直接读写共享变量 |
| 创建开销 | 大(需要分配独立地址空间、页表等) | 小(只需分配栈和少量元数据) |
| 崩溃影响 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程终止 |
简单来说:进程是"资源容器",线程是"执行流"。一个进程可以包含多个线程,它们共享进程的内存空间、文件描述符等资源,但各自拥有独立的程序计数器(PC)、栈和寄存器状态。
1.2 为什么需要线程而非多进程
用多进程也能实现"同时做多件事",为什么还要引入线程?核心原因有三:
- 共享数据更方便:线程天然共享堆内存,不需要 IPC 那套复杂机制
- 创建和切换开销更小:线程创建和上下文切换通常比进程轻量
- 响应性更好:GUI 程序中,后台线程处理耗时任务,UI 线程保持响应
当然,共享也是一把双刃剑——多个线程同时读写共享变量,正是并发 bug 的根源。这一点我们在第 4 章(JMM)和后续章节中会详细讨论。
2. Java Thread 的本质
2.1 1:1 线程模型
Java 的线程模型经历过多次演变,但自 HotSpot JVM 成为主流以来,采用的是 1:1 线程模型:
Java Thread 对象 ──1:1──► Native Thread (OS 线程) ──► OS Scheduler ──► CPU Core在 HotSpot 的传统平台线程模型中,一个已经启动的平台线程通常对应一个操作系统原生线程。创建 Thread 对象本身不会立即创建原生线程,调用 start() 后 JVM 才会启动对应的执行线程。这个原生线程由操作系统的调度器管理,最终被分配到某个 CPU 核心上执行。
Thread t = new Thread(() -> System.out.println("Hello"));
t.start(); // JVM 调用 pthread_create() (Linux) 或 CreateThread() (Windows)这意味着:
- Java 线程的调度策略由操作系统决定(通常是时间片轮转 + 优先级调度)
- Java 线程的并发度受 OS 线程数限制
- 线程发生阻塞、唤醒或调度切换时可能涉及内核态操作,具体成本取决于触发原因和操作系统实现
历史注脚:早期的 JVM(如 Green Thread)采用 N:1 模型——多个 Java 线程映射到一个 OS 线程,由 JVM 自行调度。N:1 模型的局限在于多个用户态线程共享一个 OS 线程,无法利用多个核心同时执行,且底层线程阻塞时同一线程上的其他任务也可能停滞。现代通用 Java 平台因此采用平台线程与 OS 线程一对一映射的模型。Go 语言的 goroutine 采用 M:N 模型,由 Go 运行时在少量 OS 线程上调度大量 goroutine,这在轻量级并发场景下有明显优势。
2.2 为什么不能无限制创建线程
1:1 模型带来一个直接后果:Java 线程的规模上限,由操作系统对 OS 线程的资源约束决定。
每个 OS 线程都需要分配独立的栈空间。下面列出一个 64 位 Linux 环境中常见的配置项,具体值由系统管理员和 JVM 参数决定:
| 资源 | 示例值 | 说明 |
|---|---|---|
| 线程栈大小 | 1 MB(估算示例,可通过 -Xss 调整) | 平台线程独占,默认值随 JVM 和平台变化 |
/proc/sys/kernel/threads-max | 系统级上限 | 系统范围任务数限制之一 |
/proc/sys/vm/max_map_count | 约 65530(常见配置) | 限制单进程内存映射区域数,间接影响线程创建 |
| PID 上限 | /proc/sys/kernel/pid_max | 进程/线程共用 PID 编号空间,不等同于线程数上限 |
按 1 MB 栈参数粗算,1000 个线程约预留 1 GB 栈空间;实际提交量、内核侧 task_struct、内核栈等开销需要分别测量。
// 演示线程创建的极限
public class ThreadLimit {
public static void main(String[] args) {
int count = 0;
try {
while (true) {
new Thread(() -> {
try { Thread.sleep(Long.MAX_VALUE); }
catch (InterruptedException e) {}
}).start();
count++;
}
} catch (OutOfMemoryError e) {
System.out.println("最多创建 " + count + " 个线程");
}
}
}
// 可创建的线程数取决于 JVM、操作系统、容器限制及其他资源,
// 不要把某次测试得到的数量当作生产环境容量指标。这就是为什么在高并发场景下,需要线程池(ExecutorService)来复用线程,而不是为每个任务创建新线程。线程池的内容详见第 11 章。
而 1:1 模型的另一条突围路径,是从根本上改变线程与 OS 资源的绑定关系——这就是 Java 21 引入 Virtual Thread 的动机。
2.3 Virtual Thread:JDK 21+ 的新选择
JDK 21 正式引入的 Virtual Thread(虚拟线程) 是 Java 线程模型的重大变革。它采用 M:N 模型——大量虚拟线程被映射到少量平台线程(Platform Thread,即传统的 OS 线程)上:
┌─────────────────────────────────────────────────┐
│ Virtual Threads (M) │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ VT1 │ │ VT2 │ │ VT3 │ │ VT4 │ │ VT5 │ ... │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │ │
│ └───────┴───────┼───────┴───────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ Scheduler │ │
│ └──────┬──────┘ │
│ ┌────────────────┼────────────────┐ │
│ │ │ │ │
│ ┌─┴──┐ ┌──┴──┐ ┌──┴──┐ │
│ │ PT1│ │ PT2 │ │ PT3 │ │
│ │(OS)│ │(OS) │ │(OS) │ │
│ └────┘ └─────┘ └─────┘ │
└─────────────────────────────────────────────────┘虚拟线程的调用栈采用可增长的栈块(stack chunks)管理,栈块存储在 Java 堆中,不需要像平台线程那样为每个线程预留固定大小的原生线程栈。虚拟线程执行时会挂载到某个平台线程上;遇到支持卸载的阻塞操作时,JVM 保存其执行状态并卸载,让载体线程继续执行其他虚拟线程。它的调度由 JVM 内部的调度器完成,默认基于 ForkJoinPool 实现。创建一个虚拟线程的开销通常远低于平台线程,可以创建远多于平台线程的数量,实际数量受堆内存、任务状态和系统资源限制。
但不能把所有阻塞都理解为一定会卸载。JDK 21 中,在特定 synchronized 代码路径或本地方法调用期间阻塞,可能发生 pinning,使虚拟线程继续占用载体线程。虚拟线程的性能优势取决于阻塞方式、载体线程资源和下游服务容量,不是只取决于虚拟线程数量。
// 创建虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// 模拟阻塞 I/O
Thread.sleep(Duration.ofSeconds(1));
return "done";
});
}
} // 10 万个虚拟线程,但只用了少量平台线程| 维度 | Platform Thread(传统) | Virtual Thread(JDK 21+) |
|---|---|---|
| 映射关系 | 1:1(Java Thread → OS Thread) | M:N(多对少) |
| 栈内存 | OS 分配,默认 1MB | 堆内存,按需增长(初始几KB) |
| 创建开销 | 通常较高,涉及内核与栈资源 | 通常较低,具体随实现和任务形态变化 |
| 调度 | OS 内核调度器 | JVM 内部 ForkJoinPool |
| 适用场景 | CPU 密集型任务 | I/O 密集型任务(Web 服务、数据库查询) |
注意:虚拟线程并非万能药。对于 CPU 密集型任务,虚拟线程没有优势(因为平台线程数 ≈ CPU 核心数已经是最优配置)。虚拟线程的价值在于 I/O 密集型场景——用少量 OS 线程支撑海量并发连接。虚拟线程机制与 pinning 陷阱详见第 13 章,迁移和结构化并发详见第 14 章。
3. 线程创建与异步任务表达的演进
Java 既可以直接创建 Thread,也可以只定义任务并交给执行器。下面四种方式展示了从“任务与线程绑定”到“任务与执行资源解耦”的演进,后两种并不直接创建线程。
3.1 继承 Thread(JDK 1.0)
最原始的方式,直接继承 Thread 类并重写 run() 方法:
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread running: " + Thread.currentThread().getName());
}
}
// 启动
new MyThread().start();局限:Java 只支持单继承,一旦继承了 Thread,就无法再继承其他类。这在实际项目中非常受限。
3.2 实现 Runnable(JDK 1.0)
将"要执行的任务"从"执行线程"中分离出来:
Runnable task = () -> System.out.println("Runnable running");
new Thread(task).start();优势:接口可以多实现,任务与线程解耦,可以被线程池复用。 局限:没有返回值,无法声明受检异常。
3.3 Callable + Future(JDK 5.0)
java.util.concurrent 包引入了 Callable<V> 接口和 Future<V> 接口,解决了返回值和异常的问题:
Callable<Integer> callable = () -> {
// 模拟耗时计算
Thread.sleep(1000);
return 42;
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(callable);
// get() 会阻塞直到结果就绪
try {
Integer result = future.get(); // 阻塞
System.out.println("结果: " + result);
} catch (ExecutionException e) {
System.err.println("任务执行异常: " + e.getCause());
} finally {
executor.shutdown();
}优势:有返回值、可抛异常、可取消。 局限:Future.get() 是阻塞的,不支持链式组合,无法表达"任务 A 完成后执行任务 B"这种依赖关系。
Callable 只是任务定义,不会创建线程;真正决定它在哪个线程运行的是提交目标,例如示例中的 ExecutorService。
3.4 CompletableFuture(JDK 8.0)
JDK 8 引入的 CompletableFuture 是对 Future 的全面增强,支持链式调用、组合、异常处理:
CompletableFuture 用于表达单值结果的组合和依赖关系,也不直接创建线程。supplyAsync()、runAsync() 及 *Async 方法不传执行器时通常使用 ForkJoinPool.commonPool(),但非 Async 的后续阶段(如 thenApply())不保证切换线程,可能直接在前驱任务的线程中执行。生产代码应按任务类型显式选择执行资源。
CompletableFuture.supplyAsync(() -> {
// 异步查询用户信息
return queryUser(userId);
}).thenApply(user -> {
// 前一阶段完成后转换结果
return queryOrders(user.getId());
}).thenAccept(orders -> {
// 消费最终结果
orders.forEach(System.out::println);
}).exceptionally(ex -> {
// 任意阶段异常都会被捕获
System.err.println("出错: " + ex.getMessage());
return null;
});3.5 四种执行与编排方式对比
| 方式 | 引入版本 | 返回值 | 异常声明 | 可组合性 | 可交给执行器 | 适用场景 |
|---|---|---|---|---|---|---|
Thread | 1.0 | ❌ | ❌ | ❌ | ❌ | 简单原型 |
Runnable | 1.0 | ❌ | ❌ | ❌ | ✅ | 无返回值的任务 |
Callable+Future | 5.0 | ✅ | ✅ | ❌ | ✅ | 需要返回值的一次性任务 |
CompletableFuture | 8.0 | ✅ | ✅ | ✅ | ✅ | 异步编排、流水线 |
选择方向:需要链式组合、超时、取消或异步汇聚时优先评估
CompletableFuture;单个结果等待可使用Future;没有返回值的批量任务可用Runnable;直接Thread主要保留给生命周期完全自管的简单场景。具体选择还要考虑异常传播、背压和执行器边界。
4. 线程生命周期
4.1 六种状态
java.lang.Thread.State 枚举定义了线程的六种状态。理解这些状态及其转换,是排查并发问题的基本功。
这张图展示典型状态转换,不是对所有唤醒路径的完整描述。Object.wait() 返回前需要重新获得监视器锁,可能先经过 BLOCKED;LockSupport.park() 可能因中断、已有许可或无理由返回而结束等待。线程状态是观察时刻的快照,不能依赖一次读取推断完整执行过程。
| 状态 | 含义 | 典型触发场景 |
|---|---|---|
| NEW | 线程对象已创建,尚未调用 start() | new Thread(runnable) |
| RUNNABLE | 可运行状态(包括正在运行和等待 CPU 调度) | start() 后;从 BLOCKED/WAITING/TIMED_WAITING 恢复 |
| BLOCKED | 等待获取 synchronized 锁 | 进入 synchronized 块时锁被其他线程持有 |
| WAITING | 无限期等待,需要其他线程显式唤醒 | Object.wait()、Thread.join()、LockSupport.park() |
| TIMED_WAITING | 有限期等待,超时后自动恢复 | Thread.sleep(ms)、wait(ms)、join(ms) |
| TERMINATED | 线程执行完毕或因异常终止 | run() 正常返回;未捕获的异常 |
4.2 各状态详解
下面的示例通过 Thread.sleep(100) 尝试让目标线程进入某个状态,用于展示典型状态,不保证仅靠固定睡眠时间就能稳定观察到目标状态。需要在测试中验证状态时,应使用同步信号建立时序条件并配合超时控制。
NEW 状态
Thread t = new Thread(() -> {});
System.out.println(t.getState()); // NEW
// 此时线程对象已存在,但操作系统层面还没有对应的原生线程RUNNABLE 状态
t.start();
System.out.println(t.getState()); // RUNNABLE
// 注意:RUNNABLE 不代表"正在运行",而是"可以运行"
// 它包含了传统操作系统中的 Ready 和 Running 两个子状态这里有一个常见的困惑:为什么没有 RUNNING 状态?因为在 JVM 的线程模型中,调度由 OS 内核完成,JVM 无法精确知道线程此刻是在 CPU 上执行还是在等待队列中。所以统一用 RUNNABLE 表示"有能力运行"。
BLOCKED 状态
Object lock = new Object();
Thread t1 = new Thread(() -> {
synchronized (lock) {
try { Thread.sleep(5000); } catch (InterruptedException e) {}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) { // t1 持有锁,t2 进入 BLOCKED
System.out.println("Got lock");
}
});
t1.start();
Thread.sleep(100);
t2.start();
Thread.sleep(100);
System.out.println(t2.getState()); // BLOCKEDWAITING 状态
Thread t = new Thread(() -> {
try {
Thread.currentThread().join(); // 等待自身终止,而它只有结束后才能满足,因此会一直等待
} catch (InterruptedException e) {}
});
t.start();
Thread.sleep(100);
System.out.println(t.getState()); // WAITINGTIMED_WAITING 状态
Thread t = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
});
t.start();
Thread.sleep(100);
System.out.println(t.getState()); // TIMED_WAITINGTERMINATED 状态
Thread t = new Thread(() -> {});
t.start();
t.join(); // 等待 t 结束
System.out.println(t.getState()); // TERMINATED4.3 线程中断机制
中断(Interrupt)是 Java 线程协作的重要机制。它不是强制终止线程,而是一种协作式的通知机制——被中断的线程可以自行决定如何响应。
三个关键 API:
| 方法 | 作用 | 特点 |
|---|---|---|
thread.interrupt() | 设置线程的中断标志为 true | 如果线程在 sleep/wait/join 中,会抛出 InterruptedException 并清除标志 |
thread.isInterrupted() | 查询中断标志 | 不清除标志 |
Thread.interrupted() | 查询当前线程的中断标志并清除 | 静态方法,注意会清除标志 |
// 响应中断的正确模式
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
// 执行任务...
System.out.println("Working...");
Thread.sleep(500);
} catch (InterruptedException e) {
// sleep 被中断时会抛出此异常,同时清除中断标志
System.out.println("收到中断信号,准备退出");
// 重新设置中断标志(可选,取决于是否需要让上层感知)
Thread.currentThread().interrupt();
break;
}
}
System.out.println("线程退出");
});
worker.start();
Thread.sleep(2000);
worker.interrupt(); // 通知 worker 停止
worker.join();
}
}常见陷阱:吞掉
InterruptedException(空 catch 块)是极其常见的错误。这会导致线程无法正确感知中断信号,从而无法优雅地停止。正确的做法要么是向上抛出,要么在 catch 中重新设置中断标志。
4.4 守护线程(Daemon Thread)
Java 线程分为两类:用户线程和守护线程。JVM 会在所有用户线程结束后退出,而不管守护线程是否还在运行。
Thread daemon = new Thread(() -> {
while (true) {
doBackgroundWork(); // 后台监控、日志刷新等
}
});
daemon.setDaemon(true); // 必须在 start() 之前设置
daemon.start();
// 当所有用户线程结束后,这个守护线程会被 JVM 强制终止| 特性 | 用户线程 | 守护线程 |
|---|---|---|
| JVM 退出行为 | 所有用户线程结束后 JVM 才退出 | 不阻止 JVM 退出 |
| 典型用途 | 业务逻辑线程 | 后台辅助任务,如部分监控、维护任务 |
| 资源清理 | 可以安全地清理资源 | 不保证 JVM 退出前有机会完成清理 |
| 默认类型 | new Thread() 默认是用户线程 | 需要显式 setDaemon(true) |
注意: 守护线程创建的子线程默认也是守护线程。JVM 退出时不会等待守护线程完成所有任务,也不保证它们有机会执行完 finally 块或完成资源清理。如果文件关闭、事务提交或连接释放是正确性要求的一部分,应通过明确的生命周期管理和必要的等待来保证执行完成,而不是依赖守护线程的退出行为。
4.5 线程状态的监控工具
在实际开发中,你需要监控线程状态来排查问题。常用工具:
| 工具 | 用途 | 命令/方法 |
|---|---|---|
jstack | 导出线程堆栈,检测死锁 | jstack <pid> |
jconsole | 图形化监控线程状态 | jconsole |
VisualVM | 功能更强大的图形化工具 | jvisualvm |
ThreadMXBean | 编程方式获取线程信息 | ManagementFactory.getThreadMXBean() |
Arthas | 阿里开源的 Java 诊断工具 | thread、thread -b(检测死锁) |
// 编程方式监控线程
ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
long[] threadIds = tmx.getAllThreadIds();
for (long id : threadIds) {
ThreadInfo info = tmx.getThreadInfo(id);
System.out.printf("%s: %s%n", info.getThreadName(), info.getThreadState());
}
// 检测死锁
long[] deadlockedThreads = tmx.findDeadlockedThreads();
if (deadlockedThreads != null) {
System.out.println("检测到死锁!涉及线程: " + Arrays.toString(deadlockedThreads));
}5. 本章小结
本章从操作系统的线程概念出发,梳理了 Java 线程模型的核心知识:
- 线程是 CPU 调度的基本单位,与进程(资源分配的基本单位)有本质区别
- HotSpot 采用 1:1 线程模型,每个 Java 线程对应一个 OS 线程,受 OS 资源限制
- 创建线程的方式不断演进:从
Thread到CompletableFuture,逐步解决组合性、返回值、异常处理等问题 - 线程有六种状态,理解状态转换是排查并发问题的基础
- 中断是协作式的通知机制,不是强制终止
与 Project Loom 的关系:本章介绍的 1:1 线程模型和线程状态机是理解传统 Java 并发的基础。Virtual Thread 改变了线程的调度方式(由 JVM 而非 OS 调度),但 JMM 的规则(第 4 章)、线程安全的编程范式(第 5-10 章)依然完全适用。无论底层是平台线程还是虚拟线程,happens-before 规则不变,
volatile和synchronized的语义不变。
6. 下一步
线程只是执行载体。下一步用ThreadLocal理解线程封闭,再进入Java 内存模型回答线程如何看到彼此的写入;虚拟线程的调度差异见虚拟线程模型与 Pinning,线程状态异常从并发问题诊断开始排查。