版本号戳锁 StampedLock

0 阅读20分钟

上文介绍了适用于读操作远多于写操作的读写锁。

ReentrantReadWriteLock 的读锁虽然是共享的,但获取读锁本身是有开销的,又是 CAS 操作,又是 记录线程重入,又是 AQS 队列管理的,而且获取读锁期间写线程会被完全阻塞。如果一个方法几乎只读、极少写,每次读都走一遍读锁的完整流程其实有点浪费。

image.png

本篇文章再介绍一种适应于读操作远远多于写操作的锁 —— StampedLock。

StampedLock 通过 UML 可以看出也属于 Java 中锁的体系结构,包含两个视图类,读锁视图(ReadLockView)和写锁视图(WriteLockView);好像还没有使用 AQS。

对于源码分析先放一放,先简单介绍一下这个锁的定位。

StampedLock 是一种乐观锁,先假设数据没有变化,先读了再说,先乐观地读,不加锁,如果读出来的数有变化,再降级为读锁重新读。

前面讲的 原子类型 也是乐观锁,它也是假设数据没有变化,先更新了再说,更新失败说明数据有过变化,原子类是拿 “预期值” 判断数据有没有变化的,StampedLock 也一样,只不过 StampedLock 比较数据有没有变化的 “预期值” 是版本号戳 —— Stamp 。

乐观锁:先假设没有冲突直接操作,再通过“预期值”验证数据是否变化,失败则重试或降级。

悲观锁:它假设冲突一定会发生,操作前先独占资源,阻塞其他线程,直到自己完成并释放锁。(宁可错杀一千,不可放过一个)

下面就先介绍一下 StampedLock 的基本使用,再用 ReentrantLock 搞一个简单版的 StampedLock ,最后再看看并发编程大神Doug Lea(道格·利) 写的源码。

基本使用

ReadWriteLock 支持两种模式:一种是读锁,一种是写锁;而 StampedLock 提供三种访问模式:

模式方法语义
乐观读tryOptimisticRead()返回一个 stamp,不阻塞,不排斥任何读,但读取后需 validate(stamp) 验证
读锁readLock() / unlockRead()传统共享读锁,排斥写锁,支持重入
写锁writeLock() / unlockWrite()独占写锁,排斥所有读和写

StampedLock 的 读锁、写锁 和 ReadWriteLock 的 读锁、写锁 差不多,都是共享读、独占写,只是 StampedLock 要 通过 stamp 来加锁和解锁,加锁方法会返回 stamp,解锁方法也需要这个 stamp。

// 乐观读:返回一个版本号戳,不阻塞
long stamp = lock.tryOptimisticRead();

// 验证:如果期间没有写操作,返回 true
boolean valid = lock.validate(stamp);

// 读锁(悲观):如果存在写锁则阻塞等待
long readStamp = lock.readLock();
try { ... } finally { lock.unlockRead(readStamp); }

// 写锁(独占)
long writeStamp = lock.writeLock();
try { ... } finally { lock.unlockWrite(writeStamp); }

经典使用模式

class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    // 乐观读:先读,后验证
    public double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();       // 1. 乐观读
        double currentX = x, currentY = y;         // 2. 读取数据
        if (!sl.validate(stamp)) {                 // 3. 验证版本
            // 验证失败,说明期间有写操作,降级为读锁重试
            stamp = sl.readLock();                 // 4. 获取悲观读锁
            try {
                currentX = x;                      // 5. 重新读取
                currentY = y;
            } finally {
                sl.unlockRead(stamp);              // 6. 释放读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

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

StampedLock 适应于 读操作远远多于写操作 的场景,所以绝大多数情况下 validate 直接返回 true,整个读操作没有获取任何锁,性能接近无锁。

只有在写操作频繁的场景下才会触发降级逻辑。

读锁升级

对于 ReentrantReadWriteLock 来说,仅支持从写锁降级为读锁,这是因为写锁是独占的,同一时刻只有一个线程持有,该线程在持有写锁期间再去获取读锁,不存在其他并发读线程。

但 ReentrantReadWriteLock 明确禁止从读锁升级为写锁,因为读锁是共享的。假如线程 A 和线程 B 同时持有读锁,同时想要升级到写锁,但线程 A 想要升级到写锁需要等待 线程 B 的读锁释放,而线程 B 想要升级到写锁又需要等待线程 A 的读锁释放,循环等待,死锁了。

但 StampedLock 就比较灵活, 支持锁升级(乐观读 → 读锁 → 写锁),至于为啥它就没并发问题,后面讲解源码的时候再谈。

public void updateIfFar(Point p, double threshold) {
    long stamp = sl.tryOptimisticRead();
    double dist = Math.sqrt(p.x * p.x + p.y * p.y);
    if (!sl.validate(stamp)) {
        stamp = sl.readLock();
        boolean needUnlockRead = true; // 标记:是否还需要释放读锁
        try {
            dist = Math.sqrt(p.x * p.x + p.y * p.y);
            if (dist > threshold) {
                long writeStamp = sl.tryConvertToWriteLock(stamp);
                if (writeStamp != 0L) {
                    // 读锁原子转为写锁,旧读锁已消耗,不需要再unlockRead
                    needUnlockRead = false;
                    try {
                        // write操作
                    } finally {
                        sl.unlockWrite(writeStamp);
                    }
                } else {
                    // 升级失败,手动释放读锁,拿写锁
                    needUnlockRead = false;
                    sl.unlockRead(stamp);
                    long ws = sl.writeLock();
                    try {
                        // write操作
                    } finally {
                        sl.unlockWrite(ws);
                    }
                }
                return;
            }
        } finally {
            if (needUnlockRead) {
                // 只有读锁还持有时,才释放读锁
                sl.unlockRead(stamp);
            }
        }
    }
}


注意 tryConvertToWriteLock(stamp) 这个方法是关键——它把已有的读锁直接升级为写锁,比先释放读锁再重新获取写锁要高效得多,避免了中间态的竞态窗口。

源码解析

通过上面的 UML 类图也可以看出来,StampedLock 内部没有 继承 AQS 的 Sync,没有 AQS 的帮助,那它是怎么实现锁机制的呢?

我们知道 AQS 最重要的是记录线程状态的 state 和 存放竞争失败线程的阻塞队列,那 StampedLock 是怎么实现这两个功能的呢?

public class StampedLock implements java.io.Serializable {
    /** The number of bits to use for reader count before overflowing */
    private static final int LG_READERS = 7; // 127 readers
    // Values for lock state and stamp operations
    private static final long RUNIT = 1L;
    private static final long WBIT  = 1L << LG_READERS;
    private static final long RBITS = WBIT - 1L;
    private static final long RFULL = RBITS - 1L;
    private static final long ABITS = RBITS | WBIT;
    private static final long SBITS = ~RBITS; // note overlap with ABITS
    // not writing and conservatively non-overflowing
    private static final long RSAFE = ~(3L << (LG_READERS - 1));
    private static final long ORIGIN = WBIT << 1;

    // Special value from cancelled acquire methods so caller can throw IE
    private static final long INTERRUPTED = 1L;

    // Bits for Node.status
    static final int WAITING   = 1;
    static final int CANCELLED = 0x80000000;

    abstract static class Node {
        volatile Node prev;       // initially attached via casTail
        volatile Node next;       // visibly nonnull when signallable
        Thread waiter;            // visibly nonnull when enqueued
        volatile int status; 
   }

    private transient volatile Node head;
    /** Tail (last) of CLH queue */
    private transient volatile Node tail;

    // views
    transient ReadLockView readLockView;
    transient WriteLockView writeLockView;
    transient ReadWriteLockView readWriteLockView;

    private transient volatile long state;
    private transient int readerOverflow;

    public StampedLock() {
        state = ORIGIN;
    }


锁状态 state

看成员变量就知道, StampedLock 也有一个 volatile 修饰的 long 类型的 state(64 位),比 AQS int 类型的 state(32 位) 长了两倍。

对于 ReentrantLock 来说,state 代表重入计数,对于 ReentrantReadWriteLock 来说,高 16 位表示读状态(所有持有读锁线程的总重入次数),低 16 位表示写状态(写锁持有线程的重入次数),而 StampedLock 的 state 也承担相似的功能:

位范围含义
低 16 位写锁等待线程数(writer count)
中间 16 位读锁等待线程数(reader count)
高 32 位stamp 版本号(每次写操作后递增)

正是因为它需要用一个单一的变量同时承载版本号、读写状态和读锁计数这三种信息,所以用了位数更长的 long,而不是 int。

比特区域含义
bit63 ~ bit8stamp(版本戳,每次写锁释放会自增)
bit7WBIT:写锁标志位,1 = 持有写锁
bit6 ~ bit0(低 7bit)悲观读锁计数 0~127

再看 StampedLock 这么多的 静态 long 常量,都是在给 state 这个 64 位 long 做“位域划分”:

/** 读锁相关 **/

    // 给读锁计数预留 7 个 bit,所以最多0~127个悲观读
    private static final int LG_READERS = 7; // 127 readers
    // 读锁的“增量单位”。每多一个线程拿到读锁,就是 next = s + RUNIT,即 state 低 7 位加 1。
    private static final long RUNIT = 1L;
    // 值为 127,二进制 01111111,是读锁的掩码。用 s & RBITS 就能把 state 的低 7 位单独取出来。
    private static final long RBITS = WBIT - 1L;
    // 值为 126,二进制 01111110。它是个“接近满”的阈值,用于判断 读锁计数是否快溢出了,防止加到 128 溢出侵占 WBIT 位。
    private static final long RFULL = RBITS - 1L;

/** 写锁相关 **/

    // 即 128,二进制 10000000,是写锁标志位。获取写锁时相当于给 state 加上这个值。 
    private static final long WBIT  = 1L << LG_READERS;
    // 值为 255,二进制 11111111,是“所有锁位”的合集掩码,用于判断当前有没有读锁和写锁。
    private static final long ABITS = RBITS | WBIT;
    // 把低 7 位全部屏蔽掉,只保留写锁位和更高位的版本号区域。	
    private static final long SBITS = ~RBITS; // note overlap with ABITS

/** 其他 **/

    // 保守的安全掩码,用于判断“当前没有写锁、且读计数还没逼近溢出”的状态,避免在临界值附近做乐观判断。
    private static final long RSAFE = ~(3L << (LG_READERS - 1)); 
    // 值为 256,二进制 100000000,是 state 的初始值。此时写锁位和读锁位都是 0;之所以不从 0 开始,是因为 0 在 StampedLock 的语义里表示“操作失败”。
    private static final long ORIGIN = WBIT << 1;
    // 一个特殊的返回值,由被取消的获取方法返回,用来让调用方知道该抛出 InterruptedException。
    private static final long INTERRUPTED = 1L;

还有一个细节state 只有 1 个 bit(bit7,WBIT)标记是否持有写锁,没有任何字段 /bit 用来记录写锁的重入次数,也没有 owner 记录当前线程,可见写锁不支持重入。

同样的,悲观读锁虽然有 7bit 计数,但计数是统计并发读线程数量,不是单线程重入次数,而且也不像 ReentrantReadWriteLock 那样使用各自读线程的 LocalThread 记录重入次数,per-thread 的持有计数,所以读锁也不支持重入,这也是为什么 StampedLock 不叫 ReentrantStampedLock 。

阻塞队列 CLH

abstract static class Node {
    volatile Node prev;       // initially attached via casTail
    volatile Node next;       // visibly nonnull when signallable
    Thread waiter;            // visibly nonnull when enqueued
    volatile int status; 
}
private transient volatile Node head;
private transient volatile Node tail;

结构和 AQS 的阻塞队列差不多,都是CLH 变体双向等待队列。

新来的抢锁失败的线程,就是通过 CAS 操作,把自己包装成新的 Node,接到 tail 的后面,然后把自己变成新的 tail。

下面直接看乐观锁的获取与验证。

乐观读 tryOptimisticRead

public long tryOptimisticRead() {
    long s;
    // `s & WBIT == 0`:检查此刻没有写锁**(bit7 写标记为 0)
    // 无写锁:返回版本戳 stamp
    // 有写锁:直接返回 `0L`
    return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}

public boolean validate(long stamp) {
    // 加载屏障,保证屏障之后的内存读取,不能被重排到屏障之前。
    U.loadFence();
    // 清除低 7 位悲观读计数,只比较版本戳区域(bit7~bit63)
    return (stamp & SBITS) == (state & SBITS);
}

乐观读,读取 state 时,如果当前无写锁,返回抹除读计数后的版本戳;有写锁直接返回 0,0 代表乐观读失败,也这是为什么 初始 state 是 不是 0,如果是 0,就无法区分 “锁空闲” 和 “乐观读失败” 两种情况。

validate 验证版本号,内部带 Unsafe 读屏障,它约束的是屏障前后的读操作顺序,防止把后面读取业务字段的动作重排到屏障之前。乐观读的标准写法是“先取 stamp → 再读字段 → 再 validate”,屏障放在 validate 开头,是为了保证在比较 state 之前,此前那些对共享字段的读取已经完成、不会被上提。

需要注意的是 validate 比较的只是写标志位 + 高位版本区域,单纯悲观读增减不会导致校验失败。

传统读写锁的读操作需要 CAS 修改 state,而乐观读只读取版本号、不做任何修改,读线程之间零竞争。这正是 StampedLock 性能优势的核心来源。

悲观读 readLock

public long readLock() {
    // Unsafe 的 opaque 内存读取,**不具备 volatile 的读屏障**,只保证原子读取,不阻止重排;适合这里一次乐观试探
    long s = U.getLongOpaque(this, STATE) & RSAFE, nextState;
    // 悲观读计数 + 1
    if (casState(s, nextState = s + RUNIT))
        return nextState;
    else
        return acquireRead(false, false, 0L);
}

悲观读 就是 先做一次快速乐观尝试:如果当前锁状态安全(无写锁、读计数离上限还很远),直接 CAS 给读计数 + 1,成功就返回新 state 作为 stamp;失败则进入完整阻塞排队逻辑 acquireRead。

为什么用 getLongOpaque 而不是 volatile 读?

这里只是一次试探性快照: 就算读到过期 state,CAS 本身会做正确性校验;opaque 省去 volatile 读的内存屏障开销,最大化快速路径性能。

下面看一下 悲观读 的解锁。

public void unlockRead(long stamp) {
    long s, m;
    // `stamp & RBITS` 就是 拿到获取锁那一刻的读计数值。    
    if ((stamp & RBITS) != 0L) {
       // 当前 state 的版本戳 和 解锁传入 stamp 的版本戳必须一致。
       // 版本戳一旦变了,说明中间发生过写锁获取 + 释放,这个 stamp 已经失效,不能解锁。	
        while (((s = state) & SBITS) == (stamp & SBITS) &&
               ((m = s & RBITS) != 0L)) { // 当前全局读计数不能是 0。读计数已经是 0,没有读锁可以释放,直接退出循环抛异常。
            // 当前读计数<126,属于 正常区间,没有读计数溢出风险 
            if (m < RFULL) {
                // CAS 尝试把全局读计数减 1	
                if (casState(s, s - RUNIT)) {
                    // `m == RUNIT` 等价于 `m == 1`:本次解锁前,全局读计数等于 1。
                    if (m == RUNIT)
                        signalNext(head);
                    return;
                }
            }
            // 也就是读计数等于 126/127,读计数处于高位临界区,不能直接简单 CAS 减 1,交给`tryDecReaderOverflow`处理溢出边界的复杂逻辑。
            else if (tryDecReaderOverflow(s) != 0L)
                return;
        }
    }
    throw new IllegalMonitorStateException();
}

需要注意的是,解锁只校验版本戳,不校验线程:没有任何地方校验调用 unlockRead 的线程是不是当初拿 readLock 的线程。任意线程只要拿到合法有效的 stamp,都可以调用 unlockRead 去减掉全局读计数。这是 StampedLock 一个很危险的特性。

唤醒动作只在 m == RUNIT 时触发,也就是从 1→0 的时候,意味着中间那些读线程释放时不会去惊动队列。signalNext(head):唤醒 CLH 队列 head 后继节点,如果是读节点,会连带唤醒挂载在它cowait链表上所有等待读线程;如果是写节点,只唤醒写线程。

写锁 writeLock

先用 Opaque 读拿一次 state 快照,清除低 8 位,尝试 CAS 把 bit7 置 1(加写锁); CAS 成功代表拿到写锁,加写屏障,返回新 state 作为 stamp; CAS 失败说明当前有读锁 / 写锁,进入acquireWrite阻塞排队。

/**
 * 获取写锁(不可重入)。
 * 快路径:无条件尝试一次 CAS,用 CAS 本身来确认之前那次弱读是否仍然有效。
 */
public long writeLock() {
    // opaque 读:不保证有序性,仅作为 CAS 的候选期望值;
    // & ~ABITS 抹掉低 8 位(读计数 + 写标志),只保留高位版本区域。
    // 若 CAS 成功,说明读取到写入之间低 8 位未被他人改动。
    long s = U.getLongOpaque(this, STATE) & ~ABITS, nextState;
    if (casState(s, nextState = s | WBIT)) {
        // store-store 屏障:保证后续对共享数据的写不会被重排到“拿到写锁”之前,
        // 避免其他线程通过乐观读 + validate 观察到不一致的中间状态。
        U.storeStoreFence();
        return nextState;
    }
    // 快路径失败,进入自旋 + 入队的完整流程
    // 参数含义依次为:可中断、超时控制、截止时间
    return acquireWrite(false, false, 0L);
}

总的来说,加写锁,就是 CAS,把 bit7 置 1,标记写锁已持有;高位版本戳保持不变。版本戳只在 unlockWrite 释放写锁时才 + 1。


/**
 * 释放写锁。
 * 校验两条:stamp 必须与当前 state 完全一致;且 stamp 本身必须带写锁标志位。
 */
public void unlockWrite(long stamp) {
    if (state != stamp || (stamp & WBIT) == 0L)
        throw new IllegalMonitorStateException();
    releaseWrite(stamp);
}

/**
 * 实际释放动作:更新 state 后唤醒等待队列中的下一个节点。
 * 注意这里直接赋值给 state 字段,而非 CAS——因为写锁是独占的,
 * 持有者唯一,不存在并发修改。
 */
private long releaseWrite(long s) {
    long nextState = state = unlockWriteState(s);
    signalNext(head);
    return nextState;
}

/**
 * 计算写锁释放后的新 state。
 * 这里用的是「加」而不是「减」:释放时再加一个 WBIT,
 * 使每次写锁的获取/释放都会在高位留下痕迹,
 * 既缓解 CAS 的 ABA 问题,也让 validate 能感知到期间发生过写操作。
 * 若加法导致整体归零(理论上仅在全位翻转时出现),则回退到 ORIGIN,
 * 因为 0 在 StampedLock 语义里是「操作失败」的哨兵值,不能作为正常状态。
 */
private static long unlockWriteState(long s) {
    return ((s += WBIT) == 0L) ? ORIGIN : s;
}

升级与降级 Convert

写锁降级到读锁:

/**
 * 尝试把当前持有的锁「降级/转换」为悲观读锁。
 * 语义:若 stamp 表示持有写锁,则释放写锁并获得读锁;
 *      若已是读锁,直接返回原 stamp;
 *      若是乐观读,仅在立即可用时才获得读锁并返回读戳记;
 *      其他情况一律返回 0。
 */
public long tryConvertToReadLock(long stamp) {
    long a, s, nextState;
    // 前置校验:屏蔽读计数后比较版本区域,确认从拿到这个 stamp 至今
    // 没有发生过写锁的获取/释放,否则整个转换无意义,直接退出。
    while (((s = state) & SBITS) == (stamp & SBITS)) {
        a = stamp & ABITS;   // 取出 stamp 的低 8 位(读计数 + 写标志)

        if (a >= WBIT) {
            // —— 分支一:stamp 是写锁
            // 写锁独占,要求 state 与 stamp 完全一致,否则说明状态已被改动
            if (s != stamp)
                break;
            // 先走 unlockWriteState 释放写锁(加 WBIT 递增版本),
            // 再 + RUNIT 直接挂上读计数,一步完成「释放写 + 获取读」。
            // 注意这里是普通赋值而非 CAS——写锁持有者唯一,无并发修改。
            nextState = state = unlockWriteState(s) + RUNIT;
            // 写锁释放后需要唤醒等待队列中的下一个节点
            signalNext(head);
            return nextState;

        } else if (a == 0L) {
            // —— 分支二:stamp 是乐观读(低 8 位全 0)
            // 未接近溢出:直接 CAS 给读计数加 1
            if ((s & ABITS) < RFULL) {
                if (casState(s, nextState = s + RUNIT))
                    return nextState;
            } else if ((nextState = tryIncReaderOverflow(s)) != 0L) {
                // 已接近溢出:走溢出计数兜底,返回非 0 表示转换成功
                return nextState;
            }
            // CAS 失败则回到 while 重新取 state 再判断

        } else {
            // —— 分支三:stamp 本身已经是读锁
            // 若当前 state 的低 8 位已归零,说明读计数被别的路径清掉了,
            // 这个 stamp 已不可信,退出。
            if ((s & ABITS) == 0L)
                break;
            // 否则无需任何修改,原样返回
            return stamp;
        }
    }
    // 版本不匹配或各分支均不满足,转换失败
    return 0L;
}

第一个分支就是从独占写锁,变成共享悲观读锁。

  • 清除 bit7 WBIT 写标记,版本戳 +1;
  • 低 7 位读计数 +1
  • 直接赋值给 state,不需要 CAS

当前线程持有写锁,写锁是排他锁,全局没有任何其他线程能修改 state。所以可以直接赋值,不存在并发竞争。

如果本身就是 悲观读锁 ,校验当前 state 还有效(ABITS≠0),直接返回原来的 stamp,啥也不改。

降级很简单,ReentrantReadWriteLock 的写锁也可以降级,那升级呢?


/**
 * 尝试把当前 stamp 表示的锁「升级」为写锁。
 * 成功返回新的写戳记;任何不满足条件的情况一律返回 0,调用方需自行兜底。
 */
public long tryConvertToWriteLock(long stamp) {
    // a:stamp 侧的低 8 位(读计数 + 写标志),用于判断「我手里是什么锁」
    // m:state 侧的低 8 位,用于判断「锁当前实际处于什么状态」
    long a = stamp & ABITS, m, s, nextState;

    // 前置校验:屏蔽读计数后比较版本区域。
    // 若期间发生过写锁的获取/释放,SBITS 区域已变化,整个升级无意义,直接退出。
    while (((s = state) & SBITS) == (stamp & SBITS)) {

        if ((m = s & ABITS) == 0L) {
            // —— 分支一:state 低 8 位为 0,说明当前无锁(乐观读不修改 state,也表现为 0)
            // 此时若 a 非 0,说明我手里其实持有读锁或写锁,与 state 现状矛盾,属于异常,退出。
            if (a != 0L)
                break;
            // 无锁场景:CAS 置写标志位。成功后加 store-store 屏障,
            // 保证后续对共享数据的写不会被重排到「拿到写锁」之前。
            if (casState(s, nextState = s | WBIT)) {
                U.storeStoreFence();
                return nextState;
            }
            // CAS 失败说明有线程抢先改了 state,回到 while 重新取值判断

        } else if (m == WBIT) {
            // —— 分支二:state 当前是写锁
            // 只有当我手里的 stamp 也是写锁时才合法,否则说明状态已被他人变更。
            if (a != m)
                break;
            // 已经是写锁,无需任何修改,原样返回(注意:这不代表可重入,
            // 只是「你本来就持有」的幂等返回)
            return stamp;

        } else if (m == RUNIT && a != 0L) {
            // —— 分支三:state 里只有 1 个读计数,且我手里确实持有读锁
            // 这是「唯一读者」的判定条件:减掉自己的读计数同时置上写标志,一步完成升级。
            // 若还有其他读者,m 会大于 RUNIT,直接落到最后的 else break。
            if (casState(s, nextState = s - RUNIT + WBIT))
                return nextState;

        } else {
            // —— 其他情况:存在多个读者、或读写状态混合等,均不可安全升级
            break;
        }
    }
    // 版本不匹配或各分支均不满足,升级失败
    return 0L;
}

前文讲过,因为读锁是共享的。假如线程 A 和线程 B 同时持有读锁,同时想要升级到写锁,但线程 A 想要升级到写锁需要等待 线程 B 的读锁释放,而线程 B 想要升级到写锁又需要等待线程 A 的读锁释放,循环等待,死锁了。

这也是为啥 ReentrantReadWriteLock 明确禁止从读锁升级为写锁

但 StampedLock 没有一刀切禁止读转写,而是加了严格前置条件:只有全局读计数恰好等于 1(当前线程就是唯一读者,不存在其他任何读线程),才允许原子 CAS 升级。此时没有其他读者,自然不会出现 A、B 互相等待的死锁场景。

使用场景

StampedLock 适合读多写少、读操作简短、希望降低读开销的场景,依靠乐观读提升并发吞吐量,支持写锁原子降级读锁。

但它牺牲了可重入、Condition 和可中断能力,适合"读极多、写极少、操作极短"的特定场景,不是 ReentrantReadWriteLock 的通用替代品。

  1. 不可重入:StampedLock 本身不可重入。如果在写锁持有期间再次尝试获取写锁会死锁。需要重入的场景应自行在外层处理。

  2. 不支持 Condition:没有 newCondition() 方法,无法实现等待/通知的精细化控制。

  3. 不可中断:readLock() 和 writeLock() 都是阻塞等待,不能被 Thread.interrupt() 中断。

总结

StampedLock 的核心价值是乐观读——用版本号戳代替传统读锁,在写极少的场景下实现接近无锁的读取性能。

维度ReentrantReadWriteLockStampedLock
乐观读不支持支持(tryOptimisticRead)
可重入读写锁各自可重入不可重入
Condition支持多个 Condition不支持
可中断lockInterruptibly 支持不支持
锁升级不支持支持(convertToWriteLock)
锁降级支持不支持
基于 AQS是否(自研位操作)
适用场景读多写少、需 Condition极高读占比、读操作短平快

附:UML 类图

@startuml
abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer{}
class ReentrantReadWriteLock$Sync extends AbstractQueuedSynchronizer{}
class ReentrantReadWriteLock$FairSync extends ReentrantReadWriteLock$Sync{}
class ReentrantReadWriteLock$NonfairSync extends ReentrantReadWriteLock$Sync{}


class ReentrantLock$Sync extends AbstractQueuedSynchronizer{}
class ReentrantLock$FairSync extends ReentrantLock$Sync{}
class ReentrantLock$NonfairSync extends ReentrantLock$Sync{}

class Semaphore$Sync extends AbstractQueuedSynchronizer {}
class Semaphore$FairSync extends Semaphore$Sync{}
class Semaphore$NonfairSync extends Semaphore$Sync{}

class CountDownLatch$Sync extends AbstractQueuedSynchronizer {}
class LimitLatch$Sync extends AbstractQueuedSynchronizer {}

@enduml