作者:孙济伟
2026-07-20
前言
并发编程是 Java 后端面试的"必考题",但概念多、原理深,光背定义很难真正理解。这篇文章我用大白话 + 打比方的方式,把多线程的核心知识点串一遍,希望能帮你既学懂又能面试讲得清楚。
一、进程与线程的区别 ------ 工厂 vs 工人
把操作系统想象成一个大园区:
- 进程 = 园区里的一家工厂。每家工厂有自己的厂房、设备、原材料(独立的地址空间、资源),厂和厂之间相互隔离,一家失火不会烧到隔壁。
- 线程 = 工厂里的工人。工人们共享厂里的设备和材料(共享堆和方法区),手里各拿一张自己的任务清单(程序计数器)和笔记本(栈),各干各的活。
为什么说线程切换比进程切换开销小?
换一家工厂干活,你得关门、锁设备、走一段路到新厂、开门、熟悉环境------这就是进程切换(要切换页表、刷新 TLB、重建内存映射)。
同一个工厂里换个工序呢?放下手里螺丝刀,拿个扳手继续干就行------这就是线程切换(只换栈指针和程序计数器,地址空间不变,页表不用动)。
面试追问:
Q:Java 线程和操作系统线程什么关系?
A:HotSpot 虚拟机是 1:1 映射,你 new 一个 Java 线程,底层就有一个内核线程对应。操作系统负责调度,Java 管不了,这就是为什么多线程程序有时性能反而不如预期------操作系统的调度开销不是 Java 能控制的。
二、多线程的三种实现方式 ------ 三条路,都能跑
方式一:继承 Thread 类
java
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程运行中");
}
}
new MyThread().start();
类比: 你直接买了个机器人(Thread),把它的芯片写死。想让它干别的?不行,只能重写芯片------这就是 Java 单继承的局限。
方式二:实现 Runnable 接口
java
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程运行中");
}
}
new Thread(new MyRunnable()).start();
类比: 你把任务写在一张任务单(Runnable)上,然后交给一个工人干。同一个任务单可以复印多份给不同工人------天然适合资源共享。
方式三:实现 Callable 接口 + FutureTask(带返程票)
java
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 42;
}
}
FutureTask<Integer> task = new FutureTask<>(new MyCallable());
new Thread(task).start();
Integer result = task.get(); // 等结果,像等代购回来
类比: 你把任务外包出去,对方做完能给你带个结果回来 (返回值),路上遇到坑还能喊一声"出问题了"(抛异常)。前两种是派活不管结果,Callable 是派活等着收结果。
三种方式对比
| 方式 | 返回值 | 异常处理 | 能不能再继承别的 |
|---|---|---|---|
| Thread | ❌ | ❌ | ❌ 堵死了 |
| Runnable | ❌ | ❌ | ✅ 随便 |
| Callable | ✅ | ✅ | ✅ 随便 |
面试追问:
Q:start() 和 run() 分不清?
A:run() 是菜谱,start() 是开火。你对着菜谱看半天(run()),菜不会自己熟;只有拧开燃气灶(start()),才算真正炒起来。
三、同步代码块 ------ 厕所里的门锁
多线程抢共享资源,就像好几个人同时想进同一个厕所。
java
synchronized (厕所门锁) {
// 一个人进去,锁门
count++; // 拉屎
// 出来,解锁
}
没抢到锁的线程就得在外面等着------BLOCKED 状态。
synchronized 的三种锁法
java
// 1. 锁代码块 ------ 锁厕所隔间门
synchronized (lockObject) { ... }
// 2. 锁实例方法 ------ 锁整个卫生间(这个对象)
public synchronized void method() { ... }
// 3. 锁静态方法 ------ 锁整栋楼的卫生间(所有该类的实例)
public static synchronized void staticMethod() { ... }
锁升级:从"熟人模式"到"叫警察"
JDK 1.6 之后,synchronized 的锁不是一上来就很重,而是按需升级的,像安保力度逐步加强:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (只升不降)
- 偏向锁(熟人模式):一个门只有你一个人进出,门上刻了你的名字,看到是你来,直接推门进,连锁都不用掏。
- 轻量级锁(自旋协商):你和另一个人偶尔同时用到这个门,你们互相让一下(自旋等一会儿),不惊动管理员。
- 重量级锁(叫警察来仲裁):很多人狂抢这个门,自旋等不下去了,只能叫管理员来维持秩序(操作系统内核介入,线程挂起/唤醒,开销最大)。
面试追问:
Q:synchronized 和 volatile 的区别?
A:volatile 就像门上装了个警报铃 ------值变了所有人立刻知道(保证可见性),但它不能阻止两个人同时推门(不保证原子性)。synchronized 是锁------一个人进去了,别人就进不去(既保证可见性,也保证原子性)。
Q:synchronized 公平吗?
A:不公平。你不排队,靠抢。synchronized 释放锁后谁抢到是谁的,不是先到先得。ReentrantLock 可以设成公平的。
四、锁(Lock)------ 更灵活的"门锁"
synchronized 虽然好用,但有些场合不够灵活。java.util.concurrent.locks.Lock 接口提供了更多功能。
ReentrantLock 用法
java
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
count++;
} finally {
lock.unlock(); // 切记:一定要手动开锁!忘了就是死锁
}
Lock 比 synchronized 多了什么?
| 场景 | synchronized | ReentrantLock |
|---|---|---|
| 开门后忘了锁 | ✅ 自动锁 | ❌ 得自己锁(finally) |
| 能不能插队 | ❌ 只能非公平 | ✅ 公平/非公平均支持 |
| 等不及能不能走 | ❌ 死等 | ✅ tryLock 超时不候 |
| 能不能中途退出 | ❌ 不能被打断 | ✅ lockInterruptibly |
| 叫人的精细度 | 只能"所有人全部叫醒" | Condition 可以精确叫醒某一类 |
读锁 vs 写锁(ReadWriteLock):
想象一个图书馆:
读锁 :看书的可以同时进好几个人(读读不互斥)
写锁:管理员要整理书架,其他人全得出去(读写互斥,写写互斥)
java
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock(); // 多人同时读
rwLock.writeLock().lock(); // 独占写
面试追问:
Q:ReentrantLock 怎么做到"可重入"的?
A:它有个计数器 state。同一个线程来一次 state +1,走一次 state -1,减到 0 才算真正释放。这就像你进自己家门------进去了还能进卧室、进书房,不会被自己锁在外面。同时它还会记住"这是谁的门"(exclusiveOwnerThread),别的线程来了一看不是自己,就得等。
Q:AQS(AbstractQueuedSynchronizer)是什么?
A:Lock 的底层核心骨架。它维护了一个 volatile int state (锁的状态)和一条 双向等待队列(排队的队伍)。抢不到锁的线程自动排到队尾,前一个人走了就唤醒下一个人。ReentrantLock、CountDownLatch、Semaphore 等全是基于 AQS 实现的------它就像乐高底座,上面搭什么积木都行。
五、等待唤醒机制 ------ 顾客和服务员
wait / notify:用 synchronized 的经典协作
想象你去一家面馆吃面:
java
synchronized (厨房窗口) {
while (面还没做好) {
厨房窗口.wait(); // 面没好,先等着,把窗口让给别人
}
// 面好了,端走吃
}
// 后厨
synchronized (厨房窗口) {
面做好了;
厨房窗口.notify(); // 喊一声:面好了,谁来端?
// 厨房窗口.notifyAll(); // 喊:所有人的面都好了!
}
三个铁律:
- wait/notify 必须在 synchronized 里面------就像只能在厨房窗口(锁)前喊,在自己家里喊服务员听不到(抛 IllegalMonitorStateException)。
- wait() 会释放锁------把窗口让给厨师,不像 sleep() 抱着锁睡觉。
- 被唤醒后重新抢锁------即使被叫醒了,也得挤到窗口前挤过别人才行。
为什么 wait 要在 while 循环里?
面试高频问题。因为存在虚假唤醒------服务员可能喊了一嗓子"面好了",你的面其实还没好,只是隔壁桌的面好了(系统底层有时会一次性唤醒所有等待线程)。如果是 if 判断,醒了就往下走,你端到一碗没煮好的面。while 循环会再检查一次:面没好?继续等。
Condition:更精细的"叫号系统"
Lock 的 Condition 好比是智能叫号系统:
java
ReentrantLock lock = new ReentrantLock();
Condition 面好了 = lock.newCondition(); // 面条好了的队列
Condition 饺子好了 = lock.newCondition(); // 饺子好了的队列
java
// 等面的顾客
lock.lock();
try {
while (!面已做好) 面好了.await();
// 端面
} finally { lock.unlock(); }
// 后厨喊:面好了
lock.lock();
try {
面已做好 = true;
面好了.signal(); // 只叫醒等面的,不影响等饺子的
} finally { lock.unlock(); }
synchronized 的 wait/notify 就一个等待集------像大厅里一群人一起等,服务员喊一声"好了",每个人都醒了,自己去找哪个是自己的。Condition 可以分成多个队列------"等面的坐左边,等饺子的坐右边",哪样好了叫哪样,效率高得多。
wait vs sleep
| wait() | sleep() | |
|---|---|---|
| 谁的方法 | Object(Java 万物皆可等) | Thread(线程自己睡) |
| 睡觉交不交钥匙 | ✅ 交锁(让别人进去) | ❌ 抱着锁睡(别人进不去) |
| 在哪用 | 必须在同步块里 | 随便哪都能睡 |
| 谁叫醒 | notify/notifyAll | 到点自己醒 |
六、高频面试题速记
线程的六种状态(一生)
NEW(出生) → RUNNABLE(干活) → BLOCKED(没抢到锁)
→ WAITING(等人叫)
→ TIMED_WAITING(限时等)
→ TERMINATED(凉了)
- NEW:new 出来了,还没 start()
- RUNNABLE:start() 了,包括正准备干和正在干
- BLOCKED:synchronized 没抢到锁,门口排队
- WAITING:wait() 或 join(),等别人叫
- TIMED_WAITING:sleep(3秒) 或 wait(3秒),限时等
- TERMINATED:跑完了
实现线程安全的六种方法
- synchronized ------ 锁门,简单粗暴
- Lock(ReentrantLock)------ 功能更全的门锁
- volatile ------ 只保证通知到位,不保证没人抢
- 原子类(AtomicInteger)------ 基于 CAS,无锁并发
- 线程安全容器(ConcurrentHashMap 等)------ 已经封装好了,拿来就用
- ThreadLocal ------ 每人一份副本,你改你的我改我的
死锁 ------ 经典"筷子的故事"
四个人吃饭,桌上只有四根筷子,每人拿了一根:
- 互斥:一根筷子一次只能一个人拿(资源互斥)
- 请求与保持:我拿着这根筷子,还去够旁边的筷子(不撒手还继续要)
- 不可剥夺:别人不能从我手里硬抢(除非我自己放)
- 循环等待:每个人都等旁边的人放下筷子,形成闭环
破局方法:打破任意一个条件。最常见的是按顺序拿筷子------约定好大家都先拿编号小的,再拿编号大的,就永远不会死锁。
总结
并发编程的核心,用一个场景就能串起来: