Files
autumn-recruitment/01.Java/concurrent/锁升级与 CAS 机制.md
T

255 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [java/lang, lock-escalation, CAS, AQS, optimistic-lock]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# 锁升级与 CAS 机制
## 概述
Java 的 synchronized 关键字从 JDK 1.5 到 JDK 1.6 经历了一次重大升级——引入锁升级机制,让锁在低竞争时以极轻量方式运行,在高竞争时平滑过渡到重量级互斥锁。这一设计的核心是 **CAS(Compare And Swap)** 原子操作和 **AQS(AbstractQueuedSynchronizer)** 框架,它们共同构成了 Java 并发包的底层基石。
## 核心原理
### 锁升级之路:偏向 → 轻量级 → 重量级
synchronized 的锁状态不是静态的,它会随着竞争程度逐步"膨胀":
```mermaid
stateDiagram-v2
[*] --> 无锁状态
无锁状态 --> 偏向锁: 第一个线程获取锁
偏向锁 --> 偏向锁: 同一线程重复获取\n(时间戳比较)
偏向锁 --> 轻量级锁: 其他线程尝试获取\n(发生竞争)
轻量级锁 --> 轻量级锁: CAS自旋成功\n(少数竞争者)
轻量级锁 --> 重量级锁: CAS失败次数过多\n或自旋超时
重量级锁 --> 轻量级锁: 竞争减弱\n(Monitor退出队列)
```
#### 第一阶段:偏向锁(Biased Locking)
**触发条件**:第一个线程进入同步块时,JVM 会在对象头中记录当前线程 ID,后续该线程再次进入时无需任何同步操作。
- JDK 6 默认启用,需要 `-XX:+UseBiasedLocking`。
- JDK 15 中被标记为废弃,JDK 17 中被移除。因为实际场景中"永远只有一个人访问"的概率很低,而偏向锁的撤销代价较高(需全局 STW 撤销所有偏向)。
**对象头结构(HotSpot,64位)**:
| 偏移 | 字段 | 大小 |
|------|------|------|
| 0-2 bit | Mark Word 低 3 位 | 锁标志位 |
| 3-31 bit | 偏向锁标记 + ThreadID | 优先权 + 线程 ID |
| 32-63 bit | 分代年龄 + hashCode | 64 位扩展信息 |
当有其他线程尝试获取偏向锁时,JVM 会撤销该对象的偏向状态,升级为轻量级锁。
#### 第二阶段:轻量级锁(Lightweight Locking)
**触发条件**:偏向锁被剥夺后,或者从一开始就存在多个线程竞争。
核心机制:**利用 CAS 替换对象头中的 Mark Word**。
流程:
1. 线程在栈帧中创建 **Lock Record**,复制对象头的 Mark Word 到其中。
2. 使用 CAS 将对象头的 Mark Word 替换为指向 Lock Record 的指针。
3. 如果 CAS 成功,当前线程获得锁;如果失败,说明有竞争。
4. 竞争发生时,当前线程自旋重试(最多循环指定次数)。
5. 如果自旋超过阈值(默认 10 次,可通过 `-XX:PreBlockSpin` 调整),升级为重量级锁。
> [!NOTE]
> "轻量级"并不意味着不需要操作系统内核帮助——只是在没有竞争时使用用户态 CAS,避免了上下文切换开销。一旦竞争激烈,它最终会退化为重量级锁。
#### 第三阶段:重量级锁(Heavyweight Locking)
**触发条件**:轻量级锁的 CAS 和自旋都未能成功获取锁。
此时 Monitor 对象成为真正的互斥锁:
- 未获得锁的线程会被阻塞挂起(进入 OS 的等待队列)。
- 阻塞/唤醒操作需要切换到内核态,这是性能损耗最大的环节。
### Unsafe 类与 CAS 原理
`Unsafe` 是 JVM 提供的一个"后门"类,允许 Java 代码直接操作内存和执行原子操作。其中的 CAS 原语是所有乐观锁的基础。
**核心方法签名**:
```java
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);
```
`offset` 是通过 `objectFieldOffset(Field)` 计算出的字段在对象内存布局中的字节偏移量。
**原理**:CAS 是 CPU 级别的原子指令(x86 下对应 `cmpxchg` 汇编),在多核环境下通过总线锁或缓存锁保证原子性。JVM 内部通过 `Atomic*` 类封装了这些操作,开发者无需直接使用 Unsafe。
```java
// AtomicInteger.incrementAndGet 的核心逻辑(简化版)
private volatile int value;
public final int incrementAndGet() {
int prev, next;
do {
prev = get(); // 读取当前值
next = prev + 1; // 计算新值
} while (!compareAndSet(prev, next)); // CAS 更新
return next;
}
```
这段代码用 12 行实现了线程安全的自增——没有使用任何 synchronized。其关键保障来自 `while` 循环:如果 CAS 失败(说明中间有其他线程修改了 value),就重新读取、重新计算、重新尝试,直到成功。
> [!WARNING]
> CAS 的三个问题:
> 1. ABA 问题(见下文)
> 2. 只能保证一个共享变量的原子操作,无法做多变量联合更新
> 3. 长时间自旋会增加 CPU 开销——这就是为什么有界锁最终会升级为重量级锁
#### ABA 问题与 AtomicStampedReference
ABA 问题是 CAS 的经典缺陷:线程 T1 读取值为 A,另一个线程 T2 把值改为 B 再改回 A,T1 的 CAS 检查时发现值仍是 A,误以为没有被修改过。
解决思路:**给值加版本号**——每次变更版本号加 1。即使值回到 A,版本号也已不同。
```java
// 伪代码示意 AtomicStampedReference 用法
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
int[] stampHolder = new int[1];
String current = ref.get(stampHolder); // 返回 ["A", 0]
int stamp = stampHolder[0];
// CAS 时需要同时匹配值和版本号
ref.compareAndSet("A", "B", stamp, stamp + 1); // [A, 0] -> [B, 1]
ref.compareAndSet("B", "A", stamp + 1, stamp + 2); // [B, 1] -> [A, 2]
```
### AQS(AbstractQueuedSynchronizer)核心设计
AQS 是 `java.util.concurrent` 包的基石——ReentrantLock、CountDownLatch、CyclicBarrier、Semaphore 全部基于它构建。
#### CLH 队列模型
AQS 维护了一个 FIFO 的等待队列(CLH 变体),每个节点代表一个等待资源的线程:
```mermaid
flowchart LR
H["head"] --> N1["Node 1<br/>WAITING"]
N1 --> N2["Node 2<br/>SIGNAL"]
N2 --> N3["Node 3<br">CONDITION"]
N3 --> NULL["null (tail)"]
style H stroke-dasharray: 5 5
```
核心规则:
- **头节点(head)** 是当前持有锁的节点,它的线程正在执行。
- **尾节点(tail)** 是新入队节点的插入位置。
- 前驱节点释放锁时会唤醒后继节点(通过 `park()` → `unpark()`)。
- 节点状态包括:`CANCELLED`(已取消)、`SIGNAL`(后继需 unpark)、`CONDITION`(等待条件变量)、`PROPAGATE`(共享模式传播)。
#### state 状态机
AQS 的核心是一个 `volatile int state` 变量,表示同步状态:
| 同步器 | state 含义 |
|--------|-----------|
| ReentrantLock | 持有锁的次数(重入计数) |
| CountDownLatch | 计数器初始值,递减到 0 触发 |
| Semaphore | 可用许可证数量 |
| ReentrantReadWriteLock | 高 16 位读计数,低 16 位写计数 |
#### tryAcquire / tryRelease 模板方法
子类只需实现这两个抽象方法,AQS 处理所有队列管理细节:
```java
// ReentrantLock 的非公平锁 tryAcquire 核心逻辑(简化)
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 无竞争时直接用 CAS 拿锁
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
// 重入:累加 state
setState(c + acquires);
return true;
}
return false; // 竞争发生,走 AQS 排队流程
}
```
> [!TIP]
> 面试常考点:AQS 的 `setState()` 用了 `volatile` 但非 CAS——因为重入场景下只增加不减少,且由独占线程自己操作,不存在多线程竞写问题。
### 三大常用工具类的 AQS 应用
#### CountDownLatch
单向计数器,减到 0 后释放所有等待线程。**不可重置**,适合"等齐事件"场景。
```java
// 主线程等待 5 个初始化任务完成
CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
doInit();
latch.countDown(); // 每完成一个减 1
});
}
latch.await(); // 阻塞直到计数器归零
```
#### CyclicBarrier
可循环使用的栅栏,到达指定数量后统一放行。**可以复用**,适合多阶段并行计算。
```java
// 三组数据并行处理,完成后合并结果
CyclicBarrier barrier = new CyclicBarrier(3, resultMerger::merge);
for (DataSource ds : dataSources) {
executor.submit(() -> {
Result r = ds.process();
barrier.await(); // 等待同伴
});
}
```
#### Semaphore
控制并发访问的资源信号量。常用于限流——限制同时运行的任务数。
```java
// 限制同时执行 10 个 IO 请求
Semaphore sem = new Semaphore(10);
executor.submit(() -> {
sem.acquire(); // 拿令牌,不足则阻塞
try {
ioRequest.send();
} finally {
sem.release(); // 归还令牌
}
});
```
## 实践场景
**面试高频对比题**:
| 维度 | synchronized | ReentrantLock |
|------|-------------|---------------|
| 实现层级 | JVM 内置关键字 | JDK API 层 |
| 锁升级 | 偏向→轻量级→重量级 | 无(始终 AQS + CAS) |
| 公平性 | 非公平 | 可选公平/非公平 |
| 中断响应 | 不响应中断 | `lockInterruptibly()` 支持 |
| 条件变量 | wait()/notify() | 多个 Condition |
| 超时获取 | 不支持 | `tryLock(timeout)` |
| 可重入 | 是 | 是 |
## 关联笔记
- [[01.Java/concurrent/线程池参数与拒绝策略]]