一句话总结
进程是资源分配的基本单位,线程是 CPU 调度的基本单位。一个进程里可以跑多个线程,它们共享堆、全局变量和文件描述符,但各有自己的栈、程序计数器和寄存器;因此创建、切换、销毁线程的成本远低于进程。通信上,进程之间要靠管道、消息队列、共享内存等 IPC,线程之间直接读写共享变量即可,但必须处理同步与可见性问题。
初级理解
核心区别对比
| 维度 | 进程 | 线程 |
|---|---|---|
| 定位 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 地址空间 | 独立,互相隔离 | 同进程内共享 |
| 私有资源 | 整个地址空间 | 栈、程序计数器、寄存器 |
| 共享资源 | 无(除非显式 IPC) | 堆、全局变量、文件描述符 |
| 创建/切换开销 | 大(要建/换页表) | 小(同地址空间,TLB 不用刷) |
| 通信方式 | 管道、消息队列、共享内存、信号 | 直接共享内存(需同步) |
| 崩溃影响 | 只挂自己 | 整个进程一起挂 |
一个进程内的多个线程
# Java:同一进程内的两个线程共享静态变量
public class SharedDemo {
static int counter = 0; // 堆上,线程间共享
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) counter++; });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) counter++; });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(counter); // 往往小于 200000:counter++ 不是原子操作
}
}
# 结论:共享带来方便,也带来竞争——这就是 synchronized / volatile / 原子类存在的意义
中级深入
上下文切换:线程为什么比进程轻
# 进程切换:
# 1. 保存当前进程的寄存器、程序计数器
# 2. 切换页表(虚拟地址空间变了)→ TLB 失效,后续访存要重新建立映射
# 3. 刷新各种缓存相关状态
# 线程切换(同进程内):
# 1. 保存/恢复该线程私有的栈、寄存器、程序计数器
# 2. 页表不变、TLB 不失效 → 省掉最贵的一步
# 所以:线程切换通常比进程切换快一个数量级;
# 但切换仍然不免费(微秒级 + 缓存污染),无脑开线程反而更慢
为什么有了进程还要线程
# 1. 并发:一个进程内的任务需要"同时"推进(浏览器一边渲染一边下载)
# 2. 通信成本:进程间 IPC 要走内核、序列化,线程间读写共享变量几乎零成本
# 3. 创建开销:频繁创建销毁的任务(如每个请求一个线程)用进程代价无法接受
# 4. 多核利用:进程也能用多核,但内存不共享,协作麻烦
多线程一定更快吗
# 不一定,分场景:
# 1. CPU 密集型:核数固定时,线程数超过核数只会增加切换开销,不会更快
# (Python 还有 GIL,同进程多线程连一个核都用不满)
# 2. IO 密集型:等待时让出 CPU,多线程/多协程能显著提升吞吐
# 3. 有共享竞争的:加锁会让并行退化成串行,甚至比单线程还慢
# 4. Amdahl 定律:可并行比例决定加速上限
高级进阶
用户态线程与内核态线程
# 1:1(内核线程):每个线程都是一个内核调度实体,Linux pthread、Java Thread
# 优点:可利用多核;缺点:创建切换要走内核,数量受限(万级)
# N:1(用户态线程):多个用户线程映射到一个内核线程,早期 Java 绿线、部分协程实现
# 优点:切换在用户态完成,极轻量;缺点:一个线程阻塞系统调用,整组全挂
# M:N(混合):Go 的 goroutine(g + m + p 模型)——海量用户态协程 + 少量内核线程
协程与虚拟线程
# 协程(协作式):遇到 IO 主动让出,由运行时库调度,不走内核
# Go:go func() 一行启动一个 goroutine,默认并发几十万
# Java 21 虚拟线程(Loom):写起来像阻塞式,运行时挂起时不占内核线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return null; });
}
} // 10 万个"并发睡眠",传统线程池早就 OOM 了
线程池与 IO 多路复用
# 线程不是越多越好:每个线程有栈(默认 1MB 起)+ 调度成本
# 经验值:CPU 密集型 = 核数 + 1;IO 密集型 = 核数 / (1 - 阻塞系数)
# IO 多路复用是另一条路:一个线程用 epoll 盯住成千上万连接(Redis、Nginx、Netty)
# select/poll/epoll 演进:epoll 只返回就绪的 fd,不遍历全量
实战场景
# 场景1:选进程还是选线程?
# 需要强隔离、容错(一个挂了不影响别人)→ 多进程:Chrome 每标签一进程
# 需要高频通信、共享大量数据 → 多线程:Web 服务器 worker
# 既要隔离又要通信 → 多进程 + 共享内存 / 消息队列
# 场景2:线程池正确用法
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // 核心线程
8, // 最大线程
60, TimeUnit.SECONDS, // 空闲回收
new ArrayBlockingQueue<>(200), // 有界队列,拒绝 OOM
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:回退到调用方
);
# 要点:队列必须有界;拒绝策略要想清楚;Executors.newFixedThreadPool 默认无界队列是坑
# 场景3:线程间共享变量的正确同步
# 可见性 + 原子性,两个问题
private volatile boolean running = true; // 保证可见性
private final AtomicInteger count = new AtomicInteger(); // 保证原子性
# volatile 不保证原子性;synchronized/AtomicXxx/Lock 按场景选
面试模拟
面试官:进程和线程有什么区别?
你:进程是资源分配的基本单位,有独立地址空间;线程是调度的基本单位,是进程内的执行流,共享堆和全局变量,但有自己的栈、程序计数器和寄存器。所以线程创建切换更轻量、通信更方便,但一个线程崩溃会带走整个进程,共享数据也必须处理同步。
面试官:有了进程为什么还要有线程?
你:一是并发:一个程序内部要同时做多件事,比如浏览器一边渲染一边网络下载;二是通信成本:进程间 IPC 要走内核还要序列化,线程间直接读写共享内存;三是开销:高频创建销毁用进程代价太高,比如每个请求一个线程的场景。
面试官:线程切换为什么比进程切换快?
你:同进程的线程共享地址空间,切换时页表不用换,TLB 不会失效,只需保存恢复私有的栈和寄存器;进程切换要换页表,TLB 失效后访存要重新建映射,这是最贵的部分。但线程切换也不是免费的,有寄存器保存和缓存污染,所以线程不是越多越好。