《Java 并发编程·JUC 源码与容器》2.1 ReentrantLock 的公平与非公平

JDK 25 把公平与非公平逻辑收进 initialTryLock:非公平两次不看队列直接 CAS,公平先用 hasQueuedThreads 再用 hasQueuedPredecessors 拦插队。用 100 轮插队计数实验给出四组真实数据,并解释非公平为何吞吐更高、公平锁的 tryLock 为何仍然会插队。

承接的问题

卷一 9.2 已经讲过 ReentrantLock 的用法:它比 synchronized 多了可中断、可超时、可选公平和多条件队列。本节不再重复这些 API,只回答源码层的三个问题:

  • 非公平锁到底在哪个时刻插队?插了几次?
  • 公平锁靠什么拦住新来的线程?为什么需要两道检查?
  • 非公平的吞吐优势从哪来,代价又是什么?

先交代版本差异。大量旧笔记写的是 JDK 8 的结构:NonfairSync.lock() 里直接 compareAndSetState(0, 1),失败再 acquire(1),而 nonfairTryAcquire() 负责重入判断和第二次 CAS。JDK 25 已经重写这部分:Sync.lock() 上移为模板方法,子类只实现 initialTryLock();nonfairTryAcquire 这个名字在 ReentrantLock.java 中已经不存在。读旧资料时要把名字对上,否则会以为源码漏了方法。

非公平锁:两次不看队列的 CAS

JDK 25 的 Sync 把“先试一次、失败再走 AQS”固定成模板:

// JDK 25 源码摘录,已删减
final void lock() {
    if (!initialTryLock())
        acquire(1);
}

static final class NonfairSync extends Sync {
    final boolean initialTryLock() {
        Thread current = Thread.currentThread();
        if (compareAndSetState(0, 1)) { // first attempt is unguarded
            setExclusiveOwnerThread(current);
            return true;
        } else if (getExclusiveOwnerThread() == current) {
            int c = getState() + 1;
            if (c < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(c);
            return true;
        } else
            return false;
    }

    protected final boolean tryAcquire(int acquires) {
        if (getState() == 0 && compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        }
        return false;
    }
}

这段代码回答了第一个问题。非公平的第一次插队在 initialTryLock():它只看 state 是不是 0,完全不看等待队列,CAS 成功就认为锁归自己。第二次插队在 acquire(1) 入队前的 tryAcquire():同样只做 state == 0 的 CAS,仍然不看队列。严格说,AQS 主循环在真正 park 之前还会再调一次 tryAcquire,所以插队机会不止两次;但两次快速路径已经足够解释行为:只要锁在某一瞬间空闲,新来的线程就有机会先拿,不管队列里排了多少人。

另一个值得注意的分工是:initialTryLock() 已经处理了重入,所以 tryAcquire() 只处理“非重入”的首次获取。JDK 8 的 nonfairTryAcquire() 把重入和首次获取揉在一个方法里,JDK 25 把它们拆开了。这个拆分让公平与非公平只在两个点上不同:initialTryLock() 和 tryAcquire();Sync.lock()、lockInterruptibly()、tryLockNanos() 都是共享模板。

公平锁:两道闸门

公平锁的 initialTryLock() 多了一个 hasQueuedThreads() 判断,tryAcquire() 多了一个 hasQueuedPredecessors() 判断:

// JDK 25 源码摘录,已删减
static final class FairSync extends Sync {
    final boolean initialTryLock() {
        Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedThreads() && compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        } else if (getExclusiveOwnerThread() == current) {
            if (++c < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(c);
            return true;
        }
        return false;
    }

    protected final boolean tryAcquire(int acquires) {
        if (getState() == 0 && !hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        }
        return false;
    }
}

hasQueuedThreads() 是粗判断:队列里有没有活着的等待者。hasQueuedPredecessors() 是细判断:当前线程前面有没有人。两者都不能省:

  • 快速路径只需要知道“队列是不是空的”,hasQueuedThreads() 从 tail 向前走,碰到第一个未取消节点就返回,足够便宜。
  • 重试路径可能发生在当前线程已经入队、或者队列链接正在变化的时候,必须确认“我是不是第一个”,否则会把锁让给一个已经取消的节点,或者自己插到自己前面。

hasQueuedPredecessors() 的实现也体现了这种防御:

// JDK 25 源码摘录,已删减
public final boolean hasQueuedPredecessors() {
    Thread first = null; Node h, s;
    if ((h = head) != null && ((s = h.next) == null ||
                               (first = s.waiter) == null ||
                               s.prev == null))
        first = getFirstQueuedThread(); // retry via getFirstQueuedThread
    return first != null && first != Thread.currentThread();
}

正常情况下直接读 head.next.waiter;一旦链接不一致(next 还没接上、waiter 还没写、prev 被清空),就退回 getFirstQueuedThread() 从尾部向前找。这种“快路径读链接、慢路径重扫”的写法在 AQS 里反复出现,原因是入队时先写 prev、再 CAS tail、最后才接 next;读到一个半成品链接不能直接下结论。

公平锁也不是绝对公平:它只保证“锁的获取顺序”尽量接近入队顺序,不保证业务线程的调度顺序;而且 tryLock() 的无参版本会绕过公平性,这一点下一节用实验说明。

插队计数实验

要证明“插队”,最直接的办法不是测吞吐,而是数次数。实验设计如下:主线程先持有锁,启动 4 个等待线程,等它们全部进入 AQS 队列;主线程 unlock() 之后立刻再申请一次锁。如果这次申请成功时,4 个等待线程一次都还没拿到锁,就记一次插队。

import java.lang.management.ManagementFactory;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public final class FairnessBarge {
    enum Mode { LOCK, TRYLOCK, TRYLOCK_TIMED }
    static final int WAITERS = 4;
    static final int ROUNDS = 100;

    public static void main(String[] args) throws Exception {
        System.out.println("rounds=" + ROUNDS + " waiters=" + WAITERS
                + " cpus=" + Runtime.getRuntime().availableProcessors());
        report("nonfair lock()", false, Mode.LOCK);
        report("fair   lock()", true, Mode.LOCK);
        report("fair   tryLock()", true, Mode.TRYLOCK);
        report("fair   tryLock(0,SECONDS)", true, Mode.TRYLOCK_TIMED);
        System.out.println("loadAverage="
                + ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage());
    }

    static void report(String label, boolean fair, Mode mode) throws Exception {
        int barges = run(fair, mode);
        System.out.printf("%-26s barges=%d/%d%n", label, barges, ROUNDS);
    }

    static int run(boolean fair, Mode mode) throws Exception {
        ReentrantLock lock = new ReentrantLock(fair);
        int barges = 0;
        for (int r = 0; r < ROUNDS; r++) {
            lock.lock();
            AtomicInteger acquired = new AtomicInteger();
            CountDownLatch ready = new CountDownLatch(WAITERS);
            CountDownLatch go = new CountDownLatch(1);
            Thread[] ts = new Thread[WAITERS];
            for (int i = 0; i < WAITERS; i++) {
                ts[i] = Thread.ofPlatform().start(() -> {
                    ready.countDown();
                    try {
                        go.await();
                    } catch (InterruptedException e) {
                        return;
                    }
                    lock.lock();
                    try {
                        acquired.incrementAndGet();
                    } finally {
                        lock.unlock();
                    }
                });
            }
            ready.await();
            go.countDown();
            while (lock.getQueueLength() < WAITERS) {
                Thread.onSpinWait();
            }
            lock.unlock();
            boolean got;
            if (mode == Mode.LOCK) {
                lock.lock();
                got = true;
            } else if (mode == Mode.TRYLOCK) {
                got = lock.tryLock();
            } else {
                got = lock.tryLock(0, TimeUnit.SECONDS);
            }
            if (got && acquired.get() == 0) {
                barges++;
            }
            if (got) {
                lock.unlock();
            }
            for (Thread t : ts) {
                t.join();
            }
        }
        return barges;
    }
}

本机 Temurin 25.0.5+8 上的输出(javac --release 25 后 java -cp . FairnessBarge):

rounds=100 waiters=4 cpus=10
nonfair lock()             barges=99/100
fair   lock()              barges=0/100
fair   tryLock()           barges=93/100
fair   tryLock(0,SECONDS)  barges=0/100
loadAverage=84.39306640625

四行数据对应四个结论:

调用100 轮插队次数解释
非公平 lock()99释放后主线程立刻 CAS,等待者还没被唤醒就已经输
公平 lock()0hasQueuedThreads() / hasQueuedPredecessors() 把主线程挡回队尾
公平 tryLock()93Sync.tryLock() 固定非公平,state == 0 就 CAS,不查队列
公平 tryLock(0, SECONDS)0走 initialTryLock(),公平检查生效,0 超时直接返回 false

非公平那行不是 100/100,是因为极少数轮次里等待者已经在主线程重新申请前抢到了锁;公平 tryLock() 的 93/100 也是同理,剩下 7 次是等待者先拿到锁、主线程的 tryLock() 返回 false,不计插队。这说明“插队”是概率窗口,不是保证。公平锁的两行则是结构性结果:只要队列非空,主线程不可能拿到锁,所以是 0,不受负载影响。

关于吞吐,本节不给 JMH 数字。实验时本机 load average 在 84–174 之间波动,任何吞吐对比都会被调度噪声淹没,写出来的数不可引用。结构性解释已经足够:

  • 非公平锁允许正在运行的线程在释放后立刻重新拿到锁,省掉一次 unpark 唤醒后继、后继再被调度上 CPU 的往返;锁持有时间越短,这个窗口越值钱。
  • 公平锁每次释放都把竞争机会优先让给队首,新来的线程必须排到队尾;队首先被唤醒、再参与竞争,如果临界区很短,唤醒开销可能超过临界区本身。
  • 非公平不等于饿死:等待者仍在队列里,每次释放都会 signalNext(head) 唤醒后继;只是持续高竞争时,新来的线程可能反复抢在它前面,形成长尾。

在 libraryd-conc 里落地

卷一 9.2 的 LockedLoanService 用的是默认非公平锁。本卷保持这个默认值,只把公平性做成构造参数,方便在需要严格顺序的场景切换:

import java.time.Instant;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public final class LockedLoanService implements LoanService {
    private final BookRepository books;
    private final LoanRepository loans;
    private final ReentrantLock lock;

    public LockedLoanService(BookRepository books, LoanRepository loans, boolean fair) {
        this.books = books;
        this.loans = loans;
        this.lock = new ReentrantLock(fair);
    }

    @Override
    public Reservation reserve(String isbn, String memberId) {
        boolean acquired;
        try {
            acquired = lock.tryLock(200, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new LoanException(ErrorCode.INTERRUPTED, "等待借阅锁时被中断");
        }
        if (!acquired) {
            throw new LoanException(ErrorCode.CONFLICT, "借阅服务繁忙,请稍后重试");
        }
        try {
            Book book = books.findByIsbn(isbn)
                    .orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
            if (book.stock() <= 0) {
                throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
            }
            books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
            String loanId = "loan-" + System.nanoTime();
            Instant dueAt = Instant.now().plusSeconds(86400);
            loans.save(new Loan(loanId, isbn, memberId, dueAt));
            return new Reservation(loanId, isbn, memberId, dueAt);
        } finally {
            lock.unlock();
        }
    }
}

三点说明:

  • 生产默认传 fair = false。借阅扣减是短临界区,非公平锁让刚处理完上一个请求的线程直接继续,少一次上下文切换;公平性在这里买不到业务价值。
  • 只有当“同一借阅单的后续操作必须严格排在前面”时,才传 fair = true。例如测试里要验证归还请求一定在借出请求之后处理,公平锁能把这种顺序从概率事件变成稳定事件。
  • 即使用了公平锁,reserve() 里的 lock.tryLock(200, TimeUnit.MILLISECONDS) 仍会遵守公平性,因为带超时的 tryLock 走 initialTryLock();如果改成无参 tryLock(),它会插队。这个差异在 2.3 还会再出现一次。

如果业务需要“读多写少”,不要在这把公平锁上继续加码,应该换 ReentrantReadWriteLock(3.1)或 StampedLock(3.3)。公平锁解决的是排队顺序,不解决读写冲突的粒度。

这个设计的代价

  • 公平锁的吞吐代价是结构性的。 每次释放都要唤醒队首,队首被唤醒后还要重新竞争,短临界区下唤醒成本占比很高。它不是“稍微慢一点”,而是把一次本地 CAS 变成一次跨线程调度。
  • 非公平锁不保证无饥饿。 理论上持续到达的新线程可以反复插队;实践中 AQS 的 signalNext(head) 和“唤醒后重试”会限制这种情况,但长临界区加高并发时仍要监控排队时间。线上诊断放到卷三第 4 章展开。
  • 公平只覆盖 lock() 和带超时的 tryLock()。 无参 tryLock()、tryLock(0, unit) 的语义不同,前者插队,后者不插队;写代码时不要凭“锁是公平的”推断所有入口都公平。
  • 不要用公平锁保护包含数据库或 RPC 的临界区。 公平锁让所有线程排队,临界区里的 IO 会把队尾等待时间放大;这类操作应该在锁外完成,锁内只做内存状态变更。

小结

  • JDK 25 的 ReentrantLock 用 initialTryLock() + tryAcquire() 两个扩展点区分公平与非公平,nonfairTryAcquire 已是旧版名字。
  • 非公平的插队发生在两处不看队列的 CAS;公平锁用 hasQueuedThreads() 挡快速路径、用 hasQueuedPredecessors() 挡重试路径。
  • 插队计数实验里,非公平 lock() 99/100 次插队,公平 lock() 0/100;公平锁的 tryLock() 仍会插队 93/100,tryLock(0, SECONDS) 才是 0/100。
  • 默认用非公平;只有业务顺序确实需要时才用公平,并接受它的唤醒成本。

阅读导航:上一节 1.3 共享模式与传播机制 | 下一节 2.2 Condition 的条件队列

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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