Java 从入门到精通(十二):并发编程基础——线程、JMM、synchronized 与 volatile

Java 从入门到精通(十二):并发编程基础——线程、JMM、synchronized 与 volatile
神经蛙本文默认读者已掌握 Java 基础语法与集合框架,JDK 以 8 为主,同时标注 JDK 15 之后偏向锁被废弃带来的变化。文中代码均可直接运行,建议边读边跑,尤其是第三节 i++ 复现实验与第七节死锁实验——只有亲眼看到”结果不等于 20000”和”jstack 打印出 deadlock”,抽象的内存模型概念才会落地。
一、为什么要并发:从摩尔定律失效说起
过去二十年程序员享受过一段”免费的午餐”:CPU 主频从几百 MHz 涨到几 GHz,同样的代码不改一行,明年就自动变快。但 2005 年前后这条曲线断了——受制于功耗墙与散热极限,单核主频停滞在 4GHz 附近,厂商转而把晶体管堆成多核。于是”免费午餐”结束:想变快,就得自己写并发。
并发(Concurrency)与并行(Parallelism)经常被混用,但层次不同。并发指”逻辑上同时处理多件事”,强调结构与调度,单核上靠时间片轮转也能实现;并行指”物理上真正同时执行多条指令”,必须依赖多核。可以说并行是并发在硬件上的一种执行形态,是并发的子集。
| 维度 | 并发 Concurrency | 并行 Parallelism |
|---|---|---|
| 核心语义 | 逻辑上同时推进多个任务 | 物理上同一时刻执行多条指令 |
| 硬件依赖 | 单核即可(时间片切换) | 必须多核/多 CPU |
| 关注点 | 结构、调度、正确性 | 吞吐、加速比、数据切分 |
| 典型场景 | Web 服务器处理上千请求 | 图像渲染、矩阵运算 |
| 主要难点 | 竞态、死锁、可见性 | 任务拆分、负载均衡 |
并发能带来三点收益:一是吞吐,多个请求同时被处理;二是响应性,耗时任务丢给后台线程,主线程继续响应 UI 或接收连接;三是资源利用率,一个线程等 IO 时另一个可用满 CPU。代价同样明显:上下文切换要保存/恢复寄存器与内核栈,一次切换消耗几千到上万个 CPU 周期;多线程引入数据竞争与死锁;更麻烦的是并发 bug 具有偶发性,本地跑一百次没问题,线上偶尔崩一次且无法稳定复现。
判断收益要看 Amdahl 定律:串行部分占比 S、核数 N 时,加速比上限是 1 / (S + (1 - S) / N)。串行占 10% 时,即便堆到 64 核,加速比也不超过 1 / (0.1 + 0.9/64) ≈ 8.7 倍。这意味着盲目加线程不如先找串行瓶颈:锁竞争、单点日志写入往往才是天花板。优化顺序永远是:先减少串行段(无锁化、分段、异步化),再谈加并发度。
并发不是银弹,加线程的收益服从边际递减:线程数超过核数后,继续增加只会放大上下文切换与内存占用(每个线程默认栈 1MB 起)。经验公式是 CPU 密集型 核数 + 1,IO 密集型可参考 核数 × (1 + 等待时间/计算时间),但最终以压测数据为准。
二、线程基础:创建方式、生命周期与常用 API
2.1 进程与线程
进程是操作系统分配资源的基本单位,拥有独立的虚拟地址空间与文件句柄;线程是 CPU 调度的基本单位,隶属于进程,共享堆与方法区,仅独占程序计数器、虚拟机栈与本地方法栈。正因为共享堆,线程间通信非常廉价,但也正因如此才有了后面种种可见性与原子性问题。
| 对比项 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 地址空间 | 独立,互相隔离 | 共享所属进程的堆与元空间 |
| 切换开销 | 大(页表、内核栈、TLB 刷新) | 小(仅寄存器与栈指针) |
| 通信方式 | 管道、信号、共享内存、Socket | 共享变量、wait/notify、并发工具 |
| 健壮性 | 一个进程崩溃不影响他人 | 一个线程 OOM 可能拖垮整个进程 |
2.2 四种创建方式
第一种,继承 Thread 并重写 run,写法直观,但 Java 单继承会占用宝贵的继承名额,且任务与线程耦合,不推荐。
1 | public class ExtendsThreadDemo { |
第二种,实现 Runnable 接口,把”任务”与”线程”解耦,也可被多个线程共享,是最常用的基础写法。
1 | public class RunnableDemo { |
第三种,实现 Callable 配合 FutureTask,这是前两种的重大升级:Runnable 的 run() 既不能返回值也不能抛受检异常,Callable 则可以拿到返回值并捕获执行异常。
1 | import java.util.concurrent.Callable; |
第四种,线程池。生产环境几乎不该裸 new Thread,创建销毁成本高且无节制创建会耗尽内存;线程池负责复用线程、控制并发度、统一拒绝策略与异常处理。
1 | import java.util.concurrent.*; |
| 创建方式 | 返回值 | 异常传递 | 解耦程度 | 适用场景 | 推荐度 |
|---|---|---|---|---|---|
| 继承 Thread | 无 | 无 | 差(占用继承位) | 教学演示 | 不推荐 |
| 实现 Runnable | 无 | 无 | 好 | 简单异步任务 | 一般 |
| Callable + FutureTask | 有 | 有 | 好 | 需要结果/超时控制 | 中等 |
| 线程池 | 有(Future) | 有 | 最好 | 生产环境一切场景 | 强烈推荐 |
2.3 start 与 run 的本质区别
这是入门阶段最经典的送分题,但很多人只知道结论不知道原理。直接调用 run(),只是像调用普通方法一样在当前线程里执行一遍方法体,没有任何新线程诞生;调用 start() 才会让 JVM 通过 start0() 这个 native 方法向操作系统申请创建新线程,新线程进入就绪队列等待调度,被调度到时由它自身执行 run()。另外,start() 一个已启动或已终止的线程会抛 IllegalThreadStateException——Thread 对象的生命周期是一次性的。
1 | public class StartVsRunDemo { |
2.4 线程的六种状态与迁移
Java 在 Thread.State 中定义了六种状态。注意 BLOCKED 与 WAITING 的区分:BLOCKED 是被动等待进入 synchronized 的 Monitor;WAITING/TIMED_WAITING 是主动调用 wait()、join()、park() 等进入的等待。此外 RUNNABLE 在 JVM 层面不区分”正在运行”与”就绪可运行”,因为时间片分配是操作系统的事。
| 状态 | 含义 | 进入方式 | 退出方式 |
|---|---|---|---|
| NEW | 已创建未启动 | new Thread() |
start() |
| RUNNABLE | 可运行(含就绪与运行中) | start()、等待结束、抢到锁、被唤醒 |
获得/等待 CPU、调用阻塞 API |
| BLOCKED | 阻塞于 Monitor 锁 | 进入 synchronized 未获锁 |
锁被释放并竞争成功 |
| WAITING | 无限期等待 | wait()、join()、LockSupport.park() |
notify/notifyAll、unpark、目标线程终止 |
| TIMED_WAITING | 限期等待 | sleep(n)、wait(n)、join(n)、parkNanos |
超时或被唤醒 |
| TERMINATED | 已终止 | run() 正常结束或异常退出 |
不可逆转 |
1 | public class ThreadStateDemo { |
2.5 常用 API 与正确停止线程的方式
sleep(long) 让当前线程休眠指定毫秒,不释放已持有的锁;yield() 是提示调度器”我愿意让出 CPU”,仅是个 hint,生产环境几乎不用;join() 让当前线程等待目标线程结束,本质是循环调用 wait();interrupt() 只是打一个中断标志,不会真的杀死线程,若线程正在 sleep/wait/join 中会抛 InterruptedException 并清除标志位。
Thread.stop()、suspend()、resume() 早已被废弃:stop() 会强行释放该线程持有的全部锁,对象可能停在”改了一半”的不一致状态。正确的停止方式是协作式的:用 interrupt() 打标志,被停止方主动检查并有序退出;或在循环里检查一个 volatile 标志位。
1 | public class StopThreadDemo { |
守护线程(Daemon Thread)通过 setDaemon(true) 设置,必须在 start() 之前调用;当 JVM 中只剩守护线程时会直接退出,守护线程里的 finally 块不保证执行,因此不要在其中做写文件、提交事务这类关键操作,GC 线程就是典型的守护线程。
线程优先级 setPriority(int) 取值范围 1~10,但它在 Linux 上常常形同虚设,不要依赖优先级做业务调度。相比之下,给线程起一个能表明业务含义的名字(如 order-pay-pool-3)投入产出比极高:线上 CPU 飙高时 jstack 拉一份线程栈,一眼就能定位是哪块业务。
三、线程安全三要素:原子性、可见性、有序性
并发安全的本质可归纳为三个要素,任何一条被破坏,程序都可能出现诡异行为。
| 要素 | 定义 | 被破坏的典型场景 | 主要保障手段 |
|---|---|---|---|
| 原子性 | 一组操作要么全部执行、要么完全不执行,中间不会被其他线程插入 | i++、check-then-act、转账扣款加款 |
synchronized、Lock、Atomic*、CAS |
| 可见性 | 一个线程对共享变量的修改能立刻被其他线程看到 | 工作内存缓存导致读到旧值、死循环无法退出 | volatile、synchronized、final 安全发布 |
| 有序性 | 程序执行的顺序符合预期,不被重排序打乱 | DCL 单例拿到半初始化对象 | volatile、synchronized、happens-before |
3.1 i++ 为什么不是原子的
i++ 看着是一行,编译成字节码其实是三步:先把 i 读到操作数栈,再执行加一,最后写回。线程 A 刚读完还没写回,线程 B 也来读同一个旧值,两人算出同样的结果写回去,一次加法就凭空消失了。
1 | public class UnsafeCounter { |
上面这段代码就是竞态条件(Race Condition):多个线程同时访问同一段临界区(Critical Section)代码,结果依赖于线程执行时序。注意一个陷阱——即便结果”碰巧”等于 20000,也不能说明代码安全,只是恰好没撞上,这正解释了并发 bug 为何难以复现。
四、Java 内存模型:可见性与有序性的根基
4.1 主内存与工作内存的抽象
JMM(Java Memory Model)是一套抽象规范,屏蔽了不同硬件内存模型的差异,规定了”一个线程对共享变量的写入何时对其他线程可见”。它把存储划分为主内存(Main Memory,对应堆中对象实例数据)与线程私有的工作内存(Working Memory,对应寄存器与 L1/L2 缓存)。线程不能直接读写主内存,必须先拷贝到工作内存、操作完再写回——正是这个过程带来可见性延迟。
JMM 定义了 8 种原子操作:lock、unlock 作用于主内存变量;read 把变量从主内存传出、load 把值放入工作内存副本;use 把值交给执行引擎、assign 把结果赋回工作内存;store 把值传回主内存、write 写入主内存变量。它们必须成对按顺序出现(read 后必须 load,store 后必须 write),但允许在中间插入其他操作。
4.2 happens-before 八条规则
happens-before 是判断可见性的唯一依据:操作 A happens-before B,则 A 的结果对 B 可见。它不要求 A 在时间上先发生,强调的是”结果的可见性顺序”。
| 规则 | 内容 | 工程意义 |
|---|---|---|
| 程序顺序规则 | 单线程内,前面的操作 happens-before 后面的操作 | 编译器仍需遵守 as-if-serial |
| 监视器锁规则 | 对同一个锁的 unlock happens-before 后续的 lock | synchronized 保证可见性的根源 |
| volatile 规则 | 对 volatile 变量的写 happens-before 后续对该变量的读 | 禁止重排序 + 强制刷新主内存 |
| 线程启动规则 | Thread.start() happens-before 子线程中的任何操作 |
启动前的写入对子线程必然可见 |
| 线程终止规则 | 子线程的所有操作 happens-before join() 返回 |
join 后一定看得到子线程结果 |
| 线程中断规则 | 调用 interrupt() happens-before 被中断线程检测到中断 |
中断标志的可见性保证 |
| 对象终结规则 | 对象构造完成 happens-before finalize() 开始 |
已被废弃,了解即可 |
| 传递性 | A hb B 且 B hb C,则 A hb C | 组合出跨线程的可见性链条 |
推导实战一:为什么 final 字段是安全的。JMM 规定,构造函数内对 final 字段的写入与随后把对象引用赋值给一个引用变量这两个操作不能被重排序;只要对象被正确发布(构造器中未逸出 this),其他线程读到的 final 字段一定是构造时写入的值,无需额外同步。这也是”不可变对象天生线程安全”的理论依据。
推导实战二:为什么 Thread.start() 前的写对子线程可见。由线程启动规则,主线程在 start() 之前的所有写入 happens-before 子线程的任意操作,因此子线程读到的配置一定是最新值,无需 volatile。反过来则不成立:子线程启动后再修改共享变量,主线程没有任何可见性保证。
4.3 指令重排序与 as-if-serial
为了压榨性能,编译器、JIT 与 CPU 都会在不改变单线程语义的前提下重排指令,这就是 as-if-serial 语义:单线程下结果与串行执行一致即可。问题是它只保证单线程,多线程下就会翻车。
4.4 内存屏障的四种类型
JMM 通过在指令序列中插入内存屏障(Memory Barrier)禁止特定类型的重排序,并强制刷新缓存。
| 屏障类型 | 指令示意 | 作用 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | 禁止 Load2 及其后续读操作重排到 Load1 之前 |
| StoreStore | Store1; StoreStore; Store2 | 保证 Store1 的写入对后续 Store2 可见(先刷新) |
| LoadStore | Load1; LoadStore; Store2 | 禁止 Store2 重排到 Load1 之前 |
| StoreLoad | Store1; StoreLoad; Load2 | 保证 Store1 刷新到主内存后才执行 Load2,开销最大 |
在 HotSpot 的 x86 实现里,volatile 写之后会插入一条 lock addl $0x0,(%rsp)(带 Lock 前缀的空操作),它同时具备 StoreLoad 屏障的效果,排空 store buffer 并使其他核的缓存行失效。
4.5 DCL 单例:重排序导致半初始化对象
双重检查锁定(Double-Checked Locking)是重排序危害最经典的案例。instance = new Singleton() 可分解为三步:① 分配内存;② 初始化对象;③ 引用指向该内存。若②③被重排序,线程 B 第一次判空时看到 instance != null,拿到的却是未初始化的对象,调用其方法就会 NPE 或读到默认值。
1 | public class DCLSingleton { |
补充一句:JDK 1.5 之前 volatile 语义不强,DCL 确实是坏掉的写法;JDK 5 之后加了 volatile 的 DCL 才正确。今天更推荐静态内部类(利用类加载的初始化锁)或枚举单例,天然防反射与序列化攻击。
五、volatile 深入:轻量但不可替代
5.1 volatile 的两条语义
volatile 提供两条保证:第一,可见性,对 volatile 变量的写立刻刷新到主内存,读一定从主内存加载;第二,禁止指令重排序,通过内存屏障阻止特定重排组合。它不保证原子性——这是最容易踩的坑。
5.2 三类典型适用场景
一是状态标志位,如前面停止线程的例子;二是一次性安全发布,利用 volatile 写 happens-before 后续读,保证对象初始化完成后才被别人看到(DCL 单例正是此用法);三是低开销的读写同步,即”一写多读”且写操作不依赖当前值的场景。判断口诀:写操作本身是原子的且不依赖变量当前值,才适合用 volatile。
5.3 为什么 volatile 不能替代锁
给 count 加上 volatile 后可见性有了,但 count++ 的读—改—写三步依然可被打断,两个线程仍可能读到同一个值。
1 | public class VolatileNotAtomic { |
5.4 数组与引用的可见性边界
一个易忽略的细节:volatile int[] arr 保证的是数组引用本身的可见性,即 arr 指向新数组能被看到,但 arr[0] = 1 这种元素写入不具备 volatile 语义,要元素可见请用 AtomicIntegerArray。同理,volatile List<String> list 只保证引用可见,往里 add 并不安全,请用 CopyOnWriteArrayList 或加锁。
5.5 字节码层面与性能开销
用 javap -v 反编译,volatile 字段的访问标志里会多出 ACC_VOLATILE(0x0040)。JVM 会在 volatile 写之后插入带 lock 前缀的指令,它有两重作用:一是把当前处理器缓存行写回主内存并使其他核的对应缓存行失效(MESI 协议);二是充当内存屏障禁止前后指令跨越它重排。开销上,volatile 读几乎与普通读无异(x86 上无需额外屏障),volatile 写比普通写慢数倍到十几倍,因为要排空 store buffer,但仍远低于 synchronized 的锁竞争与内核态切换。
| 对比项 | volatile | synchronized |
|---|---|---|
| 原子性 | 不保证(仅保证单次读/写的原子) | 保证 |
| 可见性 | 保证 | 保证(unlock 前写回主内存) |
| 有序性 | 保证(禁止特定重排序) | 保证 |
| 互斥性 | 无 | 有 |
| 适用条件 | 一写多读、写不依赖当前值 | 复合操作、需要互斥 |
| 开销 | 低(写时屏障开销) | 高(锁竞争 + 内核态切换) |
| 死锁风险 | 无 | 有 |
六、synchronized 深入:Monitor、对象头与锁升级
6.1 三种用法与锁对象
synchronized 是 Java 最基础的互斥手段,它之所以”不会忘了解锁”,是因为编译器会在同步块前后自动插入 monitorenter 与 monitorexit,并且生成两个 monitorexit(正常出口 + 异常出口),保证异常时锁必然释放。
| 用法 | 示例代码 | 实际锁对象 |
|---|---|---|
| 同步实例方法 | synchronized void m(){} |
当前实例 this |
| 同步静态方法 | static synchronized void m(){} |
类的 Class 对象(如 Foo.class) |
| 同步代码块 | synchronized(obj){} |
括号中指定的 obj |
| 同步 this 代码块 | synchronized(this){} |
当前实例 this |
| 同步 Class 代码块 | synchronized(Foo.class){} |
类的 Class 对象 |
1 | public class SynchronizedUsage { |
注意:静态同步方法与实例同步方法锁的是两个不同的对象,因此可被不同线程同时执行,互不阻塞。
6.2 可重入性
synchronized 是可重入锁:持有锁的线程再次请求同一把锁会直接成功,内部维护计数器,每重入加一、每退出减一,减到 0 才真正释放。可重入避免了”同步方法调另一个同步方法把自己锁死”的问题。
1 | public class ReentrantDemo { |
6.3 对象头与 Mark Word
在 HotSpot 中,对象内存布局分为对象头、实例数据与对齐填充三部分,对象头又包含 Mark Word(运行时数据)与类型指针。Mark Word 是锁实现的关键,它复用程度极高,随锁状态变化存储不同内容。
64 位虚拟机下 Mark Word 的布局如下(32 位下位数相应折半,结构一致):
| 锁状态 | 56 bit 区域 | 1 bit | 4 bit | 1 bit(偏向模式) | 2 bit(标志位) |
|---|---|---|---|---|---|
| 无锁 | unused 25 bit + identity_hashcode 31 bit | unused | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程 ID 54 bit + epoch 2 bit | unused | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈帧中 Lock Record 的指针(62 bit) | — | — | — | 00 |
| 重量级锁 | 指向 ObjectMonitor 的指针(62 bit) | — | — | — | 10 |
| GC 标记 | 空 | — | — | — | 11 |
锁状态完全由最后 2 bit 的标志位决定:01 时再看倒数第 3 位判断是否为偏向锁,00 是轻量级锁,10 是重量级锁,11 表示已被 GC 标记。这也解释了为什么调用过 hashCode() 的对象无法进入偏向锁——Mark Word 存了哈希码,没有空间放线程 ID。
6.4 Monitor 与 monitorenter/monitorexit
每一个 Java 对象都可以作为一个 Monitor(管程)。重量级锁依赖 ObjectMonitor(C++ 对象),它维护 _owner(持有锁的线程)、_EntryList(竞争失败的线程队列)与 _WaitSet(调用 wait() 后进入的等待队列)。执行 monitorenter 时,若 _owner 为空则占有锁;若已被自己持有则计数器加一;否则进入 _EntryList 阻塞。monitorexit 时计数器减一,减到 0 释放锁并唤醒 _EntryList 中的线程。
1 | public class MonitorBytecode { |
6.5 锁升级的四个阶段
早期 synchronized 是无条件重量级锁,性能很差,被 ReentrantLock 嘲讽了很多年。JDK 6 引入锁升级机制后,synchronized 在无竞争场景下几乎零成本。
| 阶段 | 触发条件 | Mark Word 内容 | 性能特征 |
|---|---|---|---|
| 无锁 | 对象刚创建,未被任何线程锁定 | 哈希码 + 分代年龄 | 无开销 |
| 偏向锁 | 只有一个线程反复进入同步块 | 偏向线程 ID + epoch | 几乎零开销,仅一次 CAS |
| 轻量级锁 | 有第二个线程来竞争(轻微交替) | 指向栈帧 Lock Record 的指针 | 用户态 CAS 自旋,不阻塞 |
| 重量级锁 | 竞争激烈,自旋超过阈值 | 指向 ObjectMonitor 的指针 | 内核态阻塞,开销最大 |
升级过程是不可逆的(只能升不能降),具体过程如下:
- 无锁 → 偏向锁:第一个线程进入同步块时,用一次 CAS 把线程 ID 写入 Mark Word 并置偏向模式为 1。之后再次进入只需比对线程 ID,连 CAS 都不用。其思想是”大多数锁在生命周期内只被一个线程持有”。
- 偏向锁 → 轻量级锁:出现第二个线程竞争时,偏向锁必须撤销(revoke)。撤销要等全局安全点(Safe Point),暂停持有者并检查其状态,再把对象头恢复为无锁或升级为轻量级锁,代价不小。
- 轻量级锁 → 重量级锁:轻量级锁通过 CAS 把 Mark Word 复制到当前线程栈帧的 Lock Record 中,并把 Mark Word 替换为指向 Lock Record 的指针。CAS 失败说明有竞争,线程开始自旋重试;JDK 6 引入自适应自旋,根据该锁上次自旋的成功率与持有者状态动态决定自旋时长。自旋超过阈值(或自旋线程数超过 CPU 核数一半)就升级为重量级锁,线程进入
_EntryList阻塞。
偏向锁还涉及两个进阶机制:批量重偏向(bulk rebiasing) 与 批量撤销(bulk revocation)。当一个类的多个对象都被 T1 偏向、却被 T2 大量访问时,JVM 发现”偏向错了人”,就把整批对象的 epoch 加一,允许重偏向 T2;若某类撤销次数超过阈值(默认 40 次),JVM 判定该类”不适合偏向”,之后新建对象直接进入轻量级锁状态。
6.6 JDK 15 之后:偏向锁被废弃
JEP 374 在 JDK 15 废弃并默认关闭偏向锁(-XX:-UseBiasedLocking),随后相关代码被彻底移除。原因很现实:偏向锁是为十多年前”处处同步却几乎无竞争”的旧代码(Hashtable、Vector)设计的,现代应用普遍使用无锁集合与线程池,收益越来越小;而它撤销需要安全点、代码复杂度极高,维护成本远超收益。结论:JDK 15 起锁升级退化为”无锁 → 轻量级锁 → 重量级锁”三级,偏向锁只需理解其设计思想。
6.7 synchronized 与 ReentrantLock
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 语法 | 关键字,自动加锁/释放 | 显式 lock()/unlock(),必须放 finally |
| 可重入 | 是 | 是 |
| 公平性 | 非公平(无公平选项) | 可选公平/非公平(构造参数) |
| 可中断 | 不可中断,死等 | lockInterruptibly() 可响应中断 |
| 尝试加锁 | 不支持超时 | tryLock() / tryLock(timeout) |
| 条件变量 | 单一 wait set | 多个 Condition,可精准唤醒 |
| 性能 | JDK 6 后已大幅优化,无竞争时极快 | 高竞争下表现稳定 |
| 使用成本 | 低,不易出错 | 高,忘记 unlock 就是事故 |
工程建议:优先用 synchronized,除非需要可中断、超时、公平性或多条件队列这些高级能力。JDK 6 之后二者性能差距已很小,可读性与安全性才是第一位的。
七、经典并发问题与示例
7.1 竞态条件:银行转账
转账是典型的复合操作:检查余额 → 扣减 A → 增加 B。若不加锁,两个线程同时操作同一账户就可能出现”总额凭空增减”。
1 | public class BankTransfer { |
7.2 死锁:四个必要条件与排查
死锁必须同时满足四个条件:互斥(资源一次只能被一个线程占有)、占有且等待(持有着资源又等待新资源)、不可抢占(资源不能被强行剥夺)、循环等待(形成环形等待链)。破坏任意一条即可避免死锁,工程上最有效的是破坏循环等待——规定全局统一的加锁顺序(如上例按账户名字典序加锁)。
1 | public class DeadlockDemo { |
排查线上死锁的标准动作是:jps -l 找到进程号,jstack -l <pid> > dump.txt 导出线程栈后搜索 deadlock,JVM 会自动打印出”线程 X 持有锁 A 等待锁 B、线程 Y 持有锁 B 等待锁 A”的完整环路与源码行号;也可以用 jconsole 的”检测死锁”或 Arthas 的 thread -b 一键定位。
此外还有两个”半死不活”的状态:活锁是线程们都很忙却永远推进不了(像两人在走廊里互相让路),解决方法是引入随机退避;饥饿是某线程长期抢不到资源(非公平锁下后来者一直插队),解决方法是使用公平锁。
7.3 生产者消费者:三种实现
7.3.1 wait/notify 版(最底层,必须理解)
1 | import java.util.LinkedList; |
上面代码中三个关键点必须记住。其一,条件判断必须用 while 而不是 if:wait() 存在”虚假唤醒(spurious wakeup)”,线程可能在没有任何 notify 的情况下醒来,用 if 的话醒来后会直接往下走,对空队列 poll() 拿到 null;用 while 则会在醒来后重新检查条件。其二,wait() 必须在同步块内调用,因为它要释放当前锁,没锁就无从释放,直接抛 IllegalMonitorStateException。其三,notify() 只随机唤醒一个,若唤醒的是同类(生产者唤醒生产者)程序就会卡死,因此多生产多消费场景一律用 notifyAll()。
7.3.2 BlockingQueue 版(生产首选)
1 | import java.util.concurrent.ArrayBlockingQueue; |
7.3.3 Lock + Condition 版(精准唤醒)
1 | import java.util.concurrent.locks.Condition; |
| 实现方式 | 代码量 | 可控性 | 唤醒精度 | 推荐场景 |
|---|---|---|---|---|
| wait/notify | 最多 | 完全手动 | notifyAll 全量唤醒 | 学习原理、面试手写 |
| BlockingQueue | 最少 | 封装完善 | 内部实现 | 生产环境首选 |
| Lock + Condition | 中等 | 高 | 多条件队列精准唤醒 | 需要多个等待条件时 |
7.4 wait 与 sleep 的区别
| 对比项 | wait() | sleep() |
|---|---|---|
| 所属类 | Object(所有对象都有) |
Thread(静态方法) |
| 释放锁 | 释放当前 Monitor | 不释放任何锁 |
| 调用前提 | 必须在 synchronized 块内 |
无要求 |
| 唤醒方式 | notify/notifyAll 或超时 |
超时或被 interrupt |
| 用途 | 线程间协作 | 单纯让出 CPU 一段时间 |
记忆口诀:wait 是”我放弃锁去等条件”,必须先持有锁才能调用;sleep 是”我抱着锁睡一会儿”,不会释放任何锁——这也意味着在 synchronized 块里 sleep 会把其他线程全堵在外面,是常见的性能杀手。
八、实战与高频面试题
8.1 线程安全计数器的四种实现
1 | import java.util.concurrent.atomic.AtomicInteger; |
一般性能排序是 LongAdder > AtomicInteger > 锁分段 ≈ synchronized(低竞争下 synchronized 优化得很好,甚至可能反超)。原理不难理解:AtomicInteger 让所有线程 CAS 同一变量,高竞争下大量自旋失败;LongAdder 用 base 加 Cell 数组把线程打散累加,sum() 时求和,以空间换时间。取舍:要精确原子快照(限流器)用 AtomicInteger,只做统计求和(QPS 计数)用 LongAdder。
8.2 三个线程交替打印 ABC
1 | import java.util.concurrent.locks.Condition; |
这道题的核心考点有三个:条件判断用 while 而非 if、signal() 而非 signalAll() 的精准唤醒、用一个共享状态变量控制轮转。也可以用 Semaphore 或 SynchronousQueue 实现,思路相通。
8.3 高频面试题 15 问
- 进程和线程的区别? 进程是资源分配的基本单位,线程是 CPU 调度的基本单位;线程共享堆与元空间,切换开销远小于进程。
- 创建线程有几种方式? 严格说只有一种——
new Thread()再start();所谓四种指”定义任务”的方式(继承 Thread、实现 Runnable、Callable+FutureTask、线程池),生产环境用线程池。 start()和run()的区别?run()只是普通方法调用,在当前线程串行执行;start()通过 native 方法start0()创建系统线程,由新线程异步执行run()。- 线程有哪些状态? NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED 六种;BLOCKED 是等待 Monitor 锁,WAITING 是主动等待。
- 如何正确停止一个线程?
interrupt()发中断请求 + 被停止方检查isInterrupted()或volatile标志位协作退出,捕获InterruptedException后需恢复中断标志;绝不用已废弃的stop()。 - 线程安全三要素? 原子性、可见性、有序性。
synchronized三者都保证,volatile只保证可见性与有序性。 - JMM 是什么? 一套屏蔽硬件差异的抽象内存模型,定义了主内存与工作内存的交互协议与 happens-before 规则,让可见性有统一判定标准。
- happens-before 是”先行发生”吗? 不是,它是可见性规则:A hb B 意味着 A 的结果对 B 可见,时间上的先后不必然产生 hb 关系。
volatile的实现原理? 字节码层面是ACC_VOLATILE标志,JIT 编译后写操作插入带 Lock 前缀的指令,触发缓存行写回与失效,并充当 StoreLoad 屏障禁止重排序。volatile能保证i++线程安全吗? 不能。i++是读—改—写三步,volatile 只保证单次读写的可见性与原子性,复合操作仍需synchronized或AtomicInteger。- 锁升级过程? 无锁 → 偏向锁 → 轻量级锁 → 重量级锁,不可逆;轻量级锁用 CAS + 自适应自旋,超阈值升级为重量级锁并阻塞。JDK 15 起偏向锁被禁用,实际为三级。
- synchronized 和 ReentrantLock 怎么选? 优先 synchronized(简洁、不会忘解锁、性能已优化);需要可中断、超时、公平锁、多个 Condition 时选 ReentrantLock。
- 死锁四条件与排查? 互斥、占有且等待、不可抢占、循环等待;破坏循环等待(统一加锁顺序)最有效;用
jstack -l <pid>查看 JVM 自动报告的 deadlock,或 Arthasthread -b。 wait()为何必须在同步块中且用while判断? 因为wait()要释放锁,无锁就抛IllegalMonitorStateException;while用于防止虚假唤醒与”唤醒后条件已被他人改变”。- DCL 单例为何要加 volatile? 因为
new的”分配内存—初始化—赋值引用”可能被重排序,导致其他线程拿到未初始化的对象;volatile 通过内存屏障禁止该重排序。
到并发编程的地基就打完了:从为什么要并发,到线程模型,到 JMM 与 happens-before,再到 volatile 与 synchronized 这两把最基础的”瑞士军刀”,最后是死锁与生产者消费者的标准解法。下一篇我们将进入 JUC(java.util.concurrent)的世界,讲解 AQS、ReentrantLock、CountDownLatch、Semaphore、ConcurrentHashMap 与线程池的实现原理。

















