《Java 并发编程·JUC 源码与容器》3.2 锁降级与写锁饥饿

锁降级为什么必须先拿读锁再放写锁,升级为什么会自死锁,非公平模式的两条插队规则各自把谁饿住。附锁降级示例、12 次写者等待分布,以及 96 次与数千次以上的插队计数对比,并说明 JDK 25 的 head-writer 启发式为什么让完整写饥饿难以复现。

承接的问题

3.1 把 state 拆成了读计数和写计数,并留下一个伏笔:降级窗口里两半会同时非零。降级是读写锁最容易被写错的地方——顺序反了不一定立刻报错,而是在压力下读到不一致的库存;升级则更直接,写错就是自己等自己。

本节回答三个问题:锁降级为什么必须「先拿读锁,再放写锁」,反过来会丢什么;为什么读锁不能升级成写锁,代码层面卡在哪一行;非公平模式的插队规则到底偏向谁,所谓「写饥饿」在 JDK 25 上还能不能复现。

降级:先拿读锁,再放写锁

降级的目的是「写完立刻以一个稳定版本继续读」。比如刷新库存快照后,要用新快照重建索引;如果先放写锁再拿读锁,中间会开一个窗口,别的写者可以进来改掉快照,你重建索引时读到的就不再是自己刚发布的版本。

实现上能这么做,是因为 tryAcquireShared 对「写锁持有者自己来拿读锁」开了绿灯。

// JDK 25 源码摘录,已删减:ReentrantReadWriteLock.Sync.tryAcquireShared
            Thread current = Thread.currentThread();
            long c = getState();
            if (exclusiveCount(c) != 0 &&
                getExclusiveOwnerThread() != current)
                return -1L;
            int r = sharedCount(c);
            if (!readerShouldBlock() &&
                r < MAX_COUNT &&
                compareAndSetState(c, c + SHARED_UNIT)) {
                if (r == 0) {
                    firstReader = current;
                    firstReaderHoldCount = 1;
                } else if (firstReader == current) {
                    firstReaderHoldCount++;
                } else {
                    HoldCounter rh = cachedHoldCounter;
                    if (rh == null ||
                        rh.tid != LockSupport.getThreadId(current))
                        cachedHoldCounter = rh = readHolds.get();
                    else if (rh.count == 0)
                        readHolds.set(rh);
                    rh.count++;
                }
                return 1L;
            }

第一段判断是关键:只要写锁被当前线程持有,就不返回失败,而是继续走读锁获取。fullTryAcquireShared 里对应的注释更直白:写锁持有者如果在这里阻塞,会死锁。所以降级不是语法糖,而是 Sync 特意允许的状态转换。

顺序反过来会发生什么?看下面的实测。

package juc.ch03;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public final class DowngradeDemo {
    public static void main(String[] args) throws Exception {
        ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
        rw.writeLock().lock();
        System.out.printf("write held: isWriteLocked=%s readLocks=%d writeHolds=%d%n",
                rw.isWriteLocked(), rw.getReadLockCount(), rw.getWriteHoldCount());

        rw.readLock().lock();          // 先拿读锁
        rw.writeLock().unlock();       // 再放写锁
        System.out.printf("after downgrade: isWriteLocked=%s readLocks=%d readHolds=%d%n",
                rw.isWriteLocked(), rw.getReadLockCount(), rw.getReadHoldCount());

        Thread competing = new Thread(() -> {
            boolean got = rw.writeLock().tryLock();
            System.out.printf("competing writer tryLock while downgraded reader holds: %s%n", got);
            if (got) rw.writeLock().unlock();
        }, "competing-writer");
        competing.start();
        competing.join();
        rw.readLock().unlock();
        System.out.printf("after read release: readLocks=%d%n", rw.getReadLockCount());

        rw.readLock().lock();
        boolean upgraded = rw.writeLock().tryLock(100, TimeUnit.MILLISECONDS);
        System.out.printf("upgrade attempt from read lock: %s (readLocks=%d)%n",
                upgraded, rw.getReadLockCount());
        rw.readLock().unlock();
    }
}
$ javac --release 25 -d out src/juc/ch03/DowngradeDemo.java
$ java -cp out juc.ch03.DowngradeDemo
write held: isWriteLocked=true readLocks=0 writeHolds=1
after downgrade: isWriteLocked=false readLocks=1 readHolds=1
competing writer tryLock while downgraded reader holds: false
after read release: readLocks=0
upgrade attempt from read lock: false (readLocks=1)

降级完成后 isWriteLocked=false,但 readLocks=1,说明当前线程仍然占着读锁。此时竞争写者的 tryLock() 返回 false——降级保住了「释放写锁之后、读锁仍然有效」这段连续性。若先 unlock 写锁再 readLock(),中间任何写者都能拿到写锁并修改数据,你随后拿到的读锁保护的是新版本,不是自己刚写的那一版。

代价也要说清楚:降级期间你同时占着读锁和写锁的交接窗口。窗口越长,写者被挡住越久。降级只适合「发布快照后立刻做一次有限的读操作」,不适合在持有读锁时做远程调用或大批量计算。

升级为什么不允许

升级是反方向:持有读锁,再要写锁。tryAcquire 在 state != 0 时只看写计数:

// JDK 25 源码摘录,已删减:ReentrantReadWriteLock.Sync.tryAcquire
            Thread current = Thread.currentThread();
            long c = getState();
            long w = exclusiveCount(c);
            if (c != 0) {
                // (Note: if c != 0 and w == 0 then shared count != 0)
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                // Reentrant acquire
                setState(c + acquires);
                return true;
            }
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            setExclusiveOwnerThread(current);
            return true;

当前线程持有读锁时,c != 0 而 w == 0,直接 return false。即使 current 是自己也没用:写锁的语义是「没有读者」,而你自己就是读者之一。如果实现允许升级,它就必须等待包括自己在内的所有读者退出,于是永远等不到——自死锁。tryLock(100ms) 的实测返回 false,不会阻塞到超时以外的状态。

需要「读到后条件性写」时,正确做法是:释放读锁,重新获取写锁,拿到写锁后重新检查条件。因为在你放锁和拿锁之间,条件可能已经被别人改掉。把「检查」和「修改」都放进写锁临界区,才能保证原子性。

非公平模式:两条不对称的插队规则

非公平模式不是「随机」,它有两条写死的规则。

// JDK 25 源码摘录,已删减:ReentrantReadWriteLock.NonfairSync / FairSync
    static final class NonfairSync extends Sync {
        final boolean writerShouldBlock() {
            return false; // writers can always barge
        }
        final boolean readerShouldBlock() {
            /* As a heuristic to avoid indefinite writer starvation,
             * block if the thread that momentarily appears to be head
             * of queue, if one exists, is a waiting writer.  This is
             * only a probabilistic effect since a new reader will not
             * block if there is a waiting writer behind other enabled
             * readers that have not yet drained from the queue.
             */
            return apparentlyFirstQueuedIsExclusive();
        }
    }

    static final class FairSync extends Sync {
        final boolean writerShouldBlock() {
            return hasQueuedPredecessors();
        }
        final boolean readerShouldBlock() {
            return hasQueuedPredecessors();
        }
    }

两条规则的分量完全不同。writerShouldBlock() 在非公平模式下恒为 false,源码注释写的是「writers can always barge」:写者永远可以插队,只要此刻没有读者、没有别的写者,它就 CAS 进去,不管队列里排了多少读者。readerShouldBlock() 只在「队列中看上去的第一个节点是独占写者」时返回 true:只有写者已经排到队首,新读者才会被挡住;如果写者前面还有没排空的读者,新读者仍然可以插到写者前面。

这解释了两个方向的饥饿:读者被写者饿住是非公平模式可以直接量到的,写者无限插队,排队的读者可能很久拿不到锁;写者被读者饿住理论上存在,但被 apparentlyFirstQueuedIsExclusive 挡住了大部分场景——写者一旦成为队首,新读者就被拦下,等当前读者排空后写者就能进入。

实测:等待分布与插队计数

12 个读者按 8 ms 间隔启动、各持有读锁 50 ms,写者在第 40 ms 请求写锁,重复 12 次。记录写者等待时长与是否超时。

package juc.ch03;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public final class StarvationDemo {
    static final int READERS = 12;
    static final long STAGGER_MS = 8, HOLD_MS = 50, TIMEOUT_MS = 3000;
    record Trial(long writerWaitMs, boolean timedOut) { }

    public static void main(String[] args) throws Exception {
        for (boolean fair : new boolean[]{false, true}) {
            List<Long> waits = new ArrayList<>();
            int timeouts = 0;
            for (int i = 0; i < 12; i++) {
                Trial t = run(fair);
                waits.add(t.writerWaitMs());
                if (t.timedOut()) timeouts++;
            }
            long[] sorted = waits.stream().mapToLong(Long::longValue).sorted().toArray();
            System.out.printf("%s writerWait p50=%d p90=%d max=%d timeouts=%d/12%n",
                    fair ? "fair  " : "nonfair", sorted[sorted.length / 2],
                    sorted[(int) Math.ceil(sorted.length * 0.9) - 1], sorted[sorted.length - 1], timeouts);
        }
    }

    static Trial run(boolean fair) throws Exception {
        ReentrantReadWriteLock rw = new ReentrantReadWriteLock(fair);
        List<Thread> readers = new ArrayList<>();
        for (int i = 0; i < READERS; i++) {
            final int idx = i;
            Thread t = new Thread(() -> {
                try {
                    Thread.sleep(idx * STAGGER_MS);
                    rw.readLock().lock();
                    try { Thread.sleep(HOLD_MS); } finally { rw.readLock().unlock(); }
                } catch (InterruptedException ignored) { }
            }, "reader-" + idx);
            t.start(); readers.add(t);
        }
        Thread.sleep(40);
        long requestAt = System.nanoTime();
        boolean got = rw.writeLock().tryLock(TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
        long waitMs = got ? (System.nanoTime() - requestAt) / 1_000_000 : TIMEOUT_MS;
        if (got) rw.writeLock().unlock();
        for (Thread t : readers) t.join();
        return new Trial(waitMs, !got);
    }
}
$ javac --release 25 -d out src/juc/ch03/StarvationDemo.java
$ java -cp out juc.ch03.StarvationDemo
nonfair writerWait p50=55 p90=60 max=62 timeouts=0/12
fair   writerWait p50=52 p90=59 max=60 timeouts=0/12

结果和很多文章里的「写者被无限饿死」不一样,必须如实说:在这个场景里没有复现出写者超时。两种模式的 p50 都在 52–55 ms,正好是一个读临界区的长度;最差 62 ms。原因就在 readerShouldBlock():写者在第 40 ms 入队后很快成为队首,之后到达的读者被 apparentlyFirstQueuedIsExclusive() 挡住,写者只需等当前这一批读者退出。源码注释也承认这只是「概率性」保护:如果写者前面还有没排空的读者,新读者仍可能插到写者前面,所以完整饥饿不是不可能,只是在这个构造下没有发生。

真正能稳定量到的不公平在另一个方向:写者永远可以插队。让一个写者先持有 30 ms 写锁,4 个写线程持续抢锁,一个读者在第 10 ms 请求读锁,统计读者等待期间有多少次写锁获取成功。

package juc.ch03;

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public final class BargingDemo {
    static final int WRITERS = 4;
    static final long HOLD_MS = 30;
    public static void main(String[] args) throws Exception {
        for (boolean fair : new boolean[]{false, true}) {
            long totalWait = 0;
            int totalWrites = 0;
            for (int trial = 0; trial < 12; trial++) {
                ReentrantReadWriteLock rw = new ReentrantReadWriteLock(fair);
                AtomicBoolean stop = new AtomicBoolean(false);
                AtomicBoolean readerRequested = new AtomicBoolean(false);
                AtomicInteger writesAfter = new AtomicInteger();
                CountDownLatch holding = new CountDownLatch(1);
                Thread holder = new Thread(() -> {
                    rw.writeLock().lock();
                    holding.countDown();
                    try { Thread.sleep(HOLD_MS); } catch (InterruptedException ignored) { }
                    rw.writeLock().unlock();
                });
                holder.start();
                holding.await();
                Thread[] writers = new Thread[WRITERS];
                for (int i = 0; i < WRITERS; i++) {
                    writers[i] = new Thread(() -> {
                        while (!stop.get()) {
                            rw.writeLock().lock();
                            try {
                                if (readerRequested.get()) writesAfter.incrementAndGet();
                            } finally {
                                rw.writeLock().unlock();
                            }
                        }
                    });
                    writers[i].start();
                }
                Thread.sleep(10);
                long t0 = System.nanoTime();
                readerRequested.set(true);
                rw.readLock().lock();
                long waitMs = (System.nanoTime() - t0) / 1_000_000;
                rw.readLock().unlock();
                stop.set(true);
                holder.join();
                for (Thread w : writers) w.join();
                totalWait += waitMs;
                totalWrites += writesAfter.get();
            }
            System.out.printf("%s avgReaderWait=%dms writeAcquiresWhileReaderQueued=%d%n",
                    fair ? "fair  " : "nonfair", totalWait / 12, totalWrites);
        }
    }
}
$ javac --release 25 -d out src/juc/ch03/BargingDemo.java
$ java -cp out juc.ch03.BargingDemo
nonfair avgReaderWait=25ms writeAcquiresWhileReaderQueued=56582
fair   avgReaderWait=21ms writeAcquiresWhileReaderQueued=96

12 次试验里,非公平模式在读者排队期间放行了 56582 次写锁获取,公平模式稳定在 96 次,平均每次试验 8 次。平均等待都在 21–25 ms,但「被插队多少次」差了两个数量级以上。这个计数随机器负载波动,绝对值不可横向引用;稳定的对比是公平模式的 96 与非公平模式的数万。这就是 writerShouldBlock() == false 的实际含义:读者的等待没有上界保证,只是在这个低冲突场景里恰好很快轮到了。

如果业务要求「请求顺序可预期」,用公平模式;如果要求吞吐,用非公平模式,但要把读者等待当成可能的长尾,而不是平均延迟。两种模式都不能解决「读临界区太长」的问题。

在 libraryd-conc 里落地

LockedLoanService 需要支持运营侧整批刷新库存。刷新动作本身要独占,刷新后的索引重建要读一个稳定版本,正好用降级。

    public void refreshStock(Map<String, Integer> latest) {
        rw.writeLock().lock();
        try {
            stock.clear(); stock.putAll(latest);
            rw.readLock().lock();        // 先拿读锁
        } finally {
            rw.writeLock().unlock();     // 再放写锁
        }
        try { rebuildAvailabilityIndex(); } finally { rw.readLock().unlock(); }
    }

    private void rebuildAvailabilityIndex() {
        Map<String, Boolean> next = new HashMap<>();
        for (Map.Entry<String, Integer> entry : stock.entrySet()) {
            next.put(entry.getKey(), entry.getValue() > 0);
        }
        availability = Map.copyOf(next); // volatile 字段,安全发布
    }

rebuildAvailabilityIndex() 在写锁释放后执行,但读锁保证没有写者能在这期间修改 stock;其它读者可以同时进来,不影响一致性。availability 用 volatile 引用一次发布不可变副本,读路径可以无锁访问它。

反过来,如果 refreshStock 里需要调用外部服务拉取最新库存,把这次调用放在写锁外面:先拉数据,再进写锁替换。锁只保护内存状态的替换,不保护网络 IO。

常见错误与边界

  • 先放写锁再拿读锁:这不是降级,是两次独立加锁。中间窗口足够另一个写者改掉数据,后续读到的版本不受保护。
  • 降级后忘记释放读锁:finally 只写了 writeLock().unlock(),读锁泄漏,写者永久等待。降级代码必须有两个 finally,或者用嵌套 try。
  • 读锁升级写锁:tryLock(timeout) 只会超时,lock() 会永久阻塞。正确做法是放读锁、拿写锁、重新校验。
  • 用公平锁解决所有饥饿:公平模式让读者也排队,BargingDemo 里插队次数从 3491 降到 96,但每次交接都要唤醒等待线程,吞吐下降;而且它不缩短读临界区带来的等待。
  • 什么时候不要用:写比例高、临界区不可缩短、需要升级、需要条件队列(Condition 只能从写锁创建),都不适合读写锁。3.3 的 StampedLock 在只读路径更快,但也不解决写者等待。

小结

  • 降级必须「先拿读锁,再放写锁」;tryAcquireShared 对写锁持有者放行读锁,正是为了让这个顺序不死锁。
  • 升级不允许:tryAcquire 看到 w == 0 就失败,写锁必须等所有读者退出,而升级者自己就是读者。
  • 非公平模式的两条规则不对称:写者恒可插队,读者只在队首是写者时被挡;本机实测没有复现写者超时,等待约等于一个读临界区。
  • 能稳定量到的是反方向:写者插队导致读者长尾(数千次以上 vs 96 次);降级窗口越短越好。

阅读导航:上一节 3.1 ReentrantReadWriteLock 的 state 拆分 | 下一节 3.3 StampedLock 的乐观读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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