首页 / Java 学习笔记 / 17

JAVA · Vol.II · DAY 17 · 并发编程

读写锁 ReentrantReadWriteLock 与 StampedLock

高级高频#并发#
第 1 站

读多写少:什么锁才是正解?

先看一个真实的线上场景:订单服务的配置中心,99% 的流量是「读配置」,只有 1% 是「更新配置」。某次大促前的压测里,负责人发现:读请求的 P99 从 2ms 一路涨到 200ms,CPU 没打满,线程却全在排队。

根因很典型——代码里用 ReentrantLock 保护配置对象。它的锁是互斥锁:两个线程只是看看数据、根本没修改,也得排队拿锁。这就像高速公路只有一个 ETC 通道,所有车都得停下来刷卡——明明是「读多写少」,却把读请求全部串行化了。

有没有一种锁,允许多个线程同时读,但写的时候仍然互斥?

有,这就是读写锁(Read-Write Lock),是今天这篇的主角之一;而 JDK 8 又带来了一把更激进的 StampedLock,用乐观读(Optimistic Read)把读路径的锁开销压到接近零。先给你一个 30 秒版答案,后面每一站都是在给这句话「上细节」:

一句话点破

读多写少场景,用 ReentrantReadWriteLock:读锁是共享锁(Shared Lock),多个读者并发;写锁是排他锁(Exclusive Lock),写者独占。若读流量压倒性占优(读:写 > 10:1)且不需要可重入,用 StampedLock 的乐观读,读路径连 CAS 都省了。写多为王时,两者都别用,老老实实 ReentrantLock

在正式开始之前,先抛几个面试官最爱挖的坑,带着问题读效率更高:

  • 读写锁的读锁到底能不能「同时被多线程持有」?写锁拿到后,别的线程还能读吗?
  • 「锁降级」是什么?为什么允许写锁转读锁,却绝对禁止读锁转写锁?
  • 读写锁会不会把写者饿死?非公平模式真的「完全不看队列」吗?
  • StampedLock 的 stamp 到底是个什么东西?为什么它不支持重入?

带着这些问题,我们从 ReadWriteLock 的规则讲起,一路拆到 StampedLock 的源码。

第 2 站

读写锁的三条规则与 ReadWriteLock 接口

把锁拆成两把——读锁(共享锁,Shared Lock)和写锁(排他锁,Exclusive Lock)——规则其实只有三条:

读写锁三条规则
组合是否互斥说明
读 — 读不互斥多个读者可同时持有读锁,读锁计数 +1
读 — 写互斥有读者时写者阻塞;有写者时读者阻塞
写 — 写互斥同一时刻只有一个写者

用图书馆类比记这三条规则:读者们各自看书互不打扰(读读共享);管理员要整理书架时,所有读者都得先让开(读写互斥),而且一次只能有一个管理员在整理(写写互斥)。只要把「整理书架」想成「写数据」,这三条规则就永远忘不掉。

ReadWriteLock 是 JDK 5 就有的接口,极其简单,只有两个方法:

ReadWriteLock.java · 接口定义
public interface ReadWriteLock {
    Lock readLock();    // 返回读锁(共享锁)
    Lock writeLock();   // 返回写锁(排他锁)
}

注意:锁是由同一个 ReadWriteLock 实例绑定的。A 线程拿着 readLock() 的锁、B 线程拿着 writeLock() 的锁,它们之间互斥与否,取决于这两个 Lock 对象是否出自同一个 ReadWriteLock。就像同一扇门的两把钥匙,只有配套的那把才能开——不同实例的锁互不干涉。

面试破题:为什么要把锁拆成两把?

因为读操作之间天然无冲突。互斥锁把「读读」也挡在门外,是拿吞吐量换简单。读写锁把冲突矩阵从「全互斥」放宽成「读读共享」,在读多写少的场景下,吞吐量可以提升一个数量级——这是它存在的全部意义。面试答到这一层,面试官就知道你理解的是「为什么」,而不是「是什么」。

读写锁最经典的实现是 ReentrantReadWriteLock,它也是 AQS 家族的一员。下一站看它如何用一个 int 同时管理两把锁。

第 3 站

ReentrantReadWriteLock:一个 int 拆成两把锁

上一站讲过 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;
}

从这里能读出两个硬数字,面试常考:

  • 最大并发读者 65535:高 16 位最多表示 2^16 - 1 个读者,再想进会抛 Error("Maximum lock count exceeded")
  • 写锁最大重入 65535 次:低 16 位同理,同一个线程反复拿写锁最多 65535 次。
为什么用位拆分而不是两个 int?

因为 AQS 的 compareAndSetState() 只能原子更新一个 int。如果把读计数和写计数放在两个变量里,就需要两次 CAS 才能同时更新——两次 CAS 之间就有竞态窗口,读者写者同时改各自变量,锁的一致性就破了。塞进一个 int 的两个半区,一次 CAS 就能原子地更新读写两个状态。

第 4 站

写锁获取:tryAcquire 逐行拆解

写锁是排他锁,走 AQS 的独占模式。看 tryAcquire()

ReentrantReadWriteLock.Sync.tryAcquire() · 写锁获取
protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c); // 提取低 16 位:写锁计数

    if (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); // 写锁重入:state +1(低 16 位计数 +1)
        return true;
    }
    // c == 0 → 无人持锁,尝试抢占写锁
    if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}

四个分支对应四种情况:

  • c != 0 且 w == 0:有人持有读锁 → 写者必须等待。这是「读写互斥」在代码里的落点;
  • c != 0 且写锁属于自己:写锁重入,低 16 位计数 +1(最多 65535 次);
  • c == 0 且 writerShouldBlock() 为 false:无人持锁,非公平模式下直接 CAS 抢锁(插队);
  • CAS 失败:有人抢先一步,返回 false 走 AQS 排队。
非公平 vs 公平,只在 writerShouldBlock 上做文章

非公平(默认)的 writerShouldBlock() 直接返回 false——写者来了先 CAS 抢一次,抢不到再排队(这和 ReentrantLock 非公平锁「先抢一次」是同一个套路);公平模式的 writerShouldBlock() 返回 hasQueuedPredecessors()——只要队列里有人排在我前面,就老老实实排队。读锁那边的对应判断在下一站。

第 5 站

读锁获取:tryAcquireShared 与读者插队

读锁是共享锁,走 AQS 的共享模式 tryAcquireShared()。核心逻辑是把 state 的高 16 位加 1:

ReentrantReadWriteLock.Sync.tryAcquireShared() · 读锁获取(简化)
protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    for (;;) {
        int c = getState();
        // ① 写锁被别的线程持有时,读者必须阻塞(读写互斥)
        if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
            return -1;
        // ② 读者数量达到上限 65535 → 抛错
        int r = sharedCount(c);
        if (r == MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // ③ 读者要不要让路?readerShouldBlock 决定(第 7 站细讲)
        if (readerShouldBlock() ||
            !compareAndSetState(c, c + SHARED_UNIT))  // 高 16 位 +1
            return -1;
        // ④ 记录持有读锁的线程(可重入读锁统计用,见第 8 站)
        ...
        return 1; // >0 表示获取成功
    }
}

注意 ① 的判断是「别的线程的写锁」——如果是自己持有写锁,当前线程依然可以获取读锁。这正是第 6 站「锁降级」得以成立的前提。

这里的 SHARED_UNIT = 1 << 16 = 65536:读锁每次 +65536,正好在 state 的高 16 位上进一位;释放时 -65536。「1 个读者」在 state 里就是 65536,和写锁的 1 完美错开——这就是位拆分的优雅之处,读和写各占各的位段,互不干扰。

核心要点
  • 写锁走 AQS 独占模式(tryAcquire),读锁走共享模式(tryAcquireShared),两条路径共用一个 int state;
  • 「读写互斥」「读读共享」的本质:读者只要发现写锁位不为 0(且不是自己持有)就让路,否则直接 CAS 累加读计数;
  • 读锁最大并发读者 65535,写锁最大重入 65535,超出抛 Error。
第 6 站

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

面试高频追问来了:「持有写锁的线程,能不能不释放写锁就直接获取读锁?」

答案是:可以。这叫锁降级(Lock Downgrade)——从权限更高的写锁降级到权限更低的读锁。ReentrantReadWriteLock 的 Javadoc 白纸黑字允许这么做,还给了标准示例。它解决的是缓存更新里最经典的问题:「写完数据,我还要用一下刚写入的值」。看 Javadoc 的 CachedData 示例:

Javadoc 标准示例 · CachedData:更新后降级读回
class CachedData {
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
            // 必须先把读锁释放掉,才能获取写锁(锁升级被禁止)
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                // 双检:可能别的线程已经抢先更新了
                if (!cacheValid) {
                    data = loadFromDB();   // 写数据
                    cacheValid = true;
                }
                // 降级:在释放写锁之前,先拿到读锁
                rwl.readLock().lock();
            } finally {
                rwl.writeLock().unlock();   // 释放写锁,但还持着读锁
            }
        }
        try {
            use(data);                      // 以只读方式使用刚写入的数据
        } finally {
            rwl.readLock().unlock();        // 最后释放读锁
        }
    }
}

关键就是中间那两行:先获取读锁,再释放写锁。这个顺序不能反,为什么?

为什么必须先拿读锁再释放写锁,而不是先释放写锁再拿读锁?
✗ 错误:先释放写锁,再拿读锁 T1 持有写锁,写完数据 T1 释放写锁 —— 无锁窗口! W2 抢到写锁,改掉了数据 T1 拿读锁 → 读到别人改过的数据 ✓ 正确:锁降级,先拿读锁再释放写锁 T1 持有写锁,写完数据 T1 直接获取读锁(必然成功) T1 释放写锁,仍持有读锁 读到自己写入的数据,全程无窗口期
图 2锁降级:先在写锁内拿读锁,再释放写锁,中间不留任何「无锁窗口」给其它写者

先释放写锁、后拿读锁的版本:释放写锁和获取读锁之间有一个无锁窗口。另一个写线程 W2 可能在这个窗口里抢到写锁、改掉数据——当前线程随后拿到读锁读到的,就是被别人改过的数据,不是自己刚写入的值,「写完立即用」的语义就破了。

先拿读锁、再释放写锁的版本:拿读锁时自己还持有写锁,任何其它线程既不能读也不能写,读锁必然获取成功;释放写锁后,当前线程依然持有读锁,保证「自己刚写入的数据」在接下来的读临界区里不会被任何写者改动。这也回答了「锁降级为什么能保证可见性」:数据在写锁临界区内写入、在读锁临界区内读取,两个临界区由同一个线程无缝衔接,中间没有其它写者插入,读到的必然是最新值。

为什么锁升级(读锁 → 写锁)是绝对禁止的?

锁升级的死锁现场

假设允许升级,两个线程 T1、T2 都持有读锁,然后同时想升级成写锁:T1 等 T2 释放读锁,T2 等 T1 释放读锁——互相等待,死锁。即使只有一个读者,升级也要求先释放读锁,释放与获取之间的窗口期照样会被其它写者插足。所以 JDK 的设计是一刀切:想从读变写?先释放读锁,再重新竞争写锁(CachedData 示例里就是这么干的:unlock 读锁 → lock 写锁 → 双检)。

顺便记一个反直觉的事实:写锁可以降级为读锁,读锁永远不能升级为写锁。这是读写锁设计里最常考的考点,也是它与 StampedLock 的一大差异(StampedLock 支持「尝试式」升级,见第 12 站)。

第 7 站

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

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

设想一个缓存系统:每秒 1000 个读请求,每 10 秒才来一个写请求。读者们发现读锁空闲就蜂拥而入,写者却迟迟拿不到写锁——因为只要有读者持锁,写者就必须等;而读者来得太快,读锁几乎从未空下来

先纠正一个流传很广的误解:JDK 8 的非公平模式并不是「完全不看队列」。看源码:

ReentrantReadWriteLock.NonfairSync · JDK 8 的非公平判断
final boolean writerShouldBlock() {
    return false; // 写者:先 CAS 抢一次再说(允许插队)
}

final boolean readerShouldBlock() {
    // 启发式:如果等待队列头部的线程是写者,读者就让路
    // 目的:缓解(但无法根治)写者饥饿
    return apparentlyFirstQueuedIsExclusive();
}

apparentlyFirstQueuedIsExclusive() 的意思是:队列头部若已有一个写者在排队,新来的读者就不许插队,乖乖排队。这比「完全不看队列」好——写者一旦入队,后面的读者会让路,写者最终能拿到锁。

但饥饿仍然会发生,因为存在时间差:读者 A 拿到读锁的瞬间,写者 W 还没入队(它还在自旋 / 唤醒的路上);读者 B、C 趁这个窗口接连拿锁,每个新读者都把读锁的「空窗期」无限后移……极端情况下,写者可能被延迟很久。所以:

  • 想要彻底不饿死写者 → 用公平模式 new ReentrantReadWriteLock(true)readerShouldBlock() 变成 hasQueuedPredecessors(),只要队列里有任何线程排队,读者和写者都让路;
  • 想要吞吐优先 → 默认非公平,接受「写者可能被延迟」的风险;
  • 对写延迟零容忍 → 上 StampedLock(它内部是写优先策略,见第 13 站),或干脆用 ReentrantLock。
公平 vs 非公平 对比
模式读者行为写者饥饿吞吐量
非公平(默认)队列头部无写者时直接插队可能延迟(有启发式缓解)(减少线程切换)
公平队列中有人排队就让路不会饥饿低(频繁线程切换)

如果业务里写操作「不能无限延迟」(比如配置必须及时下发),公平模式是兜底方案,但代价是吞吐下降——读者/写者频繁切换,AQS 队列频繁进出。这就是「公平」与「吞吐」的经典二选一,和 ReentrantLock 的选择逻辑完全一致。

第 8 站

完整 API 与可重入:读锁写锁各有一份

读写锁继承了 Lock 的全部能力:lock()lockInterruptibly()tryLock()tryLock(timeout)unlock()——读锁和写锁各有一份。先看可重入:

可重入示例 · 同一线程反复拿锁
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

// 写锁可重入:同一个线程可以连续拿写锁,计数累加
rwl.writeLock().lock();
rwl.writeLock().lock();   // 低 16 位计数 1 → 2
rwl.writeLock().unlock(); // 2 → 1(还没释放干净)
rwl.writeLock().unlock(); // 1 → 0,锁才真正释放

// 读锁可重入:同一个线程可以连续拿读锁,计数也累加
rwl.readLock().lock();
rwl.readLock().lock();    // 高 16 位计数 +2
rwl.readLock().unlock();
rwl.readLock().unlock();

这里有个读者容易踩的坑:可重入 ≠ 自动配平。每次 lock() 都要配一次 unlock(),少放一次,计数就滞留一次——在长事务、递归方法里尤其容易漏,建议一律用 try/finally 包裹。

再看锁降级后的计数叠加。CachedData 里同一线程在释放写锁前拿了读锁,此时 state 的两个半区同时非零(写计数 1 + 读计数 1)——这是读写锁里唯一的「读写并存」合法状态,且只可能发生在同一线程身上。释放时按 Javadoc 的惯例:先释放写锁,读锁留到最后,让数据在尽可能长的读保护期内不被其它写者触碰。

最后把读写锁的 API 全景列全,注意一个容易被忽略的差异:

API读锁写锁说明
lock()获取锁,拿不到就阻塞
lockInterruptibly()阻塞期间响应中断
tryLock()立即返回,拿不到返回 false
tryLock(timeout)限时等待,超时返回 false
unlock()释放,注意与 lock 配平
newCondition()只有写锁支持条件队列,读锁会抛 UnsupportedOperationException
落点

工作中读锁不支持 newCondition() 是很多人第一次用就踩的坑:想在读路径里等一个条件(比如缓存未就绪时等待),读锁给不了,只能拆成写锁的 Condition 或换 ReentrantLock。API 越全,用起来越要小心。

第 9 站

还不够:读锁也要 CAS,于是 StampedLock 登场

ReentrantReadWriteLock 已经让读者们「并行读书」了,为什么还要 StampedLock?因为读锁本身仍有锁开销:每来一个读者,都要对 state 做一次 CAS(c, c + SHARED_UNIT)——CAS 是原子指令,要写内存、要竞争缓存行,并发读者一多,读者的 CAS 之间也会互相争抢。读路径的竞争只是从「抢整把锁」变成了「抢一个计数器」,并没有消失。

JDK 8 引入的 StampedLock 想得更激进:读的时候干脆不加锁,只记一个「版本号」,读完之后验证版本号有没有变——没变说明读到的数据是一致的,变了再回头加锁重读。这就是乐观读(Optimistic Read),与之相对的普通加锁读叫悲观读(Pessimistic Read)

乐观读的类比:拍照取证

想象你在事故现场拍照取证:先「咔」拍一张(tryOptimisticRead() 拿到一个戳),然后放心大胆地记录现场情况(不加锁直接读数据),记录完再核对一眼:现场有没有在我拍照之后被破坏过?validate(stamp))。没动过 → 我记的东西完全可信;动过了 → 照片作废,回去重拍(升级成悲观读锁重读)。拍照本身不占用现场,谁来拍都行——这就是乐观读「零锁开销」的本质。

另一个角度:乐观读就是乐观锁思想在锁实现上的应用——先干活、后验证,冲突了再重来;悲观读则是悲观锁思想——先拿锁、再干活,彻底排除冲突。数据库的 MVCC 快照读、Redis 的 WATCH、Git 的提交哈希,全是同一套「版本号验证」思想。

乐观读是不是永远更快?——不是。先记着这个结论,第 14 站讲它的代价。
第 10 站

StampedLock 三种模式:写锁 / 悲观读 / 乐观读

StampedLock 把锁拆成三种模式,比读写锁多了一个「乐观读」:

StampedLock 的三种模式
模式获取 API释放 API互斥性读路径开销
写锁(Write)writeLock()unlockWrite(stamp)与读、写都互斥
悲观读锁(Read)readLock()unlockRead(stamp)与写互斥,读之间不互斥CAS + 共享
乐观读(Optimistic)tryOptimisticRead()无需释放,用 validate(stamp) 验证不加锁,仅事后验证零锁开销(成功时)

三个细节值得注意:

  • 所有获取方法都返回一个 long stamp,释放时必须把当初那个 stamp 原样传回(unlockWrite(stamp) / unlockRead(stamp))。因为 StampedLock 没有重入、没有「当前线程」的持有记录,stamp 就是锁的唯一凭证——丢了 stamp,锁就放不掉了;
  • 乐观读不需要释放,它压根没占锁,只留了一个 stamp 待会儿验证;
  • 写锁 / 悲观读锁拿不到时同样会阻塞排队,这点和读写锁一致;区别在读路径的默认选择:读写锁的读是「必须加锁」,StampedLock 的读是「能不加就不加」。

再强调一次 stamp 的语义:stamp 不是锁,是「快照版本号」。拿写锁 / 读锁时,它是这把锁的凭证;拿乐观读时,它是一张「拍照时的版本戳」。三种模式的完整用法,下一站用官方示例讲透。

第 11 站

乐观读标准用法:Point 示例逐行拆解

先看乐观读的完整流程:

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

这是 StampedLock Javadoc 里的标准示例(略有精简)——一个二维坐标点,写操作 move(),读操作 distanceFromOrigin()

Point.java · 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);
    }
}

执行顺序 ①→⑥,理解三个关键点:

  • 为什么要「先读后验证」而不是「先验证后读」:验证必须覆盖整个读取窗口。先拍照(①)再读(②)再验证(③),只要 ② 期间有任何写锁发生,③ 就能发现;如果先验证再读,验证之后、读取之前照样可能有写者插入,验证就白做了;
  • validate(stamp) 什么时候返回 false:从拍照到验证之间,只要发生过一次写锁的获取或释放,stamp 对应的版本号就变了,验证失败。所以乐观读的一致性是事后确认的——要么证明没被写过,要么重读;
  • 双字段的一致性:x、y 是两个独立字段,乐观读读它们「可能读到一半被写」——这正是 validate 要兜底的:写者一旦动过任何一个字段,整个快照作废重读。这也是为什么乐观读要求读操作快、写操作少,否则重读频繁,反而更慢。
面试追问:乐观读的 stamp 会不会是 0?

会。tryOptimisticRead() 在「当前有写锁被持有」时会返回 0(0 是无效戳),此时 validate(0) 一定返回 false,代码自然走悲观读分支——这是设计好的兜底,不是 bug。所以标准写法里不需要单独判断 stamp 是否为 0,直接交给 validate 即可。

第 12 站

锁模式转换:tryConvert 三兄弟

StampedLock 比读写锁多了一大能力:三种模式之间可以互相「尝试转换」,而且是原子操作。三个方法:

方法语义成功失败
tryConvertToWriteLock(stamp)读锁 / 乐观读 → 写锁返回新 stamp返回 0
tryConvertToReadLock(stamp)写锁 / 乐观读 → 读锁(降级)返回新 stamp返回 0
tryConvertToOptimisticRead(stamp)写锁 / 读锁 → 乐观读返回新 stamp返回 0

重点来了:tryConvertToWriteLock 让「读 → 写」升级成为可能,而 ReentrantReadWriteLock 是绝对禁止升级的。为什么 StampedLock 敢?因为它是「尝试式」的,绝不阻塞:

  • 当前线程持有写锁,想转读锁:必然成功(降级本来就是写锁的权限);
  • 当前线程持有读锁,想升级写锁:只有此刻没有其它读者(读计数恰好只有自己)才成功,否则返回 0;
  • 返回 0 意味着转换失败,锁还维持原状态,调用方自己决定下一步(比如释放旧锁后重新获取)。

它天然避开了升级死锁:升级不成功就返回 0,绝不阻塞等待——不会出现「两个读者互相等对方释放读锁」的场面。

生产场景 · 缓存刷新:先乐观读,发现过期再尝试升级
private final StampedLock sl = new StampedLock();
private String config = "v1";

public String getConfig() {
    long stamp = sl.tryOptimisticRead();
    String cur = config;
    if (!sl.validate(stamp)) {
        // 乐观读失败:尝试直接把「乐观读」升级成「写锁」
        stamp = sl.tryConvertToWriteLock(stamp);
        if (stamp != 0L) {
            // 升级成功:此刻没有其它读者,安全重算
            try {
                config = reloadFromRemote(); // 重新加载
                return config;
            } finally {
                sl.unlockWrite(stamp);
            }
        } else {
            // 升级失败:有其它读者,退回标准「读锁重读」流程
            stamp = sl.readLock();
            try { return config; }
            finally { sl.unlockRead(stamp); }
        }
    }
    return cur;
}

这个模式把「读 → 写」的升级成本降到了最低:绝大多数请求乐观读一把梭,只有版本失效且恰好无竞争时才升级成写锁重算。对照读写锁:它只能「释放读锁 → 竞争写锁 → 双检」,多一步释放和竞争,还可能在窗口期被别人抢先。

落点

StampedLock 的转换方法很适合「读为主、偶尔需要就地升级写」的缓存 / 配置场景。但要记住:转换是「尝试」,必须检查返回值是否为 0,并准备好降级路径——这是 StampedLock 比读写锁 API 复杂、容易写错的地方。

第 13 站

StampedLock 内部原理:一个 long 里装版本号

StampedLock 没有复用 AQS(它自己实现了一套 CLH 队列),核心是一个 64 位的 long state。JDK 8 的实现把 state 拆成三部分:

StampedLock 的 state(64 位 long) 低 7 位 读锁计数 0~126 bit 7 写锁位(值128) 高 56 位:版本号(每发生一次写锁获取/释放,就 +128) validate 就是比较高 57 位(bit7 及以上)有没有变 读锁获取:state += 1(低 7 位计数 +1,最多 126,超出走 readerOverflow 溢出字段) 写锁获取:state += 128(bit7 置 1);写锁释放:state += 128(bit7 复位并进位,版本号 +1) validate(stamp):(stamp & ~127) == (state & ~127) —— 只比较高 57 位 读者只动低 7 位,validate 不受影响;任何写锁动作都会让高 57 位变化,validate 立刻失效
图 4StampedLock 的 state:低 7 位是读锁计数、bit7 是写锁位、高 56 位是版本号——乐观读的「验证」本质上是版本号比较

为什么乐观读验证这么快?因为 validate() 里就是一次 volatile 读 + 位与比较,没有任何原子指令、没有锁。对比一下三种读路径的成本:

读路径核心操作开销量级
乐观读(成功)volatile 读 state + 位比较最轻(几纳秒)
悲观读锁CAS 修改 state + 排队检查中等(原子指令 + 缓存行竞争)
ReentrantReadWriteLock 读锁CAS 修改 state 高 16 位与悲观读锁同量级

另外 StampedLock 的排队策略是写优先(writer preference):新来的读者如果发现等待队列头部是写者,会让路排队,而不是插队——这是它比 ReentrantReadWriteLock 非公平模式「对写者更友好」的原因,也是官方文档建议优先考虑它的底气之一。

一个小数字

低 7 位只能数到 127,所以读锁计数到 126(RFULL)后,再多出来的读者会进入一个单独的 readerOverflow 字段——它用一个小自旋锁保护,只在极端并发读者下才被触达。面试提到这个细节,说明你真的读过源码。

第 14 站

StampedLock 的四大坑:为什么它「不好用」

性能这么香,为什么生产里 StampedLock 用得不算多?因为它有一堆限制,每一个都是坑:

坑 1:不可重入——重入就是死锁

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

long stamp = sl.writeLock();
try {
    helper(); // helper 内部又调用了 sl.writeLock()
    // → 同一个线程第二次拿写锁,直接阻塞 → 死锁!
} finally {
    sl.unlockWrite(stamp);
}

void helper() {
    long s = sl.writeLock(); // 同一线程再次获取 → 永远等不到自己释放
    try { ... } finally { sl.unlockWrite(s); }
}

ReentrantReadWriteLock 同一线程可以反复拿锁、计数累加;StampedLock 完全没有重入计数,第二次获取就把自己阻塞住了。这要求写代码时必须保证「锁内不再调用需要同一把锁的方法」——方法嵌套深了非常容易踩。

坑 2:不支持 Condition

StampedLock 没有 newCondition()。需要「条件等待 / 唤醒」的场景(比如队列空时等待生产者),它直接出局,只能用 ReentrantLock 或 ReentrantReadWriteLock(注意后者也只有写锁支持 Condition,读锁不支持,见第 8 站)。

坑 3:不支持公平模式,且 lock() 不可中断

StampedLock 只有一种排队策略(写优先),无法像 ReentrantReadWriteLock 那样构造时选公平 / 非公平。另外 readLock() / writeLock()不可中断的——阻塞期间响应不了中断;要可中断必须显式用 readLockInterruptibly() / writeLockInterruptibly()

坑 4:乐观读不是万能的——它读的是「快照一致性」

乐观读保证的是「读取窗口内没有写锁发生过」,但有两个前提你得自己把握:

  • 多字段快照:Point 例子里 x、y 一起读没问题(validate 兜底);但如果读的是引用对象的内部可变状态(比如一个 HashMap 里的多个条目),乐观读只保护「引用本身」,不保护对象内部——这种场景要么用悲观读锁,要么把对象设计成不可变快照;
  • 写频繁时乐观读反而更慢:validate 频繁失败 → 频繁升级悲观读 → 锁开销 + 重读开销双重叠加。乐观读的甜区是读:写 > 10:1 且读操作快;写多场景老老实实用悲观锁。
落点

StampedLock 适合:本地缓存、配置中心、统计值等读压倒性多于写、不需要重入和 Condition 的场合;不适合:方法嵌套深的业务代码、需要条件等待的队列场景、写频繁的热点数据。用之前先问自己三句话:需要重入吗?需要 Condition 吗?写流量够少吗?

第 15 站

四种锁大对比:一张表说清全部差异

把 Java 里最常见的四把锁放一起对比——synchronizedReentrantLockReentrantReadWriteLockStampedLock

四种锁对比表
维度synchronizedReentrantLockReentrantReadWriteLockStampedLock
出现版本JDK 1.0JDK 5JDK 5JDK 8
锁模式互斥(Monitor)互斥(AQS)读共享 + 写互斥写互斥 + 读共享 + 乐观读
读并发度串行串行多读者并行多读者并行,乐观读无锁
可重入✅ 是✅ 是✅ 是❌ 否
公平模式❌ 否✅ 可选✅ 可选❌ 否(写优先)
锁降级(写→读)✅ 支持✅ 支持(tryConvertToReadLock)
锁升级(读→写)❌ 禁止(会死锁)⚠️ 尝试式(tryConvertToWriteLock,失败返 0)
Conditionwait/notify(单个)✅ 多个✅ 仅写锁❌ 无
可中断 / 超时❌ 否✅ 是✅ 是⚠️ 需用 Interruptibly 变体
读路径开销阻塞阻塞CAS乐观读 ≈ volatile 读
适用场景简单互斥、代码块级需要公平 / 超时 / 多条件读多写少且需可重入读极多写极少、极致读性能

几个容易记错的重点:

  • synchronized 也能「读写分离」?不能。它只有互斥,没有共享读;
  • 只有 ReentrantReadWriteLock 和 StampedLock 支持锁降级,且只有 StampedLock 提供尝试式升级;
  • 「公平」开关只在 ReentrantLock / ReentrantReadWriteLock 上可选,synchronized 与 StampedLock 都没有(StampedLock 用写优先近似公平)。
选型口诀
写多 → synchronized / ReentrantLock
读多写少 + 要重入/条件 → ReentrantReadWriteLock
读极多写极少 + 极致读性能 → StampedLock(乐观读)
第 16 站

生产实战:配置中心的本地缓存怎么上锁

回到开头的场景:配置中心,99% 读、1% 写。落地一个完整可运行的「本地配置缓存」——配一把 StampedLock:

LocalConfig.java · 完整可运行示例
import java.util.concurrent.locks.StampedLock;

public class LocalConfig {
    private String version = "v0";
    private String data    = "{}";
    private final StampedLock sl = new StampedLock();

    // 写:配置更新(低频)
    public void update(String newData) {
        long stamp = sl.writeLock();
        try {
            data = newData;
            version = "v" + (Integer.parseInt(version.substring(1)) + 1);
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    // 读:乐观读,失败升级悲观读(高频路径)
    public String[] get() {
        long stamp = sl.tryOptimisticRead();
        String v = version, d = data;              // 快照
        if (!sl.validate(stamp)) {
            stamp = sl.readLock();                          // 升级
            try { v = version; d = data; } finally { sl.unlockRead(stamp); }
        }
        return new String[]{ v, d };
    }

    public static void main(String[] args) throws Exception {
        final LocalConfig cfg = new LocalConfig();
        // 10 个读者线程狂读 + 1 个写者线程偶尔更新
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1_000_000; j++) cfg.get();
            }, "reader-" + i).start();
        }
        new Thread(() -> {
            for (int j = 0; j < 100; j++) { cfg.update("{n:" + j + "}"); }
        }, "writer").start();
        System.out.println("demo started");
    }
}

这个示例可以直接跑:10 个读者线程各读 100 万次,1 个写者线程更新 100 次。把 StampedLock 换成 ReentrantReadWriteLock 再跑一遍,读路径的耗时差异就是乐观读的价值(读者越多、写者越少,差距越明显)。

生产里还有几条实打实的经验:

✅ 生产 Checklist
  • 「读」也要快:乐观读适合「读一个短小快照」;如果一次读要遍历一个大集合,validate 失败重读的成本高,不如直接悲观读锁;
  • 写操作不要长时间持锁:StampedLock 内部有自旋,写锁持有过久会拖累所有读者和 CPU;
  • 宁可多一个 validate,也不要少一个:乐观读后忘记 validate,等于把「未确认一致的数据」直接返回——这是最常见的低级 bug;
  • 需要重入 / 条件 / 公平时,别硬上 StampedLock:ReentrantReadWriteLock 或 ReentrantLock 才是正解;
  • 「版本号 + 乐观读」是配置 / 元数据缓存的黄金组合:读无锁、写低频,性能与一致性兼得。
第 17 站

面试追问拆解:六连问一次答穿

追问 1:读写锁的读锁会饿死写者吗?

非公平模式下可能延迟(JDK 8 有 apparentlyFirstQueuedIsExclusive() 启发式缓解:队列头部有写者时读者让路,但仍有窗口期);公平模式完全不会;StampedLock 则是写优先策略,对写者最友好。答完再补一句「写操作不能无限延迟的场景,用公平模式或 StampedLock」就完整了。

追问 2:stamp 到底是什么?

stamp 是 StampedLock 的「版本戳 / 凭证」:拿写锁、读锁时,它是释放锁必须回传的凭证;乐观读时,它是拍照时刻的版本号,validate(stamp) 就是比较高 57 位版本号有没有变。它不是一个锁,锁的状态在内部的 long state 里。

追问 3:为什么乐观读比读写锁的读快?

读写锁的读锁要 CAS 修改 state(原子指令 + 缓存行竞争);乐观读只是 volatile 读一次 state 再位比较,没有原子指令、没有写内存。读者越多,CAS 竞争越激烈,差距越明显。但乐观读快的前提是 validate 大概率成功——写越少越快。

追问 4:乐观读一定比悲观读好吗?

不是。写频繁时 validate 频繁失败,每次失败都要升级悲观读 + 重读,双重开销,反而比直接用悲观读锁慢。乐观读的甜区是「读:写 > 10:1 且读操作快」。面试主动说出这个反面,比只会背优点加分得多。

追问 5:为什么 ReentrantReadWriteLock 禁止锁升级?

两个读者都想升级写锁时,互相等待对方释放读锁——死锁。即使单个读者升级,释放读锁到获取写锁之间也有窗口期。所以设计上一刀切禁止;StampedLock 用「尝试式转换」绕开了死锁——升级不成功就返回 0,绝不阻塞。

追问 6:ReentrantReadWriteLock 最多支持多少并发读者?

65535。因为读锁计数占 state 高 16 位,最多 2^16 - 1。这也是位拆分方案的天花板。

面试组织答案的三段式

被问到「读多写少用什么锁」,按这个顺序讲:① 规则(读读共享、读写互斥、写写互斥,这是 ReadWriteLock 的思想)→ ② 实现(ReentrantReadWriteLock 用 state 高 16 位 / 低 16 位管理读写计数,支持锁降级、禁止锁升级;StampedLock 用 long state 做版本号,乐观读先读后验)→ ③ 选型(写多用 ReentrantLock;读多写少要可重入用 ReentrantReadWriteLock;读极多写极少用 StampedLock 乐观读,注意它不可重入、无 Condition)。三句话把「是什么、为什么、怎么选」全讲完,就是 30 秒满分答案。

总结

这一篇你掌握了什么

核心知识点回顾

  • 读写锁规则:读读共享、读写互斥、写写互斥;ReadWriteLock 接口只有 readLock() / writeLock(),读锁共享、写锁排他;
  • ReentrantReadWriteLock 实现:AQS state 高 16 位存读锁计数、低 16 位存写锁计数,一次 CAS 管理两把锁;最大并发读者 / 写锁重入都是 65535;
  • 锁降级:先获取读锁、再释放写锁,中间不留窗口期,保证读到自己的写入;读 → 写升级被禁止(会死锁);
  • 写者饥饿:非公平模式读者可能挤占写者(JDK 8 有 apparentlyFirstQueuedIsExclusive 启发式缓解),公平模式根治但吞吐下降;
  • StampedLock 三种模式:写锁 / 悲观读锁 / 乐观读;乐观读 tryOptimisticRead() + validate(stamp) 先读后验,成功时零锁开销;
  • StampedLock 特性:long state 做版本号(低 7 位读计数、bit7 写锁位、高 56 位版本号);写优先;支持 tryConvert 系列尝试式转换(含读 → 写升级);
  • StampedLock 限制:不可重入、无 Condition、无公平模式、lock() 不可中断、写频繁时乐观读反而慢;
  • 选型:写多用 ReentrantLock,读多写少要可重入用 ReentrantReadWriteLock,读极多写极少的缓存 / 配置中心用 StampedLock 乐观读。

一句话总结

读多写少的本质是「把读操作从互斥中解放出来」:ReentrantReadWriteLock 用位拆分实现读写分离,StampedLock 用版本号实现「读不加锁」。前者换来了读并发,后者换来了读零开销——代价分别是禁止锁升级、不可重入。锁的世界没有银弹,每次「放开」都对应一次「牺牲」。

面试 30 秒总结

"读多写少场景优先考虑读写锁:ReentrantReadWriteLock 把 AQS 的 state 拆成高 16 位读计数和低 16 位写计数,读读共享、读写互斥,支持写锁降级为读锁、禁止读锁升级为写锁(会死锁);写者可能被读者挤占,需要公平模式兜底。若读流量压倒性占优且不需要可重入,用 JDK 8 的 StampedLock:乐观读先读后验,tryOptimisticRead 拿版本戳、validate 校验,成功时零锁开销,还能 tryConvertToWriteLock 尝试式升级;但它不可重入、不支持 Condition。写多为王时别用读写锁,直接用 ReentrantLock。"

👉 下一篇预告:ThreadLocal 深挖——从线程私有存储到内存泄漏,为什么线程池里必须 remove()。

Comments · 评论