承接的问题
卷一 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() | 0 | hasQueuedThreads() / hasQueuedPredecessors() 把主线程挡回队尾 |
公平 tryLock() | 93 | Sync.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 的条件队列
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。