问题引入

2011 年,JDK7 发布后,一位开发者在多线程环境下使用 ReentrantReadWriteLock 实现了一个缓存系统。读操作远多于写操作,理论上读写锁应该能提供极好的并发性能。但实际测试发现,当读线程数超过 50 个时,吞吐量反而下降了——不是因为写锁的竞争,而是因为读锁之间的协调开销

根本原因:ReentrantReadWriteLock 为了维护"读读共享、读写互斥"的语义,需要追踪当前有多少线程持有读锁。每个读锁的获取和释放都需要修改共享的读锁计数器,高并发下这个计数器成为了热点。

JDK8 引入的 StampedLock乐观读解决了这个问题:读操作不加锁,只是验证版本号,如果没有写操作干扰就直接使用数据。这种"不加锁的读"让读线程之间的协调开销降为零。

理解这个故事的关键,是理解 AQS(AbstractQueuedSynchronizer)——这个支撑了 Java 并发包半壁江山的基础框架。ReentrantLockCountDownLatchSemaphoreReentrantReadWriteLock,全部基于 AQS 构建。

核心概念

AQS:state + FIFO 队列 = 一切同步器

AQS 的核心设计极简:一个 int 类型的 state 字段,加上一个 FIFO 等待队列。

java 复制代码
public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer {
    private volatile int state;                    // 同步状态
    private transient volatile Node head;          // 队列头
    private transient volatile Node tail;          // 队列尾
}
graph TD subgraph "AQS 核心结构" S[state 同步状态] H[head] T[tail] N1[Node 线程A 等待] -->|next| N2[Node 线程B 等待] N2 -->|next| N3[Node 线程C 等待] H -->|指向| N1 T -->|指向| N3 end style S fill:#f9f,stroke:#333,stroke-width:2px style H fill:#bbf,stroke:#333,stroke-width:2px style T fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是 state——同步状态的核心,所有子类通过 CAS 修改它来决定谁获得锁。蓝色节点是 FIFO 队列的头尾指针。当线程获取锁失败时,被包装成 Node 节点加入队列尾部,进入等待状态。

模板方法模式:AQS 定义了获取/释放的算法骨架,子类实现具体的语义:

java 复制代码
// AQS 提供的模板方法(以独占模式为例)
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&          // 子类实现:尝试获取
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))  // 入队并自旋/阻塞
        selfInterrupt();
}

// 子类必须实现的方法
protected boolean tryAcquire(int arg) {     // 尝试获取独占锁
    throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) {     // 尝试释放独占锁
    throw new UnsupportedOperationException();
}
子类 state 含义 tryAcquire 语义
ReentrantLock 重入次数(0=未锁定) CAS state 从 0 到 1
CountDownLatch 剩余计数 计数为 0 时获取成功
Semaphore 可用许可数 剩余许可 > 0 时获取
ReentrantReadWriteLock 高16位=读锁数,低16位=写锁重入 读/写分别判断

面试官视角:问 "AQS 是什么" 的标准答案是 "一个用 state 和 FIFO 队列实现的同步器框架"。但追问"Node 队列是 CLH 队列吗""为什么用 FIFO 而不是栈""自旋多少次后挂起",才能区分真正读过源码的人。

Node 队列:CLH 变体与自旋挂起

AQS 的等待队列是 CLH(Craig, Landin, Hagersten)锁队列的变体。原版 CLH 队列中,每个线程自旋检查前驱节点的状态;AQS 的变体中,线程自旋失败后会被挂起(park/unpark),由前驱释放锁时唤醒。

java 复制代码
static final class Node {
    volatile int waitStatus;      // 节点状态:SIGNAL/CANCELLED/CONDITION/PROPAGATE
    volatile Node prev;           // 前驱
    volatile Node next;           // 后继
    volatile Thread thread;       // 绑定的线程
    Node nextWaiter;              // 共享/独占模式或 Condition 队列
}
graph LR subgraph "AQS Node 队列" H[head] -->|prev| N1[Node A SIGNAL] N1 -->|next| N2[Node B SIGNAL] N2 -->|next| N3[Node C 等待] N3 -->|prev| N2 N2 -->|prev| N1 end style H fill:#f9f,stroke:#333,stroke-width:2px style N3 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是 head——当前持有锁的线程或哨兵节点。蓝色节点是队列中的等待线程。每个节点自旋检查前驱的 waitStatus,如果前驱是 SIGNAL,说明前驱释放锁时会唤醒自己,可以放心挂起。

acquireQueued 的核心逻辑

  1. 线程获取锁失败,包装成 Node 入队
  2. 检查前驱是否是 head,如果是,再次尝试获取锁(自旋优化)
  3. 如果前驱不是 head 或获取失败,检查前驱状态:
    • 前驱是 SIGNAL:安全挂起(LockSupport.park
    • 前驱是 CANCELLED:跳过前驱,找有效的前驱
    • 前驱是其他状态:设置为 SIGNAL
  4. 被前驱唤醒后,回到步骤 2

为什么要用 FIFO? FIFO 保证等待最久的线程优先获得锁,避免了线程饥饿。如果用栈(LIFO),新来的线程总是插队,先来的线程可能永远等不到锁。

ReentrantLock:公平与非公平的哲学

ReentrantLock 是 AQS 最典型的应用。它与 synchronized 的核心差异:

  1. 可中断lockInterruptibly() 允许在等待时响应中断
  2. 可超时tryLock(long timeout, TimeUnit unit) 超时后放弃
  3. 公平/非公平可选:构造函数控制
  4. 条件变量:可创建多个 Condition 实现精准唤醒

公平锁 vs 非公平锁

java 复制代码
public ReentrantLock() {
    sync = new NonfairSync();  // 默认非公平
}
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
graph TD subgraph "公平 vs 非公平" subgraph "非公平锁" A1[新线程到达] -->|直接 CAS| B1[尝试插队] B1 -->|成功| C1[直接获取锁] B1 -->|失败| D1[去队尾排队] end subgraph "公平锁" A2[新线程到达] -->|检查队列| B2[hasQueuedPredecessors] B2 -->|队列非空| C2[直接去排队] B2 -->|队列空| D2[尝试获取] end end style C1 fill:#f9f,stroke:#333,stroke-width:2px style C2 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点展示非公平锁的插队行为——新线程不检查队列,直接 CAS 尝试获取锁,成功就跳过所有等待者。蓝色节点展示公平锁的排队行为——新线程必须先检查队列是否有前驱,有则直接去队尾排队。

公平锁的实现

java 复制代码
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&  // 关键:检查是否有前驱等待
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // ... 重入逻辑
}

为什么默认是非公平锁? 因为非公平锁的吞吐量更高。当锁被释放时,如果新来的线程刚好 CAS 成功,就省去了"唤醒队列头线程 → 头线程被调度执行"的上下文切换开销。代价是等待时间长的线程可能饥饿,但大多数情况下饥饿概率极低。

暗面:公平锁的吞吐量比非公平锁低 10-100 倍(取决于竞争强度),因为每次释放锁都强制唤醒队列头的线程,而新线程无法"偷"锁。只有在"线程饥饿会导致严重后果"的场景(如任务调度器)才需要用公平锁。

原理分析

synchronized 的锁升级路径

synchronized 是 JVM 层面的同步机制,它的实现经历了多次优化,核心思想是"能不加锁就不加锁,能轻量就不重量":

graph TD subgraph "synchronized 锁升级" NL[无锁] -->|第一个线程获取| BL[偏向锁] BL -->|第二个线程竞争| LL[轻量级锁 CAS 自旋] LL -->|自旋失败| HL[重量级锁 操作系统 Mutex] HL -->|锁释放| NL end style BL fill:#9f9,stroke:#333,stroke-width:2px style LL fill:#bbf,stroke:#333,stroke-width:2px style HL fill:#f9f,stroke:#333,stroke-width:2px

读图导引:绿色节点是偏向锁——只有一个线程访问时,Mark Word 记录线程 ID,后续该线程进入同步块无需任何同步操作。蓝色节点是轻量级锁——多线程竞争时,线程在栈帧中创建 Lock Record,用 CAS 替换对象的 Mark Word,自旋等待。粉色节点是重量级锁——自旋失败后,对象头指向 Monitor(操作系统互斥量),线程挂起和唤醒需要操作系统介入。

对象头中的 Mark Word

复制代码
| 锁状态   | 25bit          | 4bit   | 1bit(偏向) | 2bit(锁标志) |
|----------|----------------|--------|------------|--------------|
| 无锁     | 对象哈希码     | 分代年龄 | 0         | 01           |
| 偏向锁   | 线程ID + epoch | 分代年龄 | 1         | 01           |
| 轻量锁   | 指向 Lock Record |     |            | 00           |
| 重量锁   | 指向 Monitor   |        |            | 10           |
| GC标记   | 空             |        |            | 11           |

锁升级的详细过程

  1. 无锁 → 偏向锁:第一个线程进入同步块时,CAS 将 Mark Word 的线程 ID 设为自己。后续该线程进入同步块,只需检查 Mark Word 的线程 ID 是否等于自己——不需要 CAS,不需要原子操作

  2. 偏向锁 → 轻量级锁:当另一个线程尝试获取锁时,偏向锁撤销。线程在栈帧中创建 Lock Record,用 CAS 将对象的 Mark Word 替换为指向 Lock Record 的指针。如果 CAS 成功,获得轻量级锁;如果失败,说明有竞争,进入自旋。

  3. 轻量级锁 → 重量级锁:自旋次数超过阈值(默认 10 次,可用 -XX:PreBlockSpin 调整),或者自旋线程数超过 CPU 核数的一半,JVM 认为"自旋的代价大于挂起的代价",将锁膨胀为重量级锁。对象的 Mark Word 被替换为指向 Monitor 的指针,竞争线程被挂起。

面试官视角:问 "synchronized 是可重入锁吗" 的答案是"是"。JVM 在 Monitor 中记录了持有锁的线程和重入次数,同一线程多次进入同步块时重入计数加 1,退出时减 1,到 0 时真正释放。

StampedLock:乐观读的三态锁

ReentrantReadWriteLock 的读锁在高并发下有协调开销,因为需要维护读锁计数。StampedLock 引入乐观读——读操作不加锁,只验证版本号:

java 复制代码
class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();
    
    // 乐观读
    double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();  // 获取版本戳(无锁)
        double currentX = x, currentY = y;    // 读取数据
        if (!sl.validate(stamp)) {            // 验证期间是否被写过
            stamp = sl.readLock();            // 被写过,退化为悲观读锁
            try {
                currentX = x; currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
    
    void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();
        try {
            x += deltaX; y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }
}
graph TD subgraph "StampedLock 三态" OR[乐观读 tryOptimisticRead] -->|validate 成功| SU[使用数据] OR -->|validate 失败| PR[降级为悲观读 readLock] PR -->|读取完成| UR[unlockRead] W[写锁 writeLock] -->|修改数据| UW[unlockWrite] end style OR fill:#9f9,stroke:#333,stroke-width:2px style PR fill:#bbf,stroke:#333,stroke-width:2px style W fill:#f9f,stroke:#333,stroke-width:2px

读图导引:绿色节点是乐观读——无锁获取版本戳,读取数据后验证版本戳是否变化。如果验证失败(蓝色节点),退化为悲观读锁。粉色节点是写锁——获取写锁时版本戳会变化,导致所有未验证的乐观读失效。

StampedLock 的限制

  1. 不支持重入:获取锁后不能再次获取(会导致死锁)
  2. 不支持条件变量:没有 Condition 支持
  3. 必须配对使用tryOptimisticRead 必须配合 validatereadLock 必须配合 unlockRead
  4. 线程安全但非可重入:同一个线程不能同时持有读锁和写锁

暗面StampedLock 的乐观读如果验证失败再降级为悲观读,中间的"无锁读取 + 验证失败 + 重新加锁"路径比直接用 ReentrantReadWriteLock 更复杂。只有在"读多写少且读操作极快"的场景,乐观读才能体现优势。

实战/源码

AQS 实现一个简单的互斥锁

java 复制代码
public class SimpleMutex extends AbstractQueuedSynchronizer {
    // state = 0 表示未锁定,state = 1 表示已锁定
    
    protected boolean tryAcquire(int acquires) {
        if (compareAndSetState(0, 1)) {
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        }
        return false;
    }
    
    protected boolean tryRelease(int releases) {
        if (getState() == 0) throw new IllegalMonitorStateException();
        setExclusiveOwnerThread(null);
        setState(0);
        return true;
    }
    
    public void lock() { acquire(1); }
    public void unlock() { release(1); }
}

这个 20 行不到的代码,借助 AQS 的模板方法,就实现了一个功能完整的互斥锁(可排队、可重入检测、可中断、可超时)。这就是 AQS 的强大之处。

synchronized 的锁消除与锁粗化

JIT 编译器对 synchronized 有两项优化:

锁消除(Lock Elimination):如果编译器确定某段代码不可能被多线程访问,就消除同步:

java 复制代码
public String concat(String s1, String s2) {
    StringBuffer sb = new StringBuffer();  // sb 不会逃逸出方法
    sb.append(s1).append(s2);              // append 是 synchronized 方法
    return sb.toString();
    // JIT 发现 sb 只被当前线程使用,消除 append 内部的 synchronized
}

锁粗化(Lock Coarsening):如果相邻的同步块使用同一个锁,合并成一个:

java 复制代码
// 优化前:反复获取/释放锁
for (int i = 0; i < 100; i++) {
    synchronized (lock) { list.add(i); }
}

// 优化后:一次性持有锁
synchronized (lock) {
    for (int i = 0; i < 100; i++) { list.add(i); }
}

ReadWriteLock 的正确使用模式

java 复制代码
class Cache {
    private final Map<String, Object> map = new HashMap<>();
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();
    
    public Object get(String key) {
        r.lock();
        try {
            return map.get(key);
        } finally {
            r.unlock();
        }
    }
    
    public void put(String key, Object value) {
        w.lock();
        try {
            map.put(key, value);
        } finally {
            w.unlock();
        }
    }
}

常见错误:在读锁内尝试获取写锁会导致死锁(ReentrantReadWriteLock 不支持锁升级)。如果读操作发现缓存未命中需要加载数据,应该释放读锁后再获取写锁(或者使用 StampedLock)。

常见问题

Q1:AQS 的 Node 队列是 CLH 队列还是 MCS 队列?

CLH 队列的变体。原版 CLH 中每个线程自旋检查前驱节点的状态;AQS 的变体在自旋一定次数后挂起线程(LockSupport.park),由前驱释放时唤醒(unpark),而不是无限自旋。

Q2:synchronized 和 ReentrantLock 怎么选?

维度 synchronized ReentrantLock
实现 JVM 内置 JDK 类库
功能 基本互斥 可中断、可超时、公平、多 Condition
性能 JDK6+ 优化后接近 与 synchronized 相当
灵活性 低(代码块级别) 高(手动 lock/unlock)
适用 简单同步场景 需要高级功能的场景

JDK6 之前 ReentrantLock 性能明显优于 synchronized;JDK6+ 后 synchronized 引入锁升级,两者性能差距很小。简单场景优先用 synchronized,复杂场景用 ReentrantLock

Q3:偏向锁在 JDK15+ 被废弃了,为什么?

偏向锁的撤销(revoke)成本很高——当第二个线程竞争时,需要安全点(Safepoint)操作来修改对象头。现代应用中,多线程竞争是常态,偏向锁的收益(单线程无竞争时的零开销)远小于代价(撤销时的停顿)。JEP 374 在 JDK15 中默认禁用偏向锁,JEP 477 在 JDK21 中彻底移除。

Q4:StampedLock 的乐观读什么时候会失败?

当乐观读获取版本戳(stamp)后、验证(validate)前,如果有线程获取了写锁并修改了数据,版本戳就会变化,validate 返回 false。乐观读失败后需要降级为悲观读锁重新读取。

Q5:为什么 AQS 用 int 的 state 而不是 long?

因为 32 位 int 的 CAS 操作在大多数 CPU 上是原生的(一条指令)。64 位 long 的 CAS 在 32 位 JVM 上需要拆成两条指令,原子性保证更复杂。ReentrantReadWriteLock 用高 16 位存读锁数、低 16 位存写锁数,证明了 32 位足够表达丰富的语义。

总结

AQS 与锁的设计,体现了"统一抽象 + 灵活扩展"的架构智慧:

  • AQS 的核心state + FIFO 队列。通过模板方法模式,子类只需实现 tryAcquire/tryRelease,就能获得完整的排队、阻塞、唤醒机制
  • ReentrantLock 的公平/非公平:非公平锁允许插队,吞吐量更高;公平锁保证 FIFO,避免饥饿。默认非公平是工程上的最优选择
  • synchronized 的锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。JIT 还会做锁消除和锁粗化优化。JDK15+ 偏向锁已废弃
  • ReentrantReadWriteLock:读读共享、读写互斥,但读锁的计数器维护在高并发下成为热点
  • StampedLock:用乐观读(无锁 + 版本验证)解决读热点问题,但限制更多(不可重入、不支持 Condition)

理解锁机制的关键,是追问:这个锁在什么场景下会膨胀/降级?公平性对吞吐量有什么影响?不加锁的乐观读在什么条件下是安全的? 从"知道锁怎么用"到"懂得锁为什么这样设计",是跨越中级和高级开发者的分水岭。