Java 并发编程核心:volatile、synchronized、CAS 与 AQS 原理解析

Java 并发编程核心:volatile、synchronized、CAS 与 AQS 原理解析
神经蛙线程池解决了”怎么管理线程”的问题,但线程池本身并不保证线程安全——它只是把任务交给线程执行。真正保证多线程正确协作的,是 volatile、synchronized、CAS 和 AQS 这套并发原语。本文从 JMM 内存模型讲起,把 Java 并发的”内功心法”彻底拆开。
一、JMM 内存模型:并发问题的根源
Java 多线程程序中出现的各种诡异 Bug——值没变、结果不对、指令乱序——归根结底都源于 JMM(Java Memory Model)。JMM 不是真实存在的硬件结构,而是一组规范,定义了线程如何通过主内存交互。
1.1 三大特性:可见性、原子性、有序性
| 特性 | 含义 | 违反时的现象 | 保证手段 |
|---|---|---|---|
| 可见性 | 一个线程修改了共享变量,其他线程能立即看到最新值 | 线程 A 改了 flag=true,线程 B 还在读到 false | volatile / synchronized / final |
| 原子性 | 一个操作不可分割,要么全部完成,要么全不做 | i++ 看起来是一行,实际是 读-改-写 三步 | synchronized / Lock / Atomic |
| 有序性 | 程序按照代码顺序执行(在本线程内观察) | 指令重排导致对象未初始化就被引用 | volatile / happens-before |
1 | // 经典的可见性问题示例 |
上述代码在
-server模式下极大概率会死循环——因为 JIT 编译器可能将while(!flag)优化为无限循环,不再从主内存重新读取 flag。
1.2 happens-before 规则
JMM 通过 happens-before 原则来界定操作之间的可见性顺序。如果 A happens-before B,那么 A 的结果对 B 可见:
| 规则 | 说明 |
|---|---|
| 程序顺序规则 | 同一线程中,前面的操作 happens-before 后面的操作 |
| 监视器锁规则 | unlock 操作 happens-before 后续对同一把锁的 lock 操作 |
| volatile 变量规则 | 写操作 happens-before 后续对同一变量的读操作 |
| 线程启动规则 | Thread.start() happens-before 该线程的每一个操作 |
| 线程终止规则 | 线程中的所有操作 happens-before 其他线程检测到该线程结束 |
| 线程中断规则 | interrupt() 调用 happens-before 被中断线程检测到中断事件 |
| 对象终结规则 | 构造函数返回 happens-before finalize() 方法 |
| 传递性 | 若 A hb B 且 B hb C,则 A hb C |
1.3 指令重排序与内存屏障
编译器和 CPU 为了提升性能,会对指令进行重排序,分为三种:
1 | int a = 1; // ① |
内存屏障(Memory Barrier) 是阻止重排序的”硬指令”,JVM 插入四种屏障:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 确保 Load1 数据装载先于 Load2 及后续装载 |
| StoreStore | 确保 Store1 数据刷新先于 Store2 及后续存储 |
| LoadStore | 确保 Load1 数据装载先于 Store2 及后续存储 |
| StoreLoad | 确保 Store1 数据刷新先于 Load2 及后续装载(开销最大) |
二、volatile 关键字深度解析
2.1 volatile 能保证什么(不能保证什么)
一句话总结:volatile 保证可见性和有序性(禁止指令重排),但不保证原子性。
| 能力 | 是否保证 | 说明 |
|---|---|---|
| 可见性 | ✅ | 写操作强制刷回主存,读操作强制从主存读取 |
| 有序性 | ✅ | 插入内存屏障,禁止特定类型的指令重排 |
| 原子性 | ❌ | volatile int i; i++ 仍然不是原子操作 |
1 | // volatile 不保证原子性的经典反例 |
即使加了 volatile,
count++仍然是 读-改-写 三步操作,多线程交叉执行就会丢数据。要保证原子性必须用 synchronized 或 Atomic 类。
2.2 volatile 实现原理
volatile 的底层依赖两套机制协同工作:
(1)内存语义层面 —— 内存屏障
1 | volatile boolean v; |
(2)硬件层面 —— MESI 缓存一致性协议
现代 CPU 使用 MESI 协议维护缓存行状态:
| 状态 | 含义 |
|---|---|
| M(Modified) | 已修改,与主存不一致,仅在本缓存有效 |
| E(Exclusive) | 独占,与主存一致,仅在本缓存有效 |
| S(Shared) | 共享,与主存一致,多个缓存都有副本 |
| I(Invalid) | 无效,缓存行已失效 |
当 CPU 修改一个 volatile 变量时,会触发 Bus Locking 或 MESI Invalidate,使其他 CPU 中对应的缓存行失效,从而保证可见性。
2.3 volatile 典型使用场景
场景一:状态标志位
1 | // 最经典的 volatile 用法 —— 停止标志 |
场景二:DCL 双重检查锁定(单例模式)
1 | public class Singleton { |
为什么 DCL 中 instance 必须 volatile?因为 new Singleton() 分三步:
- 分配内存空间
- 初始化对象
- 将引用指向内存地址
步骤 2 和 3 可能被重排序为 1→3→2。如果线程 A 执行完 3 但还没执行 2,此时线程 B 进入第一次检查发现 instance != null,直接返回了一个未初始化完成的对象!volatile 的禁止重排恰好解决这个问题。
场景三:volatile 数组/引用的局限性
1 | private volatile int[] arr = new int[10]; |
volatile 只保证引用本身的可见性,不保证数组元素或对象内部字段的可见性。需要细粒度控制时请用 Atomic*Array 或加锁。
三、synchronized 锁机制与锁升级
3.1 synchronized 的三种用法
1 | // 1. 修饰实例方法 —— 锁的是当前实例对象(this) |
3.2 对象头与 Mark Word
synchronized 的锁信息存储在对象头(Object Header)的 Mark Word 中。在 64 位 JVM 中,Mark Word 的结构如下:
| 锁状态 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
|---|---|---|---|---|---|---|
| 无锁 | hashcode | age | 0 | 01 | unused | 无锁(01) |
| 偏向锁 | thread ID | epoch | 1 | 01 | unused | 偏向(01) |
| 轻量级锁 | 指向栈中 Lock Record 的指针 | — | 00 | 轻量锁(00) | ||
| 重量级锁 | 指向互斥量(Monitor)的指针 | — | 10 | 重量锁(10) | ||
| GC 标记 | 空 | — | 11 | GC(11) |
3.3 锁升级过程
JDK 6 之后,synchronized 引入了偏向锁和轻量级锁,大多数情况下不需要直接进入重量级锁(操作系统层面的互斥锁)。升级路径如下:
1 | 无锁 ──(第一次获取)──▶ 偏向锁 ──(竞争出现)──▶ 轻量级锁 ──(自旋失败)──▶ 重量级锁 |
偏向锁(Biased Locking)
当一个线程反复进入同一个同步块时,JVM 认为这个锁”偏向”该线程:
- 通过 CAS 将 Mark Word 中的 ThreadID 设为当前线程 ID
- 后续该线程进入/退出同步块无需任何 CAS 操作
- 只有当其他线程尝试获取这把锁时,才撤销偏向锁
生产建议:如果锁竞争确实激烈(如 Web 服务器的请求处理),偏向锁反而增加撤销开销。JDK 15 默认关闭偏向锁(
-XX:-UseBiasedLocking),JDK 18 正式移除。
轻量级锁(Lightweight Locking)
偏向锁撤销后,或两个线程交替获取锁时,升级为轻量级锁:
- 在当前线程的栈帧中创建 Lock Record(锁记录)
- 将对象的 Mark Word CAS 替换为指向 Lock Record 的指针
- 解锁时再将 Lock Record 中的旧 Mark Word CAS 回去
- 如果 CAS 失败(说明有竞争),自旋重试若干次
重量级锁(Heavyweight Locking)
自旋超过阈值(默认 10 次,可通过 -XX:PreBlockSpin 调整)后,升级为重量级锁:
- 向操作系统申请互斥量(mutex)
- 未获取到锁的线程阻塞(BLOCKED 状态),涉及用户态/内核态切换
- 被唤醒的线程需要重新竞争锁(非公平)
3.4 锁消除与锁粗化(JIT 编译器优化)
JIT 编译器在运行时还会对 synchronized 做两种智能优化:
1 | // 锁消除:锁的对象是局部变量,不可能被外部线程访问 |
3.5 synchronized vs volatile 对比
| 维度 | synchronized | volatile |
|---|---|---|
| 可见性 | ✅ | ✅ |
| 原子性 | ✅(复合操作也保证) | ❌ |
| 有序性 | ✅(互斥即有序) | ✅(内存屏障) |
| 阻塞 | 会阻塞(重量级锁时) | 不会阻塞 |
| 性能开销 | 较高(尤其重量级锁) | 很低 |
| 适用场景 | 复合原子操作、临界区保护 | 状态标志、单次读/写 |
四、CAS 与原子操作
4.1 什么是 CAS(Compare And Swap)
CAS 是一条 CPU 原子指令,其语义等价于以下伪代码:
1 | // CAS(V, Expected, NewValue) |
CAS 是乐观锁的核心思想:不先加锁,而是假设没有冲突,更新时再检验。冲突了就重试。
4.2 CAS 底层实现
Java 中 CAS 通过 sun.misc.Unsafe 类调用本地方法,最终映射为 x86 的 cmpxchg 指令:
1 | // AtomicInteger.getAndIncrement() 的核心调用: |
| 平台 | CAS 指令 |
|---|---|
| x86/x64 | lock cmpxchg |
| ARM | LDREX / STREX |
| RISC-V | LR / SC |
4.3 ABA 问题及解决方案
CAS 检查的是”值有没有变”,但如果值从 A → B → A 变回来,CAS 会误以为没变过:
1 | 线程1 读取值 A(准备 CAS 为 C) |
解决方案:加版本号
1 | // AtomicStampedReference —— 带版本号的引用 |
4.4 CAS 自旋与性能问题
CAS 失败后会自旋重试,在高竞争下可能导致:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| CPU 空转 | 大量线程自旋消耗 CPU | LongAdder(分段 CAS)、限制自旋次数 |
| ABA 问题 | 值被改回原样导致误判 | AtomicStampedReference |
| 只能保证单个变量 | 无法对多个变量做原子 CAS | 用 synchronized 或封装成对象 |
1 | // LongAdder —— 高并发下的计数器替代方案(JDK 8+) |
五、Atomic 原子类体系
JUC 提供了一套基于 CAS 实现的原子类,位于 java.util.concurrent.atomic 包下。
5.1 体系总览
1 | java.util.concurrent.atomic |
5.2 AtomicInteger 核心源码解析
以最常用的 getAndIncrement() (即 i++ 的原子版)为例:
1 | public final int getAndIncrement() { |
这个
do-while循环就是自旋锁的本质——不断尝试直到成功。在低竞争时几乎无开销;高竞争时会浪费 CPU 周期。
5.3 字段更新器:给现有类”打补丁”
1 | // 场景:想对一个第三方类的某个 volatile 字段做原子操作, |
字段更新器要求目标字段必须是 volatile 的,且反射能访问到。它本质上是对指定偏移量做 CAS 操作,是一种”侵入性更低”的原子操作方式。
六、AQS(AbstractQueuedSynchronizer)框架
6.1 AQS 设计思想
AQS 是 JUC 并发包的基石——ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 全部基于 AQS 构建。
Doug Lea(JUC 作者)的设计哲学:将同步状态的管理与线程的排队机制分离。
1 | AQS 核心架构: |
6.2 核心数据结构
state —— 同步状态
1 | // AQS 内部就是一个 volatile int |
不同同步器对 state 的含义不同:
| 同步器 | state 含义 |
|---|---|
| ReentrantLock | 0=未锁,>0=重入次数 |
| CountDownLatch | 剩余计数 |
| Semaphore | 可用许可数 |
| ReentrantReadWriteLock | 高16位=读锁持有数,低16位=写锁重入数 |
CLH 队列 —— 等待线程队列
AQS 内部维护一个 FIFO 双向链表(CLH 变体),每个节点是一个 Node:
1 | static final class Node { |
6.3 独占模式 vs 共享模式
AQS 支持两种资源获取模式:
| 模式 | 方法 | 特点 | 代表实现 |
|---|---|---|---|
| 独占(Exclusive) | acquire/release | 同一时刻只有一个线程持有 | ReentrantLock |
| 共享(Shared) | acquireShared/releaseShared | 多个线程可同时持有 | CountDownLatch, Semaphore |
6.4 AQS 获取锁的完整流程(独占模式)
1 | acquire(arg) |
6.5 ReentrantLock 如何基于 AQS 实现
ReentrantLock 内部有两个 AQS 子类:
1 | // NonfairSync —— 非公平锁 |
非公平锁 vs 公平锁的性能差异:非公平锁允许”插队”(新来的线程可以直接 CAS 抢锁,不用排队),减少了线程上下文切换,吞吐量更高。公平锁严格按照 FIFO 顺序获取锁,避免了饥饿,但吞吐量低 10%~20%。默认是非公平锁——因为大多数场景更看重吞吐量而非绝对公平。
可重入实现
ReentrantLock 的”可重入”体现在 tryAcquire 中判断当前线程是否已经是锁的持有者:
1 | // 简化的可重入逻辑 |
6.6 AQS Condition(条件变量)
Condition 是 AQS 提供的条件等待/通知机制,替代了 Object.wait()/notify():
1 | ReentrantLock lock = new ReentrantLock(); |
Condition 的优势:一个 Lock 可以创建多个 Condition,实现精准唤醒(只唤醒某一类等待线程),而 Object.notify() 是随机唤醒一个。
七、JUC 并发工具类
7.1 CountDownLatch —— 倒计时门闩
1 | // 场景:主线程等待 N 个子线程全部完成后汇总 |
原理:内部基于 AQS 共享模式,countDown() 就是 releaseShared(1)(state - 1),await() 就是 acquireSharedInterruptibly(1)(state != 0 则阻塞)。
7.2 Semaphore —— 信号量
1 | // 场景:限流 —— 控制同时访问某资源的线程数不超过 N |
原理:同样基于 AQS 共享模式,state 表示剩余许可数。acquire() 就是 tryAcquireShared(state > 0 则 CAS 减 1),release() 就是 releaseShared(state + 1 并唤醒等待线程)。
7.3 CyclicBarrier —— 循环栅栏
1 | // 场景:N 个线程互相等待,全部到达后一起继续(可重复使用) |
与 CountDownLatch 的关键区别:CyclicBarrier 可以复用(reset() 后重新计数),且支持 barrierAction(所有线程到达后执行的回调)。底层用的是 ReentrantLock + Condition,不是 AQS。
7.4 三大工具类对比
| 维度 | CountDownLatch | Semaphore | CyclicBarrier |
|---|---|---|---|
| 计数方向 | 递减(归零后不可重置) | 可增可减 | 递增(到达后自动重置) |
| 是否可复用 | ❌ 一次性 | ✅ | ✅ 循环使用 |
| 底层实现 | AQS 共享模式 | AQS 共享模式 | ReentrantLock + Condition |
| 等待方 | 一个/多个线程等待 | 获取许可的线程 | 所有参与线程互相等待 |
| 典型场景 | 等待 N 个任务完成 | 限流、资源池 | 多阶段并行计算 |
八、并发容器
8.1 ConcurrentHashMap(JDK 7 vs JDK 8)
这是面试最高频的并发容器,JDK 7 和 JDK 8 的实现完全不同:
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 数据结构 | Segment + HashEntry(分段锁) | Node 数组 + CAS + synchronized |
| 锁粒度 | 每个 Segment 一把锁(默认 16 段) | 每个桶头节点一把锁(更细) |
| 锁实现 | ReentrantLock | synchronized + CAS |
| size 计算 | 累加各 Segment 的 count(不加锁可能不准) | baseCount + CounterCell[] 分散计数 |
| 扩容 | Segment 独立扩容 | 多线程协助扩容(transfer) |
JDK 8 ConcurrentHashMap 核心源码
1 | // put 操作简化流程 |
JDK 8 的设计非常精妙:大部分情况用无锁 CAS,只在哈希冲突时才降级为 synchronized 锁住单个桶。而且 synchronized 锁的是桶的头节点(Node 对象),不是整个 Segment,粒度比 JDK 7 更细。
8.2 CopyOnWriteArrayList
1 | // 写时复制列表 —— 读完全无锁,写时复制整个底层数组 |
| 优点 | 缺点 |
|---|---|
| 读操作性能极高(无锁) | 写操作需要复制整个数组,O(n) 开销 |
| 读写完全不互斥 | 数据一致性最终一致(不是强一致) |
| 适合配置列表、事件监听器等读多写少场景 | 不适合写频繁的场景 |
8.3 BlockingQueue 家族
| 实现类 | 数据结构 | 有界? | 特点 |
|---|---|---|---|
| ArrayBlockingQueue | 数组 | ✅ 有界 | 有界容量,必须指定大小 |
| LinkedBlockingQueue | 链表 | 可选有界 | 默认 Integer.MAX_VALUE(近似无界) |
| PriorityBlockingQueue | 堆 | 无界 | 按优先级出队 |
| SynchronousQueue | 无缓冲 | 容量为 0 | 直接传递,不存储元素 |
| DelayQueue | 堆 | 无界 | 元素只有到期后才能取走 |
| LinkedTransferQueue | 链表 | 无界 | transfer() 方法可直接交给消费者 |
1 | // 生产者-消费者经典模式 |
8.4 并发容器选择指南
1 | 需要 Map? |
九、实战避坑清单
以下每一条都是线上血泪教训的总结:
| # | 坑点 | 正确做法 |
|---|---|---|
| 1 | 用 synchronized 包裹耗时 IO 操作 |
缩小同步范围,或用读写锁分离 |
| 2 | 在 synchronized 块中调用外部方法(可能死锁) |
同步块内只操作自己的对象 |
| 3 | volatile 当锁用(以为能保证 i++ 原子性) |
复合操作用 Atomic 或 synchronized |
| 4 | 忘记在 finally 中 unlock() |
始终用 try-finally 或 try-with-resources |
| 5 | CountDownLatch 记得调用 countDown() |
放在 finally 块中,防止异常导致死等 |
| 6 | 创建线程池用 Executors 工厂方法 |
手动创建 ThreadPoolExecutor,指定合理参数 |
| 7 | 共享可变对象作为 HashMap 的 Key | Key 必须是不可变的(immutable) |
| 8 | 在构造函数中启动新线程(this 引用逃逸) | 不要在构造函数中暴露 this 给其他线程 |
| 9 | 忽略中断异常(catch 后吞掉) | 至少恢复中断状态:Thread.currentThread().interrupt() |
| 10 | 用 wait()/notify() 而不用 Condition |
Condition 支持多条件队列、精确唤醒,功能更强 |
总结
本文从 JMM 内存模型出发,梳理了 Java 并发编程的完整知识链路:
1 | JMM(可见性/原子性/有序性) |
掌握这套体系,你就拥有了分析和解决绝大多数 Java 并发问题的能力。下一步建议结合 JConsole / VisualVM / Arthas 观察线程状态,在实践中加深理解。
















