进程、线程、协程与Java虚拟线程
进程、线程、协程与Java虚拟线程
Java 开发者每天都在用线程池、CompletableFuture、@Async,但底层到底是怎么回事?为什么 Java 21 的虚拟线程被称为「革命性」特性?
本文从 OS 层面一路讲回 JVM,用三层递进的方式串起进程、线程、协程,最后深入 Java 虚拟线程。
文章目录
一、进程:操作系统眼中的「程序」
什么是进程?
进程是 OS 资源分配的基本单位。双击一个程序 → OS 创建一个进程。
进程 = 独立的内存空间(代码段 + 数据段 + 堆 + 栈)
+ 系统资源(文件句柄、网络 Socket、信号处理...)
+ 至少一个线程(主线程)
进程的内存布局
高地址 ┌─────────────┐
│ 栈 (Stack) │ ← 函数调用、局部变量、向下增长
│ ↓ │
│ ↑ │
│ 堆 (Heap) │ ← new 出来的对象、malloc,向上增长
├─────────────┤
│ 数据段 (BSS) │ ← 全局/静态变量
├─────────────┤
│ 代码段 (Text)│ ← 编译后的机器指令(只读)
低地址 └─────────────┘
进程的关键特性
| 特性 | 说明 | 影响 |
|---|---|---|
| 内存隔离 | 每个进程独立的虚拟地址空间 | A 进程崩了不连累 B,但通信成本高 |
| 切换代价大 | 切换页表 + 刷新 TLB + 缓存可能失效 | 微秒级 |
| 通信 (IPC) | 管道、消息队列、共享内存、Socket、信号 | 全都很「重」 |
| 数量有限 | 每个进程 GB 级内存 | 一台机器跑几十到几百个 |
比喻:进程 = 独立别墅。每人一栋,自带水电网。邻居着火与你无关,但想串门得先敲大门(IPC)。
二、线程:CPU 眼中的「执行流」
什么是线程?
线程是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享进程的内存空间。
进程 ⊃ 线程1, 线程2, 线程3, ...
共享:堆、全局变量、文件句柄、代码段
独有:栈、寄存器上下文、程序计数器 (PC)
线程 vs 进程
┌─────────────────────────────────────────┐
│ 进程 (独立别墅) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 线程 1 │ │ 线程 2 │ │ 线程 3 │ │
│ │ 私有栈 │ │ 私有栈 │ │ 私有栈 │ │
│ │ PC+寄存器│ │ PC+寄存器│ │ PC+寄存器│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ └───────────┼───────────┘ │
│ ▼ │
│ 共享堆 + 全局变量 + 文件 │
└─────────────────────────────────────────┘
| 维度 | 进程 | 线程 |
|---|---|---|
| 调度者 | OS | OS |
| 内存 | 独立地址空间 (GB) | 共享堆,私有栈 (~1MB) |
| 切换代价 | 大(页表 + TLB + 缓存) | 中(寄存器 + 栈切换) |
| 通信 | IPC(管道/Socket/共享内存) | 共享变量(快但需要同步) |
| 隔离性 | 强(一个崩不影响其他) | 弱(一个线程崩可能带崩进程) |
| 创建销毁 | 慢(fork + 资源分配) | 较快 |
| 数量上限 | 几十~几百 | 几千(受限于栈空间) |
比喻:线程 = 别墅里的室友。共用厨房(堆内存),各睡各的卧室(私有栈)。好处是沟通快(共享变量),坏处是一个室友在厨房纵火(内存越界),大家都遭殃。
内核线程 vs 用户线程
内核线程 (1:1) 用户线程 (N:1) 混合模型 (N:M)
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ T1 │ │ T2 │ │ T1 │ │ T2 │ │ T3 │ │ T1 │ │ T2 │ │ T3 │
└─┬──┘ └─┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ └──────┼──────┘ │ │ │
┌─▼────────▼─┐ │ ┌──────▼──────▼──────▼──────┐
│ 内核线程1 │ ┌───▼───┐ │ 用户态调度器 │
│ 内核线程2 │ │内核线程│ │ ┌─────┐ ┌─────┐ │
└────────────┘ └───────┘ │ │KLT 1│ │KLT 2│ │
└──┴─────┴──┴─────┴───────┘
Java Thread 早期 Green Thread Go goroutine
(1:1,阻塞 = 真阻塞) (一个阻塞 = 全体阻塞) Java Virtual Thread
(阻塞时自动切换)
三、协程:程序员眼中的「暂停与恢复」
什么是协程?
协程是用户态的轻量级执行单元——和线程最关键的区别:协程切换不经过 OS 内核。
线程切换:用户态 → 系统调用 → 内核态 → 保存上下文 → 调度 → 恢复 → 用户态
↑ 微秒级,涉及特权级切换
协程切换:用户态 → 保存少量寄存器 + 栈指针 → 跳到另一个协程
↑ 纳秒级,纯函数调用级别的开销
线程 vs 协程:一张图看懂
线程模型(抢占式): 协程模型(协作式):
线程A 线程B 线程C 协程A 协程B 协程C
│ │ │ │ │ │
│ ✂───┤ │ ← OS 时钟中断 │ ✂───┤ │ ← 自己 yield
│ │ │ 强行切换 │ │ │ 主动让出
├──────┤ ✂───┤ ├──────┤ │
│ │ │ │ ├──────┤
✂──────┼──────┤ │ │ ✂── 自己 yield
│ │ │ │ │ │
| 维度 | 线程 | 协程 |
|---|---|---|
| 调度器 | OS 内核 | 用户态运行时(Go scheduler / JVM / asyncio) |
| 切换代价 | 微秒级(内核态) | 纳秒级(纯用户态) |
| 调度策略 | 抢占式(时间片到期强行切换) | 协作式(自己 yield,不抢) |
| 内存占用 | ~1MB(栈固定分配) | 几 KB(栈动态增长) |
| 创建数量 | 几千 | 几十万~百万 |
| 阻塞影响 | 线程阻塞 = 内核线程也阻塞 | 协程「阻塞」= 挂起自己,调度器执行其他协程 |
| 并发安全 | 需要锁/Mutex/volatile | 协作式调度天然无竞态(同一时刻一个线程只跑一个协程) |
比喻:线程 = 公司雇了 3 个专职员工,老板(OS)决定谁干活谁休息。协程 = 你一个人同时做 3 件事:烧水(协程 A)→ 水没开,切菜(协程 B)→ 等下锅,洗碗(协程 C)→ 切换成本不是「换个人」而是「换个姿势」。
有栈协程 vs 无栈协程
| 有栈协程 (Stackful) | 无栈协程 (Stackless) | |
|---|---|---|
| 代表 | Go goroutine | Kotlin suspend、C++20 协程、JS async/await |
| 实现 | 每个协程有独立栈,可在任意深度切换 | 编译期将函数拆成状态机,只能在 suspend 点切换 |
| 内存 | 每个协程 2~8KB 栈 | 几乎无额外内存(状态机大小) |
| 灵活性 | 高——任意位置可暂停 | 低——只能标记了 suspend 处暂停 |
| 染色问题 | 无(透明) | 有——async 函数会「传染」调用者 |
// 无栈协程的「染色问题」——一个函数标了 suspend,调用者也得标
suspend fun fetchUser(): User { ... }
suspend fun processUser() { fetchUser() } // 传染!
四、Java 虚拟线程:Project Loom 的革命
传统 Java 线程的「阿喀琉斯之踵」
Java 从诞生起,Thread 就是 1:1 映射到 OS 内核线程的。在 Web 服务场景下这成了瓶颈:
// 一个典型的 Web 请求处理
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id) {
// 线程在这里阻塞 99% 的时间!
Order order = orderDao.findById(id); // 等数据库 50ms
User user = userService.getUser(uid); // 等 RPC 100ms
Inventory inv = inventoryService.check(id); // 等 RPC 80ms
return assemble(order, user, inv);
}
// 线程在等,但 OS 内核线程也跟着一起等 → 浪费!
问题链条:
1 个请求占用 1 个线程 → 线程在等 I/O(CPU 空闲)
→ 但线程数有上限(每个线程 ~1MB 栈,8GB 内存约 4000 个)
→ 高并发时线程池耗尽 → 请求排队/超时
→ 解决方案?加机器(花钱)或异步编程(地狱)
虚拟线程怎么解决的?
虚拟线程 = JVM 管理的用户态线程,N:M 映射到少量平台线程(OS 内核线程)。
┌──────────────────────┐
虚拟线程 × 100 万 │ VT₁ VT₂ VT₃ ... │ ← JVM 调度器管理
│ 每个只占几百字节 │ (ForkJoinPool)
└──────────┬───────────┘
│ N:M 动态映射
┌──────────▼───────────┐
平台线程 × N │ OS Thread₁ ... │ ← N ≈ CPU 核心数
(传统内核线程) │ OS Threadₙ │
└──────────────────────┘
核心魔法:当虚拟线程遇到阻塞操作
时间线 →
平台线程 1: ████████ VT_A ██░░░░░░░░░░░░░░░░░░████ VT_C ████████
平台线程 2: ████████ VT_B ████████████████████████████████████████
VT_A 执行中 → db.query()(阻塞)
→ JVM 检测到阻塞 → 把 VT_A 的栈帧卸下来(unmount)
→ 平台线程 1 立即接手 VT_C 继续执行
→ ...数据库返回...
→ VT_A 的栈帧装回去(mount),等待任意空闲平台线程继续
→ 平台线程永不空闲!
关键洞察:阻塞操作发生时,JVM 在底层做了 yield,开发者感知不到。你写的是同步代码,跑出来的效果却是异步的。
代码对比:传统方式 vs 虚拟线程
// 方式一:传统线程池(池耗尽了请求就排队)
ExecutorService pool = Executors.newFixedThreadPool(200);
pool.submit(() -> {
String a = db.call(); // 阻塞,线程被占用
String b = api.call(); // 阻塞,线程继续被占用
return a + b;
});
// 方式二:CompletableFuture 异步(代码可读性灾难)
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> db.call())
.thenCompose(a -> CompletableFuture
.supplyAsync(() -> api.call())
.thenApply(b -> a + b));
// 方式三:虚拟线程(同步写法 + 异步效果,清爽)
Thread.startVirtualThread(() -> {
String a = db.call(); // 底层自动 yield,不占用平台线程
String b = api.call(); // 同上
return a + b;
});
// 或配合 ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> doWork());
}
不需要 async/await 关键字,不需要改代码风格——只需把 newFixedThreadPool 换成 newVirtualThreadPerTaskExecutor。
虚拟线程什么场景下「无敌」?
虚拟线程最适合的区域
CPU 密集型 ←───├───→ I/O 密集型
(不重要) │ (这里是主场!)
示例: 示例:
- 视频编码 - HTTP API 调用(等网络)
- 科学计算 - 数据库查询(等磁盘)
- 加密解密 - 消息队列消费(等消息)
- 微服务编排(等下游)
黄金场景:Web 服务器处理请求、微服务调用链、数据库访问——凡是「大部分时间在等」的场景。
什么时候不该用虚拟线程?
| 不适合的场景 | 原因 |
|---|---|
| 纯 CPU 计算 | 虚拟线程不会加速计算,反而有调度开销 |
| synchronized 块内有 I/O | synchronized 会 pin 住平台线程(虚拟线程无法 unmount)。JDK 21 已有 partial fix,JDK 24 彻底解决 |
| native 代码中有阻塞 | JVM 感知不到 native 层的阻塞 |
| 需要线程优先级/守护线程精细控制 | 虚拟线程不支持 setPriority() |
性能数据
Spring Boot 3.2 + 虚拟线程(Tomcat)
vs
Spring Boot 3.2 + 传统线程池(Tomcat)
并发连接数 5000,每个请求内 sleep 100ms 模拟 I/O:
传统线程池(200 线程):吞吐量 ~2,000 req/s,P99 延迟 ~2.5s
虚拟线程: 吞吐量 ~50,000 req/s,P99 延迟 ~120ms
↑ 25 倍吞吐量提升,延迟降低 95%
来源:Spring 官方 Blog(2023)
虚拟线程 vs Go goroutine
| Java 虚拟线程 | Go goroutine | |
|---|---|---|
| 出现版本 | JDK 21(2023) | Go 1.0(2012) |
| 映射模型 | N:M(虚拟线程 → 平台线程) | N:M(goroutine → OS 线程) |
| 调度器 | ForkJoinPool(work-stealing) | Go Scheduler(work-stealing) |
| 栈 | 堆上分配,动态增长 | 堆上分配,动态增长(初始 2KB) |
| 阻塞处理 | 自动 unmount | 自动切换 |
| 抢占 | JDK 21 开始支持(Thread.yield() 提示) |
Go 1.14 开始支持异步抢占 |
| 编程体验 | 同步代码,无需 await |
go func(),无需 await |
| 成熟度 | 新特性,生态适配中 | 十余年打磨,极致成熟 |
五、一张脑图收尾
并发编程
│
┌──────────────┼──────────────┐
▼ ▼ ▼
进 程 线 程 协 程
(资源分配单位) (CPU调度单位) (执行流组织)
│ │ │
独立内存空间 共享进程内存 共享线程内存
GB 级占用 MB 级占用 KB 级占用
内核态隔离 内核态切换 用户态切换 ✨
│ │ │
│ └──────┬───────┘
│ │
▼ ▼
多进程架构 Java 虚拟线程 (JDK 21)
(Nginx) N:M 映射到平台线程
阻塞 = yield(自动挂起)
百万并发不是梦
一句话总结
- 进程:OS 分配资源的「独立别墅」——隔离强、切换重
- 线程:进程里的「室友」——共享内存、比进程轻、但仍有内核切换开销
- 协程:线程里的「多面手」——用户态切换、几 KB 栈、创建百万个毫无压力
- 虚拟线程:Java 版的 goroutine——同步代码、异步性能、I/O 密集型场景下传统线程池的终结者
- 选择策略:CPU 密集用传统线程池,I/O 密集用虚拟线程,两者可以混用
并发编程的本质不是「跑得更快」,而是「等得更聪明」。虚拟线程把这个哲学贯彻到了极致。
更多推荐



所有评论(0)