《Java 并发编程·JUC 源码与容器》3.3 StampedLock 的乐观读

StampedLock 用版本戳把读分成加锁读和乐观读两档。本节按 JDK 25 源码拆解 7 位读者计数、写位与版本位,实测 validate 的语义、不可重入与 tryConvertToWriteLock 的边界,并给出三版缓存的对照数据。

承接的问题

3.2 的降级让写者在释放写锁后继续持有读锁,代价是读锁仍然要对 state 做 CAS。如果一个数据结构「几乎只读、偶尔整体替换」,能不能让读路径连 CAS 都不做?

StampedLock 的答案是乐观读:读之前取一个版本戳,读完检查这个戳有没有失效;没有失效就直接用结果,失效了再退化成加锁读。它不基于 AQS,没有 state 拆分,也没有 HoldCounter,实现思路和前面两节完全不同。

本节只讲实现与边界,三者的选型在卷一 9.3 已经给过结论。需要先记住一点:StampedLock 不是 Lock 的替代品,它没有重入、没有条件队列、没有公平性,也没有「持有者」概念。

版本戳:一个 long 里的三种模式

StampedLock 的 state 是一个 long:bit 0–6 是读者计数,bit 7 是写位,bit 8 及以上是版本号。

// JDK 25 源码摘录,已删减:StampedLock
    private static final int LG_READERS = 7; // 127 readers
    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
    private static final long RSAFE = ~(3L << (LG_READERS - 1));
    // ...
    private static final long ORIGIN = WBIT << 1;

按 m = stamp & ABITS 区分三种模式:

m == WBIT 是写锁;m == 0 是乐观读(即使此刻有读者持有读锁,乐观戳的读者位也被清掉);0 < m <= RFULL 是加锁读。

SBITS 把低 7 位读者计数掩掉,只保留写位和版本位,所以它适合做「版本是否变过」的比较。ORIGIN = WBIT << 1 = 256,让初始状态既不是 0(0 被保留为失败值),也不带写位。

写锁的获取是 state | WBIT;释放时 unlockWriteState 执行 state += WBIT。因为写锁状态一定带着 WBIT,加 WBIT 会清掉 bit 7 并向高位进位,版本号因此前进一格。7 位读者计数最多表示 127,其中 RFULL = 126 是最后一个可直接自增的值,RBITS = 127 被保留为溢出自旋的哨兵;再多的读者走 tryIncReaderOverflow,不会让计数污染写位。

tryOptimisticRead 与 validate

乐观读的语义只有一句话:取戳之后没有写锁成功提交过,戳就有效。它不阻止写者,也不保证你读到的是一份原子快照。

// JDK 25 源码摘录,已删减:StampedLock
    public long tryOptimisticRead() {
        long s;
        return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
    }

    public boolean validate(long stamp) {
        U.loadFence();
        return (stamp & SBITS) == (state & SBITS);
    }

两个细节:

  • tryOptimisticRead() 在写锁被持有时返回 0。调用方必须把 0 当作「拿不到乐观戳」,退化成 readLock(),不能把 0 传给 validate 后继续用旧数据。
  • validate() 先执行 U.loadFence(),再比较 SBITS 掩码后的值。这个 fence 保证调用方的数据读不会被重排到 state 校验之后,否则可能用旧戳读到新数据却仍然验证通过;但它不会让你读到的那次读变成原子读。被保护的数据本身必须是不可变对象,或者通过 volatile / VarHandle 安全发布。

下面的程序把三种模式、版本推进和转换边界一次跑出来。

package juc.ch03;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.StampedLock;

public final class StampedSemantics {
    public static void main(String[] args) throws Exception {
        StampedLock lock = new StampedLock();
        long optimistic = lock.tryOptimisticRead();
        System.out.printf("unlocked: stamp=%d isOptimistic=%s validate=%s%n",
                optimistic, StampedLock.isOptimisticReadStamp(optimistic), lock.validate(optimistic));

        long write = lock.writeLock();
        System.out.printf("writeLock: stamp=%d (ABITS=%d) isWrite=%s optimisticNow=%d tryWriteLock=%d%n",
                write, write & 255, StampedLock.isWriteLockStamp(write),
                lock.tryOptimisticRead(), lock.tryWriteLock());
        lock.unlockWrite(write);
        System.out.printf("after unlock: nextStamp=%d validate(old)=%s%n", write + 128, lock.validate(optimistic));

        long read = lock.readLock();
        CountDownLatch in = new CountDownLatch(1), out = new CountDownLatch(1);
        Thread second = new Thread(() -> {
            long s = lock.readLock(); in.countDown();
            try { out.await(); } catch (InterruptedException ignored) { }
            lock.unlockRead(s);
        });
        second.start(); in.await();
        System.out.printf("two readers: convertToWrite=%d%n", lock.tryConvertToWriteLock(read));
        out.countDown(); second.join();
        long converted = lock.tryConvertToWriteLock(read);
        System.out.printf("sole reader: convertToWrite=%d isWrite=%s%n",
                converted, StampedLock.isWriteLockStamp(converted));
        lock.unlockWrite(converted);
        long opt = lock.tryOptimisticRead();
        long optWrite = lock.tryConvertToWriteLock(opt);
        System.out.printf("optimistic->write: %d%n", optWrite);
        lock.unlockWrite(optWrite);
    }
}
$ javac --release 25 -d out src/juc/ch03/StampedSemantics.java
$ java -cp out juc.ch03.StampedSemantics
unlocked: stamp=256 isOptimistic=true validate=true
writeLock: stamp=384 (ABITS=128) isWrite=true optimisticNow=0 tryWriteLock=0
after unlock: nextStamp=512 validate(old)=false
two readers: convertToWrite=0
sole reader: convertToWrite=640 isWrite=true
optimistic->write: 896

逐行读:

  • 空闲时 tryOptimisticRead() 返回 ORIGIN,即 256,isOptimisticReadStamp 为 true,validate 为 true。
  • 写锁状态是 384(256 | 128),stamp & ABITS == 128,所以是写戳;此时 tryOptimisticRead() 返回 0,tryWriteLock() 也返回 0——后者直接证明它不可重入。
  • 写锁释放后状态变成 512(384 + 128),版本前进;之前取的 256 再 validate 就是 false。这就是「一次写让所有旧乐观戳失效」。
  • 两个读者时,第一个读者的 tryConvertToWriteLock 返回 0;只剩一个读者时返回 640,转换成功。
  • 空闲状态下的乐观戳可以直接转成写戳 896,因为 m == 0 且没有别的持有者。

不可重入与 tryConvertToWriteLock 的边界

StampedLock 没有所有者字段,也没有 HoldCounter。写锁不记录「谁持有」,所以同一线程再次 writeLock() 会像别的线程一样排队,永远等不到——自死锁。tryWriteLock() 返回 0 是最安全的探测方式。

读锁更隐蔽:同一线程 readLock() 两次会成功,读者计数加 2,但解锁时没有「当前线程持有几次」的校验。少解一次是泄漏,多解一次会把别人的读计数减掉。它把重入的责任完全交给了调用方。

转换方法是非阻塞的条件升级,边界写在源码里。

// JDK 25 源码摘录,已删减:StampedLock.tryConvertToWriteLock
    public long tryConvertToWriteLock(long stamp) {
        long a = stamp & ABITS, m, s, nextState;
        while (((s = state) & SBITS) == (stamp & SBITS)) {
            if ((m = s & ABITS) == 0L) {
                if (a != 0L)
                    break;
                if (casState(s, nextState = s | WBIT)) {
                    U.storeStoreFence();
                    return nextState;
                }
            } else if (m == WBIT) {
                if (a != m)
                    break;
                return stamp;
            } else if (m == RUNIT && a != 0L) {
                if (casState(s, nextState = s - RUNIT + WBIT))
                    return nextState;
            } else
                break;
        }
        return 0L;
    }

三个分支对应三种成功条件:

  • 当前状态完全空闲(m == 0)且传入的是乐观戳(a == 0):CAS 上写位即可;
  • 当前已经是写锁且戳匹配:原样返回,相当于幂等确认;
  • 当前恰好只有一个读者(m == RUNIT)且传入的是读戳(a != 0):把那个读者位换成写位。

其余的「有多个读者」「乐观戳但别人正持有锁」「版本已经变了」都会返回 0。它不会等待,也不会帮你排队。所以 tryConvertToWriteLock 只适合「读完后发现需要写、且冲突概率很低」的场景;冲突时应该退回 writeLock() 重新来,而不是自旋重试转换。

三版缓存:synchronized、读写锁、乐观读

用同一份数据做三版缓存:三者都保护一个 long[] 快照,写路径都复制数组再发布,读路径的同步策略不同——synchronized 直接锁方法,ReentrantReadWriteLock 用读锁,StampedLock 用乐观读加 volatile 快照。

package juc.ch03;
import java.util.SplittableRandom;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.StampedLock;

public final class CacheCompare {
    static final int KEYS = 1024, THREADS = 8, MS = 1500;
    interface Cache { long get(int k); void put(int k, long v); String stats(); }

    static final class SyncCache implements Cache {
        volatile long[] data = new long[KEYS];
        public synchronized long get(int k) { return data[k]; }
        public synchronized void put(int k, long v) { long[] n = data.clone(); n[k] = v; data = n; }
        public String stats() { return "locks=n/a"; }
    }

    static final class RwCache implements Cache {
        final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
        volatile long[] data = new long[KEYS];
        public long get(int k) {
            rw.readLock().lock();
            try { return data[k]; } finally { rw.readLock().unlock(); }
        }
        public void put(int k, long v) {
            rw.writeLock().lock();
            try { long[] n = data.clone(); n[k] = v; data = n; }
            finally { rw.writeLock().unlock(); }
        }
        public String stats() { return "readLocks=" + rw.getReadLockCount(); }
    }

    static final class StampedCache implements Cache {
        final StampedLock lock = new StampedLock();
        volatile long[] data = new long[KEYS];
        final AtomicLong zero = new AtomicLong(), fail = new AtomicLong(), fallback = new AtomicLong();
        public long get(int k) {
            long stamp = lock.tryOptimisticRead();
            if (stamp == 0L) {
                zero.incrementAndGet(); fallback.incrementAndGet();
                stamp = lock.readLock();
                try { return data[k]; } finally { lock.unlockRead(stamp); }
            }
            long v = data[k];
            if (!lock.validate(stamp)) {
                fail.incrementAndGet(); fallback.incrementAndGet();
                stamp = lock.readLock();
                try { return data[k]; } finally { lock.unlockRead(stamp); }
            }
            return v;
        }
        public void put(int k, long v) {
            long stamp = lock.writeLock();
            try { long[] n = data.clone(); n[k] = v; data = n; }
            finally { lock.unlockWrite(stamp); }
        }
        public String stats() {
            return "optimisticZero=" + zero.get() + " validateFail=" + fail.get() + " fallback=" + fallback.get();
        }
    }

    public static void main(String[] args) throws Exception {
        double load = java.lang.management.ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage();
        System.out.printf("loadAvg=%.2f threads=%d measure=%dms%n", load, THREADS, MS);
        for (String phase : new String[]{"read-only", "5%-write"}) {
            System.out.println("-- " + phase);
            run(new SyncCache(), 0.0);
            run(new RwCache(), 0.0);
            run(new StampedCache(), phase.equals("read-only") ? 0.0 : 0.05);
        }
    }

    static void run(Cache cache, double writeRatio) throws Exception {
        AtomicLong ops = new AtomicLong(), sink = new AtomicLong();
        CountDownLatch start = new CountDownLatch(1);
        Thread[] threads = new Thread[THREADS];
        for (int i = 0; i < THREADS; i++) {
            final int id = i;
            threads[i] = new Thread(() -> {
                SplittableRandom rnd = new SplittableRandom(id * 31L + 7);
                try { start.await(); } catch (InterruptedException ignored) { }
                long local = 0, n = 0, deadline = System.nanoTime() + MS * 1_000_000;
                while (System.nanoTime() < deadline) {
                    int k = rnd.nextInt(KEYS);
                    if (writeRatio > 0 && rnd.nextDouble() < writeRatio) cache.put(k, n);
                    else local += cache.get(k);
                    n++;
                }
                ops.addAndGet(n); sink.addAndGet(local);
            }, "worker-" + i);
            threads[i].start();
        }
        long t0 = System.nanoTime();
        start.countDown();
        for (Thread t : threads) t.join();
        double s = (System.nanoTime() - t0) / 1e9;
        System.out.printf("%-12s ops=%,d Mops/s=%.2f sink=%d %s%n",
                cache.getClass().getSimpleName(), ops.get(), ops.get() / s / 1e6, sink.get(), cache.stats());
    }
}
$ javac --release 25 -d out src/juc/ch03/CacheCompare.java
$ java -cp out juc.ch03.CacheCompare
loadAvg=90.26 threads=8 measure=1500ms
-- read-only
SyncCache    ops=22,079,364 Mops/s=14.71 sink=0 locks=n/a
RwCache      ops=4,262,857 Mops/s=2.79 sink=0 readLocks=0
StampedCache ops=28,974,263 Mops/s=19.21 sink=0 optimisticZero=0 validateFail=0 fallback=0
-- 5%-write
SyncCache    ops=15,179,073 Mops/s=10.11 sink=0 locks=n/a
RwCache      ops=3,477,160 Mops/s=2.28 sink=0 readLocks=0
StampedCache ops=22,695,989 Mops/s=15.13 sink=30589019227385 optimisticZero=50268 validateFail=12095 fallback=62363

这台机器当时的 load average 是 90.26,绝对值只能说明「同一进程内三者的相对关系」,不能横向引用。结构性结论比 Mops 更稳:

  • 只读阶段,StampedCache 的 optimisticZero=0、validateFail=0,说明 8 个线程的每次读都命中了乐观路径,没有一次 CAS。RwCache 每次读都要对 state 做 CAS,还要维护分线程读计数,在这台机器上慢了约 5 倍。
  • 混合阶段,optimisticZero=50268 表示取戳时写锁正被持有;validateFail=12095 表示取到戳之后、验证之前有写提交。两者合计 62363 次退回 readLock(),与 fallback 完全对上。写比例只有 5%,但每次写都会让所有在途乐观戳失效,退化的次数并不少。
  • synchronized 版本没有统计项,但它的写路径同样复制数组;读路径的监视器在 8 线程下仍然能跑到 10–15 Mops/s,说明短临界区里 monitorenter 的成本不一定比读写锁高。

这里必须强调:Mops/s 受机器负载影响很大,同一程序两次运行会有明显波动。真正可复现的是计数器之间的关系——乐观读的收益来自「成功路径零 CAS」,代价是「写一次就作废一批读」。

在 libraryd-conc 里落地

LockedLoanService 的查询可以把目录做成 volatile 不可变快照,用乐观读;借还书仍然走写锁。下面只列这个版本的字段与三个方法,未列出的方法沿用 3.1。

    // LockedLoanService 的 StampedLock 版本:字段与三个方法节选
    private final StampedLock lock = new StampedLock();
    private volatile Map<String, Book> catalog = Map.of();

    public Optional<Book> find(String isbn) {
        long stamp = lock.tryOptimisticRead();
        Book book = catalog.get(isbn);
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try { book = catalog.get(isbn); } finally { lock.unlockRead(stamp); }
        }
        return Optional.ofNullable(book);
    }

    public void replaceCatalog(Map<String, Book> latest) {
        long stamp = lock.writeLock();
        try { catalog = Map.copyOf(latest); } finally { lock.unlockWrite(stamp); }
    }

    public boolean reserveOne(String isbn) {
        long stamp = lock.writeLock();
        try {
            Book book = catalog.get(isbn);
            if (book == null || book.stock() <= 0) return false;
            Map<String, Book> next = new HashMap<>(catalog);
            next.put(isbn, new Book(book.isbn(), book.title(), book.stock() - 1));
            catalog = Map.copyOf(next);
            return true;
        } finally {
            lock.unlockWrite(stamp);
        }
    }

catalog 是 volatile 的不可变 Map,所以乐观读里直接 catalog.get 是安全的:即使写者正在替换,读到的也是旧快照或新快照,不会是正在修改的 HashMap。如果这里改成可变的 HashMap,乐观读就会和写者的 put 发生数据竞争,可能读到断链的桶,validate 也救不了——它只验证版本,不验证数据结构完整性。

reserveOne 先写锁、再整体替换快照,读者要么看到旧库存、要么看到新库存。这个方法不是完整的借阅流程,只演示写路径;配额、借阅记录仍按 3.1 的写锁临界区处理。

常见错误与边界

  • 对可变容器做乐观读:HashMap 在扩容时读到中间状态,validate 通过也不代表数据完整。乐观读只适合不可变快照、volatile 字段或经过安全发布的数组。
  • 忘记 validate 或验证错戳:不验证就等于裸读;把写戳拿去验证、或者验证一个已经 unlock 的旧戳,语义都不对。
  • 把 0 当有效戳:tryOptimisticRead() 返回 0 表示写锁被持有,必须走 readLock()。
  • 读锁重入:readLock() 两次会成功,但解锁没有所有者校验;少解泄漏,多解减掉别人的计数。写锁重入直接自死锁。
  • 把 tryConvertToWriteLock 当 lock() 用:它不等待,多读者或版本变化时返回 0;冲突时应重新 writeLock()。
  • 需要条件变量或公平性:StampedLock 没有 Condition,也不支持公平模式;readLock() 不响应中断,需要中断时用 readLockInterruptibly()。
  • 什么时候不要用:写比例高、读临界区长、需要重入或条件队列。写频繁时每次写都作废一批乐观戳,重试会把读路径的收益吃回去;它的优势只在「读极多、写极少、数据可做成不可变快照」时成立。

小结

  • StampedLock 用 long state 编码 7 位读者计数、1 位写位和高位版本号;写锁释放会让版本前进,使所有旧乐观戳失效。
  • tryOptimisticRead 在写锁被持有时返回 0;validate 用 SBITS 比较并加 acquire fence,但它不是锁,也不保证读的原子性。
  • 它不可重入、没有所有者、没有条件队列;tryConvertToWriteLock 只在「完全空闲」或「恰好一个读者」时成功。
  • 三版缓存里,乐观读的成功路径零 CAS,只读场景明显快于读写锁;5% 写就让六万多次读退回加锁路径。
  • 落地时把共享状态做成 volatile 不可变快照,乐观读才安全;对本机 Mops/s 数字只能看相对关系。

阅读导航:上一节 3.2 锁降级与写锁饥饿 | 下一节 4.1 Semaphore 与许可模型

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收