承接的问题
2.3 拆完了 ReentrantLock 的三件事:可重入靠 state 计数,中断靠 lockInterruptibly 的抛出路径,超时靠 tryLock(timeout) 的排队与取消。这三件事都建立在「state 只表示一个独占持有者的重入次数」上。
读写锁把互斥拆成两档:读锁共享,写锁独占。于是 state 要同时回答两个问题:
- 现在一共有多少个读锁持有者(不是多少个线程,而是重入后的总次数);
- 写锁被谁持有、重入了几次。
ReentrantReadWriteLock 没有加第二个 volatile 字段,而是把两本账压进同一个字。本节只看这个字怎么切、为什么这样切,以及切分之后哪些信息必须放到 state 外面。读写锁的用法和选型卷一 9.3 已经讲过,这里不重复。
先给结论:JDK 25 的 Sync 继承的是 AbstractQueuedLongSynchronizer,state 是 long,读、写各占 32 位。网上大量基于 JDK 8 的文章写「高低 16 位」,那是旧实现;本机另一套 Zulu 8 的 src.zip 里确实还是 SHARED_SHIFT = 16、MAX_COUNT = 65535。版本差异必须按手头的 JDK 核对。
一个 state,两个计数器
如果读计数和写计数分别放在两个 volatile int 里,获取锁时就要读两次、CAS 两次。两次读之间状态可能变化,你无法得到一个一致的快照;两次 CAS 也不具备原子性,写者可能在看错读计数的情况下拿到写锁。放进同一个字,一次 CAS 就能同时校验两本账。
JDK 25 的切分常量和提取函数如下。
// JDK 25 源码摘录,已删减:ReentrantReadWriteLock.Sync
static final int SHARED_SHIFT = 32;
static final long SHARED_UNIT = (1L << SHARED_SHIFT);
static final long MAX_COUNT = Integer.MAX_VALUE;
static final long EXCLUSIVE_MASK = (1L << SHARED_SHIFT) - 1;
/** Returns the number of shared holds represented in count. */
static int sharedCount(long c) { return (int)(c >>> SHARED_SHIFT); }
/** Returns the number of exclusive holds represented in count. */
static int exclusiveCount(long c) { return (int)(c & EXCLUSIVE_MASK); }
// ...
static final class HoldCounter {
int count; // initially 0
// Use id, not reference, to avoid garbage retention
final long tid = LockSupport.getThreadId(Thread.currentThread());
}
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}
约定很直接:
- 低 32 位是写锁重入计数,
exclusiveCount(c)只做c & 0xFFFFFFFF; - 高 32 位是读锁总持有次数,
sharedCount(c)做无符号右移 32 位; - 读锁获取一次就
state += SHARED_UNIT,释放一次就state -= SHARED_UNIT; - 写锁获取一次就
state += 1,释放一次就state -= 1。
因此 state == 0 表示完全空闲;state 高 32 位非零、低 32 位为零表示只有读者;低 32 位非零、高 32 位为零是正常写锁;两半都非零只应出现在写者同时持有读锁的窗口,降级是典型写法——写者先拿到读锁、还没有释放写锁,3.2 会展开这个窗口。
javap -p 可以直接确认基类与字段类型,不必猜。
$ javap -p 'java.util.concurrent.locks.ReentrantReadWriteLock$Sync'
abstract class java.util.concurrent.locks.ReentrantReadWriteLock$Sync extends java.util.concurrent.locks.AbstractQueuedLongSynchronizer {
private static final long serialVersionUID;
static final int SHARED_SHIFT;
static final long SHARED_UNIT;
static final long MAX_COUNT;
static final long EXCLUSIVE_MASK;
private transient java.util.concurrent.locks.ReentrantReadWriteLock$Sync$ThreadLocalHoldCounter readHolds;
private transient java.util.concurrent.locks.ReentrantReadWriteLock$Sync$HoldCounter cachedHoldCounter;
private transient java.lang.Thread firstReader;
private transient int firstReaderHoldCount;
static int sharedCount(long);
static int exclusiveCount(long);
注意 HoldCounter、ThreadLocalHoldCounter、firstReader 仍然存在,它们是 state 之外的补充账本,下一节再看为什么必须有。
从 16 位到 32 位:一次不声张的扩容
JDK 8 的实现是 int state,高低各 16 位:
// JDK 8 源码摘录,已删减:ReentrantReadWriteLock.Sync
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
16 位意味着每条账的上限是 65535。写锁重入 65535 次在业务里几乎不可能,但读锁不一样:读计数统计的是所有线程的重入次数之和,一个高并发服务同时持有几万个读锁并不离谱。到顶之后代码会抛 Error("Maximum lock count exceeded"),这是保护,不是正常路径。
JDK 25 换成 long state 后,两条账各自的上限变成 Integer.MAX_VALUE(2 147 483 647),MAX_COUNT 也从 65535 变成了 Integer.MAX_VALUE。代价是 Sync 的基类换成了 AbstractQueuedLongSynchronizer:这是 AQS 的 long 版本,队列逻辑重复了一份,代码量增加,但 64 位平台上 long CAS 与 int CAS 的指令成本相同,state 本身也只是从一个 4 字节字段变成 8 字节字段。
为什么不能继续用 16 位、把读者上限当成「够用」?因为溢出不是抛异常那么简单。下面的位运算演示会看到:16 位读计数加到 65536 时,进位会直接污染写计数。
位运算拆解实测
把两本账真正算一遍,比读注释可靠。下面的程序用 JDK 25 的常量拼出 state,再按 sharedCount / exclusiveCount 拆回来。
package juc.ch03;
public final class StateSplitDemo {
static final int SHARED_SHIFT = 32;
static final long SHARED_UNIT = 1L << SHARED_SHIFT;
static final long MAX_COUNT = Integer.MAX_VALUE;
static final long EXCLUSIVE_MASK = (1L << SHARED_SHIFT) - 1;
static int sharedCount(long c) { return (int) (c >>> SHARED_SHIFT); }
static int exclusiveCount(long c) { return (int) (c & EXCLUSIVE_MASK); }
public static void main(String[] args) {
long state = ((long) 5 << SHARED_SHIFT) | 3L;
System.out.printf("state=0x%016X shared=%d exclusive=%d%n",
state, sharedCount(state), exclusiveCount(state));
System.out.printf("SHARED_UNIT=0x%016X MAX_COUNT=%d EXCLUSIVE_MASK=0x%016X%n",
SHARED_UNIT, MAX_COUNT, EXCLUSIVE_MASK);
long readers = 7L * SHARED_UNIT;
long plusWriter = readers + 1;
System.out.printf("7 readers + 1 writer -> 0x%016X shared=%d exclusive=%d%n",
plusWriter, sharedCount(plusWriter), exclusiveCount(plusWriter));
long afterRelease = plusWriter - SHARED_UNIT;
System.out.printf("release one reader -> 0x%016X shared=%d exclusive=%d%n",
afterRelease, sharedCount(afterRelease), exclusiveCount(afterRelease));
int oldShift = 16;
int oldUnit = 1 << oldShift;
int oldMask = oldUnit - 1;
int oldState = (65535 << oldShift) | 1;
int overflowed = oldState + oldUnit;
System.out.printf("old16: 65536th reader -> 0x%08X readers=%d writers=%d%n",
overflowed, overflowed >>> oldShift, overflowed & oldMask);
}
}
$ javac --release 25 -d out src/juc/ch03/StateSplitDemo.java
$ java -cp out juc.ch03.StateSplitDemo
state=0x0000000500000003 shared=5 exclusive=3
SHARED_UNIT=0x0000000100000000 MAX_COUNT=2147483647 EXCLUSIVE_MASK=0x00000000FFFFFFFF
7 readers + 1 writer -> 0x0000000700000001 shared=7 exclusive=1
release one reader -> 0x0000000600000001 shared=6 exclusive=1
old16: 65536th reader -> 0x00000001 readers=0 writers=1
几行输出对应几个设计点:
SHARED_UNIT是1L << 32,十六进制0x0000000100000000。读者加减法不需要乘除,直接加减这个单位。EXCLUSIVE_MASK是0x00000000FFFFFFFF,只保留低 32 位。exclusiveCount不做移位,所以写计数永远不会「看见」读者位。- 合成的
state=5 个读者 + 3 次写重入能正确拆成 5 和 3。真实运行时两半同时非零只出现在降级窗口,但编码本身允许这种组合。 - 旧 16 位布局下,第 65536 个读者让
state从0xFFFF0001进位成0x00000001:读者总数归零,写计数还是 1。后续读者会以为锁是空闲的,写者也会以为没有读者。这就是旧实现必须在MAX_COUNT处抛Error的原因。
这也是读锁的代价:每个读者都要对同一个 state 做一次 CAS,核心数一多,这个缓存行就是争用点。读多写少时它比互斥锁好,但不是无锁。
HoldCounter:state 只有总数,重入要分线程
state 的高 32 位只有一个总数,回答不了两个问题:
- 当前线程到底有没有持有读锁?
unlockRead必须校验,否则任何线程都能把别人的读计数减掉; - 当前线程重入了几次?重入两次就要释放两次,不能第一次释放就把整个读锁放掉。
写锁有 exclusiveOwnerThread 记录持有者,读锁是共享的,AQS 没有「共享模式持有者」这个概念。于是 Sync 在 state 之外维护一本分线程账本:ThreadLocalHoldCounter,每个线程一个 HoldCounter,里面是 count 和线程 id。
但每次读锁获取/释放都查 ThreadLocal 太贵,所以实现加了两级缓存:
firstReader/firstReaderHoldCount:第一个把读计数从 0 变成 1、且还没释放的线程,走字段直读,不碰ThreadLocal;cachedHoldCounter:最近一次成功获取读锁的线程的计数器,释放时先比对线程 id,命中就省掉ThreadLocal查找。
HoldCounter 存的是 tid 而不是 Thread 引用,源码注释写得很清楚:避免缓存比线程活得久时把 Thread 对象钉住。
用反射把这三个字段读出来,可以看到两条路径确实在切换。运行时需要 --add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED。
package juc.ch03;
import java.lang.reflect.Field;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public final class HoldCounterProbe {
public static void main(String[] args) throws Exception {
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Object sync = field(rw, ReentrantReadWriteLock.class, "sync");
rw.readLock().lock();
rw.readLock().lock();
System.out.printf("main twice: state=0x%016X shared=%d firstReader=%s firstReaderHoldCount=%d cached=%s%n",
state(sync), rw.getReadLockCount(), name(get(sync, "firstReader")),
get(sync, "firstReaderHoldCount"), describe(get(sync, "cachedHoldCounter")));
CountDownLatch in = new CountDownLatch(1);
CountDownLatch out = new CountDownLatch(1);
int[] otherHold = new int[1];
Thread other = new Thread(() -> {
rw.readLock().lock();
otherHold[0] = rw.getReadHoldCount();
in.countDown();
try { out.await(); } catch (InterruptedException ignored) { }
rw.readLock().unlock();
}, "probe-reader");
other.start();
in.await();
Object cached = get(sync, "cachedHoldCounter");
System.out.printf("other once: state=0x%016X shared=%d cached.tid=%d otherId=%d otherHold=%d mainHold=%d%n",
state(sync), rw.getReadLockCount(), get(cached, "tid"), other.threadId(),
otherHold[0], rw.getReadHoldCount());
out.countDown();
other.join();
rw.readLock().unlock();
rw.readLock().unlock();
System.out.printf("after release: state=0x%016X shared=%d cached=%s%n",
state(sync), rw.getReadLockCount(), describe(cached));
}
static long state(Object sync) throws Exception { return (long) get(sync, "state"); }
static String describe(Object hc) throws Exception {
return hc == null ? "null" : "HoldCounter{tid=" + get(hc, "tid") + ", count=" + get(hc, "count") + "}";
}
static String name(Object t) { return t == null ? "null" : ((Thread) t).getName(); }
static Object field(Object target, Class<?> cls, String name) throws Exception {
Field f = cls.getDeclaredField(name);
f.setAccessible(true);
return f.get(target);
}
static Object get(Object target, String name) throws Exception {
for (Class<?> c = target.getClass(); c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f.get(target);
} catch (NoSuchFieldException ignored) { }
}
throw new NoSuchFieldException(name);
}
}
$ javac --release 25 -d out src/juc/ch03/HoldCounterProbe.java
$ java --add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED -cp out juc.ch03.HoldCounterProbe
main twice: state=0x0000000200000000 shared=2 firstReader=main firstReaderHoldCount=2 cached=null
other once: state=0x0000000300000000 shared=3 cached.tid=25 otherId=25 otherHold=1 mainHold=2
after release: state=0x0000000000000000 shared=0 cached=HoldCounter{tid=25, count=0}
逐行读:
- 主线程连续两次
readLock,state高 32 位是 2,firstReader是main,firstReaderHoldCount是 2,cachedHoldCounter还是null。重入路径完全没有分配HoldCounter。 - 第二个线程获取读锁后,
state变成 3,firstReader仍是main(它还是第一个读者),cachedHoldCounter指向新线程的计数器,tid=25与threadId()一致。 - 两个线程各自的
getReadHoldCount()分别是 2 和 1(输出里的mainHold/otherHold)。state只有一个 3,分线程的 2/1 只能来自HoldCounter。 - 全部释放后
state=0,但cachedHoldCounter还留着count=0的对象。它只是缓存,不是所有权记录;下一次别的线程释放时,tid不匹配就会走readHolds.get()。
这里也藏着两个真实风险。第一,线程拿着读锁结束(线程死亡前没有 unlock),HoldCounter 随线程的 ThreadLocalMap 一起消失,但 state 里的读计数还在,写锁永远拿不到。第二,getReadHoldCount() 每次都要查 ThreadLocal,它适合监控和断言,不要放进热路径。
在 libraryd-conc 里落地
把 LockedLoanService 的内部锁从 ReentrantLock 换成 ReentrantReadWriteLock:库存扣减、配额校验、借阅记录写入走写锁;按 ISBN 查询走读锁。查询会读 stock 的物化值,所以必须和写操作互斥于同一把锁。
package com.plume.conc.core;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public final class LockedLoanService implements LoanService {
private static final int DEFAULT_QUOTA = 5;
private static final long LOAN_SECONDS = 14L * 24 * 3600;
private final BookRepository books;
private final LoanRepository loans;
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final Map<String, Integer> stock = new HashMap<>();
private final Map<String, Integer> borrowed = new HashMap<>();
public LockedLoanService(BookRepository books, LoanRepository loans) {
this.books = books;
this.loans = loans;
}
@Override
public Reservation reserve(String isbn, String memberId) {
rw.writeLock().lock();
try {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
int left = stock.computeIfAbsent(isbn, key -> book.stock());
if (left <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
int held = borrowed.getOrDefault(memberId, 0);
if (held >= DEFAULT_QUOTA) {
throw new LoanException(ErrorCode.QUOTA_EXCEEDED, memberId);
}
stock.put(isbn, left - 1);
borrowed.put(memberId, held + 1);
Reservation reservation = new Reservation(UUID.randomUUID().toString(), isbn,
memberId, Instant.now().plusSeconds(LOAN_SECONDS));
loans.save(new Loan(reservation.loanId(), isbn, memberId, reservation.dueAt()));
return reservation;
} finally {
rw.writeLock().unlock();
}
}
@Override
public boolean returnBook(String loanId) {
rw.writeLock().lock();
try {
Optional<Loan> found = loans.findById(loanId);
if (found.isEmpty()) {
return false;
}
Loan loan = found.get();
stock.computeIfPresent(loan.isbn(), (key, value) -> value + 1);
borrowed.computeIfPresent(loan.memberId(), (key, value) -> value > 1 ? value - 1 : null);
return true;
} finally {
rw.writeLock().unlock();
}
}
public Optional<Book> find(String isbn) {
rw.readLock().lock();
try {
return books.findByIsbn(isbn).map(book -> new Book(book.isbn(), book.title(),
stock.getOrDefault(book.isbn(), book.stock())));
} finally {
rw.readLock().unlock();
}
}
}
find 刻意也读 stock:只有读写共享同一份库存视图,读锁才有意义;computeIfAbsent 与 computeIfPresent 避免给从未物化的 ISBN 凭空加库存。
常见错误与边界
- 按旧文章记 16 位:
SHARED_SHIFT、MAX_COUNT都变了。写「读锁上限 65535」在 JDK 25 上是错的。可以用javap -p或getReadLockCount()的返回类型核对。 - 在生产代码里反射读
state:需要--add-opens,而且字段名和布局随版本变化。监控用getReadLockCount()、isWriteLocked()、getWriteHoldCount()。 unlockRead不配对:当前线程没持有读锁时释放,tryReleaseShared会走到unmatchedUnlockException,抛IllegalMonitorStateException;它不会静默减掉别人的计数。- 线程死亡时仍持有读锁:
state的读计数不会自动回收,写锁永久阻塞。线程池里尤其要保证finally释放。 - 什么时候不要用:写操作占比高、临界区无法缩短、需要读锁升级写锁,都不适合
ReentrantReadWriteLock。升级的需求应该改写流程,或者看 3.3 的StampedLock转换接口能覆盖到什么程度。
小结
- JDK 25 的
ReentrantReadWriteLock.Sync继承AbstractQueuedLongSynchronizer,state是long,读写各 32 位;JDK 8 才是 16/16。 SHARED_UNIT是读计数的加减单位,EXCLUSIVE_MASK保证写计数只取低 32 位,MAX_COUNT是两条账各自的上限。state只存总数,分线程的重入次数由HoldCounter+ThreadLocalHoldCounter维护;firstReader和cachedHoldCounter是避免ThreadLocal查找的两级缓存。HoldCounter存tid而不是Thread引用,是为了不阻止线程对象回收;计数归零时readHolds.remove(),否则线程池场景会积累。
阅读导航:上一节 2.3 锁的中断、超时与可重入 | 下一节 3.2 锁降级与写锁饥饿
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。