ReentrantReadWriteLock 读写锁

候选人小何在面试蚂蚁 P7 时,面试官问道:

"ReentrantReadWriteLock 的读写锁是怎么实现的?读锁和写锁会相互阻塞吗?"

小何说:"读锁是共享锁,写锁是独占锁..."面试官追问:"那高并发读场景下,写锁会不会饿死?"

小何说:"可能..."面试官继续追问:"JDK 8 的 StampedLock 解决了什么问题?乐观读是什么?"

小何答不上来了...

一、核心问题:读写锁原理 🔴

1.1 问题拆解

第一层:设计动机(为什么需要?)
  "为什么需要读写锁?它解决了什么问题?"
  考察点:读多写少场景、读写冲突模型

第二层:AQS 状态分片(怎么实现?)
  "ReentrantReadWriteLock 如何用 AQS 的 state 表示读写锁状态?"
  考察点:状态分片、高16位读锁计数、低16位写锁计数

第三层:写锁饥饿(有什么问题?)
  "读写锁中,写锁会饿死吗?"
  考察点:读写锁的公平性、写锁优先策略

第四层:StampedLock(怎么进化?)
  "JDK 8 的 StampedLock 是什么?乐观读比读写锁好在哪?"
  考察点:版本号戳、乐观读、WriteReadLock 的区别

1.2 ❌ 错误示范

候选人原话 A:"读写锁允许多个线程同时读,所以比 ReentrantLock 性能好。"

问题诊断:读写锁的性能优势只在读多写少场景下成立。在写多读少读写均衡场景下,读写锁的开销(读锁计数器管理、写锁的独占获取)反而比普通互斥锁更差。

候选人原话 B:"ReentrantReadWriteLock 默认是公平的,不会饿死。"

问题诊断:默认是非公平的。但即使公平模式下,写锁仍然可能"相对饿死"——因为读锁是共享的,一个写锁持有者释放后,可能同时被多个等待的读锁获取,写锁需要再次等待。

1.3 标准回答

P5 级别:读写锁的概念

读写锁的设计动机

在读多写少场景中(如缓存、配置、只读数据),大量读操作之间互相不冲突,如果用互斥锁会导致无谓的等待。读写锁允许并发读,只将写操作串行化

ReentrantReadWriteLock 的特性

  • 可重入:读锁和写锁分别可重入(同一线程可以先获取读锁,再获取写锁;或先获取写锁,再获取读锁)
  • 锁降级:持有写锁时获取读锁(锁降级)
  • 锁升级:持有读锁时尝试获取写锁(不支持——会导致死锁)
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

// 锁降级:写锁 → 读锁(安全)
writeLock.lock();
try {
    doWrite();
    readLock.lock();   // 写锁降级为读锁
    try {
        doRead();
    } finally {
        readLock.unlock();  // 只释放读锁
    }
} finally {
    writeLock.unlock();
}

P6 级别:AQS 状态分片

如何用单个 int state 表示读写锁

ReentrantReadWriteLock 将 AQS 的 state 字段(int)分成两部分:

  • 高 16 位:读锁计数(共享锁计数)
  • 低 16 位:写锁计数(独占锁计数,重入计数)
// 获取读锁计数
static int sharedCount(int c) { return c >>> 16; }

// 获取写锁计数
static int exclusiveCount(int c) { return c & 0xFFFF; }

// state = 0x00010001 = 读锁:1, 写锁:1
// 高16位: 0x0001 = 1 个读锁
// 低16位: 0x0001 = 1 次重入

写锁的 tryAcquire()

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);  // 写锁计数

    if (c != 0) {  // 已有锁
        if (w == 0 || current != getExclusiveOwnerThread()) {
            return false;  // 有读锁,或写锁被其他线程持有
        }
        // 当前线程持有写锁 → 重入
        if (w + acquires > (1 << 16) - 1) throw new Error("Maximum lock count exceeded");
        setState(c + acquires);
        return true;
    }
    // 无锁:检查是否需要阻塞(公平锁检查队列)
    if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) {
        return false;
    }
    setExclusiveOwnerThread(current);
    return true;
}

读锁的 tryAcquireShared()

protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState();
    if (exclusiveCount(c) != 0 &&  // 有写锁持有者
        getExclusiveOwnerThread() != current) {
        return -1;  // 获取失败
    }
    // 读锁计数
    int r = sharedCount(c);
    if (!readerShouldBlock() &&  // 检查是否需要阻塞读
        r < SHARED_UNIT &&       // SHARED_UNIT = 1 << 16
        compareAndSetState(c, c + SHARED_UNIT)) {
        // 第一个获取读锁的线程:设置 firstReader, firstReaderHoldCount
        return 1;
    }
    // 重入读锁,或 CAS 失败(自旋)
    return fullTryAcquireShared(current);
}

P7 级别:写锁饥饿与 StampedLock

写锁饥饿问题

即使使用公平锁,写锁也可能"相对饿死":

时间线:
T1: 线程A 获取写锁(state: W=1)
T2: 线程B,C,D 请求读锁(进入等待队列)
T3: 线程A 释放写锁(state: W=0)
T4: 线程B,C,D 同时获取读锁(state: R=3)
T5: 线程E 请求写锁(等待)
T6-Tn: 更多读请求到达,读锁一直被持有

在这个场景中,线程 E 的写锁可能无限期等待。ReentrantReadWriteLock 提供了写锁优先选项:

// 写锁优先的读锁获取
ReentrantReadWriteLock rw = new ReentrantReadWriteLock(true);  // 公平模式
// 公平模式下,新读请求检查 hasQueuedPredecessors(),
// 如果队列中有等待的写锁,新读锁请求会被阻塞

StampedLock(JDK 8)

StampedLock 提供了乐观读模式,解决读锁的性能问题:

StampedLock sl = new StampedLock();

// 悲观读(类似普通读锁)
long stamp = sl.readLock();
try {
    // 读取数据
} finally {
    sl.unlockRead(stamp);
}

// 乐观读(不阻塞,性能更高)
long stamp = sl.tryOptimisticRead();  // 获取乐观版本号
// 读取数据
if (!sl.validate(stamp)) {  // 检查是否被写锁修改
    // 被修改:降级为悲观读
    stamp = sl.readLock();
    try {
        // 重新读取
    } finally {
        sl.unlockRead(stamp);
    }
}

StampedLock 的优势

对比ReentrantReadWriteLockStampedLock
读锁获取需要 CAS 操作tryOptimisticRead 直接返回版本号(无 CAS)
乐观读不支持支持
写锁饥饿存在存在
模式读/写读/写/乐观读

【面试官心理】 这道题我能问到 P7 级别,是因为它涉及了 AQS 状态管理、读写锁的设计权衡、StampedLock 的版本号机制等多个层次。能说出 StampedLock 的乐观读验证机制(validate)的候选人说明他理解了版本号机制。能正确比较读写锁和 StampedLock 的候选人说明他有工程判断力。

1.4 追问升级

追问 1:为什么读锁是共享锁但写锁持有时读锁不能获取?

因为读锁需要看到一致的数据。如果写锁持有者在修改数据(尚未提交),读锁获取到的数据可能是半成品。

写锁的独占性保证了:当写锁释放后,所有等待的读锁同时获取,它们看到的都是一致的完整数据。

追问 2:StampedLock 的乐观读能完全替代读写锁吗?

不能。StampedLock 的乐观读只检查版本号,不加任何锁。如果在 tryOptimisticRead()validate() 之间有写锁获取/释放,乐观读会失败(validate 返回 false)。但乐观读失败后降级为悲观读的开销比直接用读写锁更大(因为需要处理版本号转换)。

StampedLock 更适合"读多写少、写操作轻量"的场景。

二、StampedLock 的实现细节 🟡

2.1 乐观读的性能优势

乐观读不需要 CAS 操作,直接返回一个版本号:

public long tryOptimisticRead() {
    return getState();  // 直接读取 state(版本号)
}

public boolean validate(long stamp) {
    // 检查 state 是否变化
    return (getState() & SBITS) == (stamp & SBITS);
}

性能差异:tryOptimisticRead() 约 510 纳秒,readLock() 的 CAS 约 3050 纳秒。

2.2 StampedLock 的坑

StampedLock sl = new StampedLock();

// 错误:忘记将 stamp 传递给 unlock
long stamp = sl.readLock();
try {
    // 业务
} finally {
>    sl.unlock(stamp);  // ❌ stamp 不是 writeLock 的 stamp
}

StampedLock 有三种 unlock 方法:unlockRead()unlockWrite()unlock()。必须使用对应类型的 unlock。

三、生产避坑

3.1 读写锁的写锁饿死

场景:缓存系统使用读写锁,高并发读 + 间歇性写,写锁长时间无法获取。

解决方案:使用 StampedLock 的乐观读 + 写锁;或在业务层面限制读锁持有时间。

3.2 锁升级死锁

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

// 锁升级:读锁 → 写锁(死锁!)
readLock.lock();
try {
    writeLock.lock();  // 死锁!当前线程持有读锁,写锁是独占的
} finally {
    readLock.unlock();
    writeLock.unlock();
}

解决方案:先释放读锁,再获取写锁(但中间有窗口期);或使用 StampedLock。

💡

面试加分点:能说出"JDK 15 将 StampedLock 的 longueur() 方法标记为 deprecated(因为它返回的是容量而非长度,容易误用)",说明他对 JDK 的演进有实际关注。

⚠️

面试陷阱:被问到"StampedLock 支持重入吗",很多人会说"不支持"。准确答案是:StampedLock 支持有限的重入——读锁可重入获取读锁,写锁可重入获取写锁,但读锁不可重入获取写锁(不支持锁升级)。