承接的问题
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 与许可模型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。