《Java 并发编程·JUC 源码与容器》3.1 ReentrantReadWriteLock 的 state 拆分

读写锁要把读者总数和写者重入数塞进同一个 state。本节按 JDK 25 源码核对 32/32 位布局、SHARED_UNIT 与 EXCLUSIVE_MASK 的位运算、HoldCounter 与 ThreadLocalHoldCounter 的分线程计数,并给出 javap 与反射探测的本机输出。

承接的问题

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 锁降级与写锁饥饿

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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