Skip to content

进程/线程/协程深度解析

面试官:进程和线程的区别是什么?

:进程是操作系统资源分配的基本单位,线程是 CPU 调度的基本单位。进程拥有独立的地址空间,线程共享进程的资源…

面试官:那协程和线程又有什么区别?为什么说协程更轻量?

这个追问把很多人问住了。能说清「用户态调度」、「栈空间差异」和「百万级并发」原理的候选人,才算真正掌握了并发编程的本质。


链式追问一:进程与线程的本质

Section titled “链式追问一:进程与线程的本质”

Q1:进程和线程的根本区别是什么?必考

Section titled “Q1:进程和线程的根本区别是什么?”

核心对比

维度进程线程
资源分配独立的虚拟地址空间、文件描述符表、堆内存共享进程的地址空间和资源
CPU 调度较重的调度单位,切换成本高轻量的调度单位,切换快
创建开销大(分配地址空间、复制页表、创建内核对象)小(共享进程资源,只创建线程栈和内核对象)
通信方式IPC(管道、消息队列、共享内存、Socket)共享内存(直接读写共享变量,需要同步)
隔离性强(一个进程崩溃不影响其他进程)弱(一个线程崩溃可能导致整个进程崩溃)
上下文切换慢(需要切换页表、刷新 TLB、切换内核栈)快(只需切换寄存器、栈指针,共享页表)

进程内存布局

进程虚拟地址空间(Linux x86_64,用户空间 128TB)
┌─────────────────┐ 0x7FFFFFFFFFFFFF
│ 用户栈 │ ↓ 向下增长
├─────────────────┤
│ (共享库 mmap)│
├─────────────────┤
│ 堆(Heap) │ ↑ 向上增长
├─────────────────┤
│ BSS 段 │ 未初始化全局变量
├─────────────────┤
│ 数据段 │ 已初始化全局变量
├─────────────────┤
│ 代码段 │ 只读,可共享
└─────────────────┘ 0x400000

线程共享与独有

线程共享的资源:
├── 堆内存(malloc/new 分配的内存)
├── 全局变量、静态变量
├── 打开的文件描述符
├── 进程的信号处理器
└── 当前工作目录
线程独有的资源:
├── 线程栈(默认 1MB~8MB,可配置)
├── 寄存器状态(PC、SP、通用寄存器)
├── 线程局部存储(Thread Local Storage)
├── 信号掩码
└── errno 变量

本质一句话:进程是资源隔离的边界,线程是执行流的单元。


Q2:线程切换时,操作系统具体保存了什么?为什么说比进程切换快?高频

Section titled “Q2:线程切换时,操作系统具体保存了什么?为什么说比进程切换快?”

线程上下文切换保存的内容

// Linux 内核 task_struct 中保存的上下文
struct thread_info {
unsigned long flags; // 线程标志
struct cpu_context cpu_context; // CPU 上下文
};
struct cpu_context {
unsigned long x19; // 保存的寄存器
unsigned long x20;
// ... 其他通用寄存器
unsigned long sp; // 栈指针
unsigned long pc; // 程序计数器(返回地址)
};

切换过程对比

进程切换:
1. 保存当前进程的寄存器状态 → PCB
2. 切换页表基址寄存器(CR3)→ 指向新进程的页表
3. 刷新 TLB(快表)→ 新进程的地址映射缓存失效
4. 刷新 CPU 缓存(部分失效)→ L1/L2 缓存命中率下降
5. 恢复新进程的寄存器状态
线程切换(同进程内):
1. 保存当前线程的寄存器状态 → task_struct
2. 切换栈指针 → 指向新线程的栈
3. 恢复新线程的寄存器状态
✓ 不需要切换页表,不刷新 TLB

性能数据

操作时间(约)备注
进程切换5~10 微秒刷新 TLB 是最大开销
线程切换(同进程)1~2 微秒不刷新 TLB,缓存友好
协程切换(用户态)0.1~0.5 微秒无系统调用,类似函数调用

TLB 刷新的影响

  • TLB(Translation Lookaside Buffer)缓存虚拟地址到物理地址的映射
  • 进程切换必须刷新 TLB(不同进程的虚拟地址映射不同)
  • 现代 CPU 引入 ASID(Address Space ID)技术,可避免每次刷新 TLB
  • 但即使有 ASID,进程切换仍比线程切换慢 3~5 倍

Q3:Linux 中创建进程和线程的实现有什么区别?高频

Section titled “Q3:Linux 中创建进程和线程的实现有什么区别?”

Linux 的统一设计:Linux 内核不严格区分进程和线程,都用 task_struct 表示调度实体。

// 创建进程:fork() 系统调用
pid_t fork(void) {
// 底层调用 clone(),标志位不带 CLONE_VM
return clone(SIGCHLD, 0); // 不共享地址空间
}
// 创建线程:pthread_create() → clone()
int pthread_create(pthread_t *thread, ...) {
// 底层调用 clone(),标志位共享多个资源
return clone(CLONE_VM | // 共享地址空间
CLONE_FS | // 共享文件系统信息
CLONE_FILES | // 共享文件描述符表
CLONE_SIGHAND | // 共享信号处理器
CLONE_THREAD, // 同一线程组
...);
}

Copy-on-Write(COW)优化

fork() 后:
父进程和子进程共享同一个物理内存页
页表项标记为只读
当任一进程尝试写入:
触发缺页异常
内核复制该页(COW)
更新页表,两进程各有一份独立的可写页
优点:
fork() 不需要立即复制整个地址空间
延迟复制,只有真正修改的页才复制

实战案例

// Redis 的 fork() 使用场景:RDB 持久化
void rdbSaveBackground() {
if ((childpid = fork()) == 0) {
// 子进程:将内存数据写入磁盘
rdbSave();
exit(0);
}
// 父进程:继续处理客户端请求
// 子进程利用 COW,不影响父进程的内存访问
}

Q4:Java 线程有哪些状态?与操作系统线程状态的对应关系?必考

Section titled “Q4:Java 线程有哪些状态?与操作系统线程状态的对应关系?”

Java 线程 6 种状态(Thread.State 枚举)

Java 状态含义触发条件OS 线程状态
NEW刚创建,未启动new Thread() 后,start()未创建
RUNNABLE可运行(就绪或运行中)start() 调用后Ready / Running
BLOCKED等待 synchronized 锁竞争锁失败Blocked
WAITING无限期等待Object.wait()Thread.join()LockSupport.park()Waiting
TIMED_WAITING有限期等待Thread.sleep()wait(timeout)parkNanos()Waiting
TERMINATED已终止run() 执行完毕或异常退出Terminated

状态转换图

start()
NEW ───────→ RUNNABLE ←─────┐
│ │
┌──────────┼──────────┐ │
│ │ │ │
BLOCKED WAITING TIMED_WAITING
│ │ │ │
└──────────┴──────────┴──┘
TERMINATED

关键区别

// BLOCKED vs WAITING
synchronized (lock) { // 竞争锁失败 → BLOCKED
lock.wait(); // 释放锁并等待 → WAITING
}
// Thread.sleep() vs Object.wait()
Thread.sleep(1000); // 不释放锁,TIMED_WAITING
lock.wait(1000); // 释放锁,TIMED_WAITING
// Java RUNNABLE 对应 OS 两个状态
while (true) {
// 正在 CPU 上执行 → OS Running
// 时间片用完,等待调度 → OS Ready
}

链式追问二:协程与用户态调度

Section titled “链式追问二:协程与用户态调度”

Q5:协程和线程的本质区别是什么?为什么协程更轻量?必考

Section titled “Q5:协程和线程的本质区别是什么?为什么协程更轻量?”

核心对比

维度线程协程(Goroutine/Virtual Thread)
调度方操作系统内核(抢占式)用户态运行时(协作式或抢占式)
切换方式内核态切换(系统调用)用户态切换(无系统调用)
栈空间固定大(Linux 默认 8MB)动态小(Go 初始 2KB,可增长到 1GB)
创建开销高(内核对象 + 默认 8MB 栈)低(用户态对象 + 小栈)
切换开销高(内核态切换 ~1-2µs)低(用户态切换 ~0.1-0.5µs)
并发量受系统线程数限制(通常数千)可达百万级(甚至千万级)
内存占用1000 线程 ≈ 8GB 栈内存100 万协程 ≈ 2GB 栈内存(Go)

栈空间对比

线程栈(Linux x86_64):
┌─────────────────┐
│ 8MB 栈空间 │ 即使只用了几 KB,也占用 8MB 虚拟内存
│ (大部分未用)│
├─────────────────┤
│ Guard Page │ 检测栈溢出
└─────────────────┘
Go 协程栈:
┌─────────────────┐
│ 2KB 初始栈 │ 按需增长(连续栈或分段栈)
│ ┌───────────┐ │ 当栈不够用时:
│ │ 实际使用 │ │ 1. 分配更大的栈(如 4KB)
│ └───────────┘ │ 2. 复制旧栈数据到新栈
└─────────────────┘ 3. 调整指针(GC 协助)

切换开销对比

线程切换(内核态):
用户态 → 内核态(系统调用)
→ 保存寄存器、切换栈
→ 刷新 TLB(如果是进程切换)
→ 内核态 → 用户态
总开销:~1-2 微秒
协程切换(用户态):
直接保存寄存器、切换栈指针
(类似函数调用,无内核参与)
总开销:~0.1-0.5 微秒(快 5-10 倍)

Q6:Go 的 goroutine 和 Java 的虚拟线程有什么区别?高频

Section titled “Q6:Go 的 goroutine 和 Java 的虚拟线程有什么区别?”

Go goroutine 调度模型(GMP)

G(Goroutine):协程,用户态执行单元
M(Machine):操作系统线程,执行 G
P(Processor):逻辑处理器,持有运行队列
调度流程:
1. P 持有一个本地运行队列(Local Run Queue)
2. M 从绑定的 P 的队列中取 G 执行
3. G 阻塞(如 channel、IO)→ M 释放 P
4. 其他 M 接管 P,继续执行队列中的 G
全局队列:
当本地队列空时,从全局队列偷 G(Work Stealing)

Java 虚拟线程(Virtual Thread,JDK 21+)

// 创建虚拟线程
Thread vt = Thread.ofVirtual().name("vt-1").start(() -> {
System.out.println("Hello from virtual thread");
});
// 使用 ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // 10 万虚拟线程,同步代码,异步效果

虚拟线程调度原理

虚拟线程(百万级)
├─→ 挂载到平台线程(Carrier Thread,OS 线程)
│ └── 平台线程数 ≈ CPU 核心数
└─→ 当执行阻塞操作(sleep、IO、lock)
└── 自动卸载(unmount)
└── 平台线程去执行其他虚拟线程
└── IO 完成后,虚拟线程重新挂载
关键机制:Continuation(续体)
保存虚拟线程的栈帧到堆内存
恢复时从堆中重建栈帧

对比总结

特性Go goroutineJava Virtual Thread
调度器Go runtime(GMP)JVM(ForkJoinPool)
抢占式调度是(Go 1.14+)是(基于 JVM safepoint)
栈增长连续栈(复制式增长)Continuation(栈帧存堆)
阻塞操作自动让出 M自动卸载,释放平台线程
与现有代码兼容需用 Go 生态完全兼容现有 Java 代码
适用场景新项目、微服务Java 生态、传统应用迁移

Q7:什么场景下应该用多进程、多线程、协程?实战

Section titled “Q7:什么场景下应该用多进程、多线程、协程?”

选型决策表

场景推荐方案原因
CPU 密集型(图像处理、加密、机器学习)多进程充分利用多核,避免 GIL(Python),隔离故障
IO 密集型(Web 服务、数据库查询)多线程 / 协程IO 等待时可切换执行其他任务,提高吞吐量
高并发 IO(C10K/C100K 问题)协程 / 虚拟线程海量并发下,线程栈内存不够用,协程更轻量
需要强隔离(浏览器多标签、沙箱)多进程一个崩溃不影响其他,Chrome 典型案例
共享大量状态(缓存、连接池)多线程共享内存通信简单,无需序列化
分布式计算(MapReduce、Spark)多进程 + 消息传递跨机器通信,进程粒度更灵活

性能对比示例(Web 服务器)

场景:1 万并发连接,每个连接每秒 10 次请求
方案 1:一连接一线程(BIO)
线程数:10,000
栈内存:10,000 × 1MB = 10GB(内存不足!)
上下文切换:频繁,CPU 大量时间浪费
方案 2:线程池 + NIO(Netty)
线程数:CPU 核心数 × 2(如 32)
栈内存:32 × 1MB = 32MB(可接受)
利用 IO 多路复用,一个线程监控多个连接
方案 3:虚拟线程(Java 21+)
虚拟线程数:10,000
栈内存:10,000 × 2KB(动态)≈ 20MB
平台线程:CPU 核心数(如 8)
同步代码写法,异步性能

实战案例

// 传统线程池(受限于线程数)
ExecutorService executor = Executors.newFixedThreadPool(200);
// 200 个线程,最多同时处理 200 个请求
// 虚拟线程(JDK 21+)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 可轻松处理 10 万+ 并发请求,内存占用低
// 适合场景:HTTP 请求、数据库查询、RPC 调用等 IO 密集型任务