Lesson 17 · 并发编程

读写锁 ReentrantReadWriteLock 与 StampedLock

高级·#并发·#

第 1 站

面试官:读多写少,用什么锁效率最高?

面试官问:"一个配置中心,99% 的操作是读配置,只有 1% 是更新配置。用什么锁来保证线程安全?"

如果你的回答是 ReentrantLock——那所有的读操作也会被串行化。明明两个线程只是看看数据、根本没修改,却还要排队拿锁。这就像高速公路只有一个 ETC 通道,所有车都得停下来刷卡。

有没有一种锁,允许多个线程同时读,但写的时候仍然互斥?——这就是读写锁(ReadWriteLock)的核心思想。

读写锁把锁分成两把:读锁(共享锁)写锁(排他锁)。读与读之间不互斥,读与写互斥,写与写互斥。对于读多写少的场景,吞吐量可以提升一个数量级。

今天我们从 ReentrantReadWriteLock 的源码讲起,再看 JDK 8 引入的 StampedLock 如何用乐观读进一步突破性能天花板。

第 2 站

ReentrantReadWriteLock:一个 int 拆成两把锁

ReentrantLock 用 AQS 的一个 int state 表示锁状态。ReentrantReadWriteLock 更巧妙——把同一个 int 拆成高 16 位和低 16 位,高 16 位存读锁计数,低 16 位存写锁计数:

AQS state(32 位 int)拆分为读计数 + 写计数 高 16 位(bit 31~16) 读锁持有计数 低 16 位(bit 15~0) 写锁重入计数 unsignedRightShift(state, 16) state & 0x0000FFFF 最大 65535 个并发读者 写锁最大重入 65535 次
图 1AQS state 的高 16 位存储读锁计数,低 16 位存储写锁计数——一个 int 同时管理两把锁

来看源码中的位运算常量和核心方法:

ReentrantReadWriteLock.Sync · 状态拆分与位运算
// 位运算常量
static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = 1 << SHARED_SHIFT; // 0x00010000 = 65536
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF = 65535
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 同上,低16位掩码

// 从 state 中提取读锁计数(高 16 位无符号右移)
static int sharedCount(int c) {
    return c >>> SHARED_SHIFT;
}

// 从 state 中提取写锁计数(低 16 位 & 掩码)
static int exclusiveCount(int c) {
    return c & EXCLUSIVE_MASK;
}

三条核心规则:

读写锁三条规则
组合是否互斥说明
读 — 读不互斥多个读者可同时持有读锁,sharedCount +1
读 — 写互斥有读者时写者阻塞,有写者时读者阻塞
写 — 写互斥同一时刻只有一个写者
tryAcquire() · 写锁获取的关键判断
protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c); // 提取低 16 位:写锁计数

    if (c != 0) {
        // c != 0 说明有人持锁(读或写)
        // w == 0 说明写锁计数为 0 → 那就是有读者 → 写者必须等
        // current != getExclusiveOwnerThread() → 不是自己重入 → 也等
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        setState(c + acquires); // 写锁重入
        return true;
    }
    // c == 0 → 无人持锁,尝试 CAS 抢占写锁
    if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}
为什么用位拆分而不是两个 int?

因为 AQS 的 compareAndSetState() 只能原子更新一个 int。如果把读计数和写计数存在两个变量里,就需要两次 CAS 才能保证一致性,这在并发环境下会产生竞态条件。用一个 int 的两个半区,一次 CAS 就能同时更新读写状态。

第 3 站

锁降级:写锁 → 读锁 → 释放写锁

面试中有一个高频追问:"持有写锁的线程,能不能不释放写锁就直接获取读锁?"

答案是:可以。这叫锁降级(Lock Downgrade)——从权限更高的写锁降级到权限更低的读锁。Javadoc 中明确说明这是被允许的。

锁降级的正确顺序
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

// ① 获取写锁
rwl.writeLock().lock();
try {
    // 修改共享数据
    data.update(newValue);
} finally {
    // ③ 释放写锁(在此之前必须先获取读锁!)
    rwl.writeLock().unlock();
}

// ② 在释放写锁之前获取读锁
rwl.readLock().lock();
try {
    // 以只读方式使用刚写入的数据
    return data.get();
} finally {
    rwl.readLock().unlock(); // ④ 最终释放读锁
}
为什么要先拿读锁再释放写锁,而不是反过来?

如果在释放写锁之后、获取读锁之前,有另一个写线程抢到了写锁并修改了数据,那当前线程读到的就是被别人改过的数据,不是自己刚写入的值。先拿读锁,等于在释放写权限的同时仍然"占住"读权限,保证数据的连续性。

但反过来——锁升级(读锁 → 写锁)是不允许的

tryAcquire() · 写锁获取时读锁计数的判断
// 在 tryAcquire 中有这个判断:
if (c != 0) {
    if (w == 0 || current != getExclusiveOwnerThread())
        return false; // ← 如果有读者(w==0 但 c!=0),直接失败
}
// 即使当前线程持有读锁,w == 0 依然成立 → 获取写锁失败
为什么不允许锁升级?

假设允许锁升级,考虑两个线程 T1、T2 都持有读锁,然后同时尝试升级为写锁——两个线程都在等对方释放读锁,死锁。即使只有一个读者,升级也需要先释放读锁,而释放和获取之间有一个"窗口期",其他写线程可能趁虚而入修改数据。所以 JDK 的设计是:想从读变成写?先释放读锁,再竞争写锁。

第 4 站

写锁饥饿:读者源源不断,写者永远排队

读写锁引入了一个新问题——写者饥饿(Writer Starvation)

设想一个场景:缓存系统每秒有 1000 个读请求,每 10 秒才来一个写请求。在非公平模式下,只要有读锁被持有,新的读请求就能不断获取读锁。写锁?永远排不上号——因为读锁从来不为空。

NonfairSync.tryReadLock() · 非公平模式下的读者获取
final boolean tryReadLock() {
    Thread current = Thread.currentThread();
    for (;;) {
        int c = getState();
        // 关键判断:写锁被别的线程持有时,读者才需要排队
        if (exclusiveCount(c) != 0 &&
            getExclusiveOwnerThread() != current)
            return false; // 有写者 → 读者阻塞

        int r = sharedCount(c);
        if (r == MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            // CAS 成功,读锁 +1
            ...
            return true;
        }
    }
}

注意看:非公平模式下,读者不会检查是否有写者在排队。只要没有活跃的写锁,读者就直接拿锁。这就导致写者可能被无限延迟。

公平模式如何解决?

FairSync · 公平模式的 writerWaiting 检查
// 公平模式的写锁获取 —— 前面有人排队就阻塞
final boolean writerShouldBlock() {
    return hasQueuedPredecessors(); // AQS 队列中有人在我前面
}

// 公平模式的读锁获取 —— 前面有写者在排队就阻塞
final boolean readerShouldBlock() {
    return hasQueuedPredecessors(); // 队列中有写者排在我前面 → 读者也让路
}
公平 vs 非公平 对比
模式读者行为写者饥饿吞吐量
非公平(默认)直接尝试获取,不看队列可能饥饿(减少线程切换)
公平检查队列前面是否有写者不会饥饿低(频繁线程切换)

如果你的业务场景中写操作不能丢、不能无限延迟,可以选择公平模式:new ReentrantReadWriteLock(true)。但公平模式的代价是吞吐量下降 30%~50%,因为频繁地在读者和写者之间切换。

第 5 站

StampedLock:乐观读——先读后验,零开销读

ReentrantReadWriteLock 的读锁虽然允许多个读者并发,但每个读者仍然需要CAS 修改 state 的高 16 位——这意味着读操作本身也有锁竞争的开销。

JDK 8 引入的 StampedLock 提供了一种更激进的模式:乐观读(Optimistic Read)——读的时候根本不加锁,只是记住一个"版本号"(stamp),读完之后验证版本号是否变化。如果没变,说明读到的数据是一致的;如果变了,再升级为悲观读锁。

StampedLock 乐观读流程:先读后验,失败再升级 tryOptimisticRead() 不加锁,直接读数据 validate(stamp)? true 返回数据 零锁开销 ✓ false readLock() 悲观读 重新读取 + 返回
图 2StampedLock 乐观读:无锁读取 → 验证版本号 → 成功直接返回,失败升级为悲观读锁

来看标准用法:

StampedLock 乐观读用法 · 一个坐标点示例
public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    // 写操作
    public void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp); // 释放写锁
        }
    }

    // 乐观读 —— 零锁开销
    public 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);
    }
}

关键要点:tryOptimisticRead() 返回的 stamp 不是锁,只是一个"快照版本号"。如果在此期间有写操作修改了 state,版本号就会变化,validate() 返回 false,此时需要升级为悲观读锁重新读取。

StampedLock 的三种模式
模式API互斥开销
写锁(Write)writeLock()与读、写都互斥CAS + 排他
悲观读锁(Read)readLock()与写互斥,读之间不互斥CAS + 共享
乐观读(Optimistic)tryOptimisticRead()不加锁,仅事后验证零开销(成功时)
第 6 站

StampedLock vs ReentrantReadWriteLock

既然 StampedLock 的乐观读性能这么好,是不是可以全面替换 ReentrantReadWriteLock?先看一张完整对比表:

核心差异一览
特性ReentrantReadWriteLockStampedLock
JDK 版本JDK 5JDK 8
乐观读不支持支持(tryOptimisticRead)
可重入(最大限制)
公平模式支持不支持(只有非公平)
Condition支持不支持
读锁 → 写锁升级不支持支持(tryConvertToWrite)
读操作开销CAS 修改 state乐观读时零开销
API 复杂度简单较复杂(需要管理 stamp)

性能差距:在读多写少(读:写 > 10:1)的场景下,StampedLock 的乐观读比 ReentrantReadWriteLock 快 5~10 倍,因为完全避免了 CAS 竞争。但如果写操作频繁,乐观读频繁失败,反而会因为反复升级而变慢。

StampedLock 最大的坑——不可重入:

StampedLock 不可重入的陷阱
StampedLock sl = new StampedLock();

long stamp = sl.writeLock();
try {
    doSomething(); // 这个方法内部又调用了 sl.writeLock()
    // → 死锁!StampedLock 不支持重入!
} finally {
    sl.unlockWrite(stamp);
}

void doSomething() {
    long s = sl.writeLock(); // 同一个线程再次获取写锁 → 阻塞
    try { ... } finally { sl.unlockWrite(s); }
}
选择决策:什么时候用哪种锁?

记住一个简单的判断框架:

锁选择决策树

1. 写操作为主(写:读 > 1:1)→ ReentrantLock(读写锁没有意义)
2. 读多写少,且需要可重入 / Condition / 公平模式ReentrantReadWriteLock
3. 读多写少,追求极致读性能,不需要可重入 → StampedLock
第 7 站

总结:场景 → 锁 决策表

面试官问"读多写少用什么锁",你的回答应该是:先看场景特征,再选锁。

场景 → 锁 决策表
场景推荐锁原因
本地缓存(配置中心、元数据)StampedLock读远多于写,乐观读零开销,性能最优
简单的读写共享资源ReentrantReadWriteLockAPI 简单,支持可重入和 Condition
写操作为主ReentrantLock读写锁的读锁反而增加开销
需要公平调度ReentrantReadWriteLock(fair)StampedLock 不支持公平模式
读 + 偶尔批量更新StampedLock乐观读 + tryConvertToWrite 原子升级

核心知识点回顾

  • ReentrantReadWriteLock:AQS state 高 16 位存读计数,低 16 位存写计数;一次 CAS 管理两把锁
  • 锁降级:写锁 → 读锁 → 释放写锁——被允许;锁升级(读 → 写)——不允许,因为有死锁风险
  • 写者饥饿:非公平模式下读者持续进入会饿死写者,公平模式通过队列检查解决
  • StampedLock 乐观读:tryOptimisticRead() 不加锁、零开销,validate() 事后验证
  • StampedLock 限制:不可重入、不支持 Condition、不支持公平模式
  • 选型口诀:缓存用 StampedLock,通用读写用 ReadWriteLock,写多不用读写锁
"读多写少用什么锁?" —— 面试标准回答框架:

1. 读写锁思想:读锁共享、写锁排他,读多写少场景大幅提升并发度
2. ReentrantReadWriteLock:AQS state 位拆分实现,支持锁降级但不支持锁升级
3. StampedLock:JDK 8 引入,乐观读零锁开销,读多写少性能最优,但不可重入
4. 选型建议:缓存场景优先 StampedLock,需要可重入或 Condition 时用 ReentrantReadWriteLock