《Java 并发编程·线程与内存模型》9.2 ReentrantLock 与 synchronized 的取舍

ReentrantLock 比 synchronized 多出的四件事——可中断、超时、公平、多条件队列,逐项给出本机实测;再用 JFR 验证 JDK 25 默认配置下 synchronized 已不 pin 虚拟线程(LockingMode=1 可复现 20 次 pin),据此重新回答什么时候仍该坚持 synchronized。

承接的问题

9.1 讲完 park/unpark,ReentrantLock 的实现材料就齐了:它用 CAS 抢锁,抢不到就进队列,队列里的线程用 park 阻塞,前驱释放锁时 unpark 后继。这套机制在卷二展开,本节只回答使用层面的问题:

  • synchronized 已经能保证互斥和可见性,为什么还需要 ReentrantLock?
  • 既然老资料都说「虚拟线程要用 ReentrantLock,因为 synchronized 会 pin 载体线程」,JDK 25 还成立吗?
  • 什么情况下应该反过来,坚持用 synchronized?

先把结论放在前面:ReentrantLock 的价值在「可控的等待」——能中断、能超时、能选公平、能分条件队列。JDK 25 修掉 pinning 之后,「虚拟线程必须用 Lock」这条理由已经消失,但前四条仍然成立。

四个只有 Lock 才有的能力

一、可中断地等待

synchronized 的进入是不可中断的:线程在 BLOCKED 状态等监视器,interrupt() 只会设置标志,不会让它退出等待。ReentrantLock.lockInterruptibly() 在排队时响应中断,抛出 InterruptedException 并把自己从队列里摘掉。

ReentrantLock lock = new ReentrantLock();
lock.lock();
Thread waiter = new Thread(() -> {
    try {
        lock.lockInterruptibly();
        try {
            // 拿到锁后的工作
        } finally {
            lock.unlock();
        }
    } catch (InterruptedException e) {
        // 中断发生在等待阶段,锁没有拿到,不需要 unlock
        Thread.currentThread().interrupt();
    }
}, "interruptible-waiter");
waiter.start();
// 实际代码用 lock.hasQueuedThread(waiter) 确认已入队后再中断
waiter.interrupt();

这里有两个容易写错的点:catch 里不能调用 unlock(),因为线程根本没拿到锁;如果本方法不打算处理中断,要恢复中断标志,让上层知道。

二、带超时的尝试

tryLock(long, TimeUnit) 在指定时间内等锁,超时返回 false。这让「等不到就降级」成为可能:返回忙碌、走异步补偿、或者快速失败。

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 {
    // 临界区
} finally {
    lock.unlock();
}

synchronized 没有等价物。没有超时,一个卡住的持锁线程就能把调用方线程池耗尽;这是生产事故里很常见的一类雪崩。

三、公平性

new ReentrantLock(true) 按 FIFO 顺序把锁交给等待最久的线程。非公平模式允许新来的线程插队(barging):如果持锁线程刚释放,新线程恰好在 tryAcquire 里 CAS 成功,它可以直接拿锁,不用排队。插队减少了一次唤醒和上下文切换,吞吐更高,但极端情况下等待者会被不断插队。

公平模式的顺序可以用两个排队线程验证:

phase3 queued before release: B=true C=true
phase3 fair order: B acquired
phase3 fair order: C acquired

B 和 C 都在 hasQueuedThread 里可见,释放后严格按 B、C 的顺序获得锁。注意这是公平锁;默认的非公平锁不保证这个顺序。

另外,tryLock() 的无参版本即使对公平锁也会插队——Javadoc 明确写了这一点。要遵守公平顺序,用 tryLock(0, TimeUnit.SECONDS):它在拿不到锁(包括锁空闲但队列里有人)时立即返回 false,不会绕过队列。

四、多条件队列

一个 synchronized 对象只有一个等待集,notifyAll 会把所有等待者都唤醒,再由各自的条件判断淘汰。ReentrantLock.newCondition() 可以创建多个等待队列,把「等库存」和「等配额」分开:

private final ReentrantLock lock = new ReentrantLock();
private final Condition stockChanged = lock.newCondition();
private final Condition quotaChanged = lock.newCondition();

Condition.await()/signal() 的语义和 wait/notify 对应,但等待前必须持有对应的 Lock,且同样要放在 while 循环里防虚假唤醒。Condition 的实现细节(等待队列、signal 的转移)属于卷二。

三项实测

tryLock(500ms)、lockInterruptibly() 与公平锁顺序的本机输出如下。负载很高,毫秒数只能说明「等满了超时」,不能当性能数据:

phase1 tryLock(500ms): ok=false after 504 ms, isInterrupted=false
phase2 lockInterruptibly: InterruptedException, isInterrupted=false, mainStillHolds=true
phase2 after unlock: isLocked=false
phase3 queued before release: B=true C=true
phase3 fair order: B acquired
phase3 fair order: C acquired
  • phase1:持锁线程不放锁,tryLock(500ms) 等到超时返回 false,没有提前返回,也没有抛异常。
  • phase2:等待中的线程被 interrupt(),lockInterruptibly 抛 InterruptedException;此时 isInterrupted=false(异常把标志清了),锁仍由主线程持有,说明等待者没有拿到锁,catch 里不能 unlock。
  • phase3:公平锁下 B、C 严格按入队顺序获得锁。

能力对照

能力synchronizedReentrantLock
互斥与可见性有有
可重入有有
等待可中断否lockInterruptibly()
等待可超时否tryLock(timeout)
公平锁否new ReentrantLock(true)
多条件队列单个等待集任意多个 Condition
尝试获取不等待否tryLock()
释放锁的确定性自动(字节码保证)手动 finally
虚拟线程阻塞JDK 25 默认不 pin不 pin

JDK 25 的重新评估:synchronized 不再 pin 虚拟线程

JDK 21 时代有一条流传很广的规则:虚拟线程里不要用 synchronized,会 pin 住载体线程。原因是旧实现里监视器是 JVM 内部的 ObjectMonitor,虚拟线程在 synchronized 块里阻塞时,挂载的载体平台线程无法被卸载,虚拟线程的伸缩性就没了。当时的建议是改用 ReentrantLock,因为它的阻塞点是 LockSupport.park,虚拟线程调度器可以卸载它。

JDK 24 的 JEP 491 重写了 monitor,让 synchronized 阻塞也能卸载虚拟线程。JDK 25 默认使用新实现:

$ java -XX:+PrintFlagsFinal -version | grep -w LockingMode
      int LockingMode                              = 2                {product} {default}

我写了一个最小验证:20 个虚拟线程,每个在 synchronized 块里 wait(50),用 JFR 录 jdk.VirtualThreadPinned 事件:

public class PinTest2 {
    static final Object LOCK = new Object();

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 20; i++) {
            Thread vt = Thread.ofVirtual().name("vt-" + i).start(() -> {
                synchronized (LOCK) {
                    try {
                        LOCK.wait(50);
                    } catch (InterruptedException ignored) {
                    }
                }
            });
            vt.join();
        }
        System.out.println("20 virtual threads: done");
    }
}

先确认事件真的开着——VirtualThreadStart 有 20 条,说明录制配置没问题;然后看 VirtualThreadPinned:

events="jdk.VirtualThreadPinned#enabled=true,jdk.VirtualThreadStart#enabled=true,jdk.VirtualThreadEnd#enabled=true"
java -XX:StartFlightRecording=filename=pin-wait.jfr,settings=profile,dumponexit=true,"$events" -cp . PinTest2 wait
jfr summary pin-wait.jfr | grep VirtualThread
 jdk.VirtualThreadEnd                       20           200
 jdk.VirtualThreadStart                     20           220
 jdk.VirtualThreadPinned                     0             0

默认配置下 20 次「同步块内阻塞」录到 0 次 pin。为了证明事件不是永远不触发,用已弃用的 LockingMode=1 做对照(本机该值会复现 pinning):

java -XX:LockingMode=1 -XX:StartFlightRecording=filename=pin-lm1.jfr,settings=profile,dumponexit=true,"$events" -cp . PinTest2 wait
jfr summary pin-lm1.jfr | grep VirtualThread
 jdk.VirtualThreadEnd                       20           200
 jdk.VirtualThreadPinned                    20          1140
 jdk.VirtualThreadStart                     20           220

同一程序下,LockingMode=1 的 20 次全部 pin,累计 pin 时长约 1140 毫秒;默认配置 0 次。这个对照足够说明:在 JDK 25 默认配置下,「synchronized 会 pin 虚拟线程」已经不成立,把它当作选 ReentrantLock 的理由是过时的。

还有哪些场景会 pin

JEP 491 只解决了 monitor 导致的 pin。虚拟线程在原生方法(native/FFI)调用中阻塞,仍可能 pin 住载体线程;这不在本节实验范围内。诊断方法仍然是 JFR 的 jdk.VirtualThreadPinned 事件,卷三会展开。

LockingMode=1 只是对照实验用的开关,它在 JDK 24 已标记弃用(4.3 节实测过),不要写进生产启动参数。

重新回答「为什么还用 ReentrantLock」

去掉 pinning 这一条之后,剩下的理由都很具体:

  1. 需要中断等待:lockInterruptibly();
  2. 需要超时降级:tryLock(timeout);
  3. 需要公平顺序:new ReentrantLock(true);
  4. 需要多个条件队列:newCondition();
  5. 需要尝试获取、拿不到就走另一条路:tryLock()。

如果这五条一条都不需要,synchronized 更简单:不会忘记 unlock,不会被异常路径绕过,字节码保证释放,JIT 还能做锁消除和锁粗化。「Lock 更快」不是理由——在无竞争和低竞争下两者差距很小,而 synchronized 的实现已经跟着 JDK 一起演进了两轮。

公平锁的代价

公平锁保证 FIFO,代价是每次都可能有排队线程需要唤醒,吞吐通常低于非公平锁。这里不给具体百分比:本机当前负载在 100 以上,任何吞吐对比都会被调度噪声淹没,写出来的数字不可引用。

可以做的是判断「需不需要公平」:

  • 需要:任务有明确的先后语义,例如同一借阅单的后续操作必须排在前面;或者已经观测到个别线程长期拿不到锁。
  • 不需要:绝大多数业务场景。非公平锁的插队减少了唤醒开销,平均延迟更低;「不公平」不等于「饿死」,等待者仍在 AQS 队列里,锁释放时会尝试交接,只是持续高竞争下可能出现长尾。

如果确实需要公平,先确认瓶颈真的是锁顺序,而不是临界区太长。把公平锁加到一段包含数据库调用的临界区上,只会让所有线程一起变慢。

在 libraryd-conc 里落地

把 4.2 节的 SynchronizedLoanService 换成 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 = new ReentrantLock();

    public LockedLoanService(BookRepository books, LoanRepository loans) {
        this.books = books;
        this.loans = loans;
    }

    @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);
            Reservation reservation = new Reservation(loanId, isbn, memberId, dueAt);
            loans.save(new Loan(loanId, isbn, memberId, dueAt));
            return reservation;
        } finally {
            lock.unlock();
        }
    }

    @Override
    public boolean returnBook(String loanId) {
        lock.lock();
        try {
            return loans.findById(loanId)
                    .map(loan -> {
                        loans.save(loan);
                        return true;
                    })
                    .orElse(false);
        } finally {
            lock.unlock();
        }
    }
}

几点说明:

  • tryLock(200ms) 把「等锁」变成有界操作。超时抛 CONFLICT,由上层决定重试还是降级;不会无限期占住 Web 容器的请求线程。
  • 中断发生在等待阶段,锁没拿到,所以 catch 里直接抛异常,不调用 unlock()。
  • 拿到锁之后的临界区用 try/finally 保证释放;LoanException 是运行时异常,也会走 finally。
  • 这版仍然是全局单锁:不同 ISBN 的借阅也串行。9.3 先处理「读多写少」的缓存;按 ISBN 分段与线程池分片属于后续章节的优化,第 10 章会先用池化把不同 ISBN 的请求分开。

如果不关心中断和超时,这个类可以直接退回 synchronized 方法,语义等价且少一个忘记 unlock 的风险。

常见错误与边界

lock() 写在 try 里面。

try {
    lock.lock();       // 错:lock 抛异常时 finally 仍会 unlock
    updateInventory();
} finally {
    lock.unlock();
}

lock() 必须写在 try 之前。虽然 lock() 正常不会抛异常,但带超时的 tryLock 会;更稳妥的写法是统一「获取成功后再进 try」。

在另一个线程里 unlock()。 ReentrantLock 要求释放锁的线程就是持有者,否则抛 IllegalMonitorStateException。把锁对象传给线程池任务时尤其容易犯。

吞掉 InterruptedException 不恢复标志。 catch 里打印一行日志就结束,会让上层永远不知道线程被要求停止。要么继续抛,要么 Thread.currentThread().interrupt()。

忽略 tryLock 的返回值。 tryLock 返回 false 时没有持有锁,后续 unlock 会抛异常。

给所有锁都开公平模式。 公平锁的唤醒开销会叠加在每次交接上;没有顺序要求时默认非公平即可。

Condition.await() 不放在 while 里。 和 wait 一样有虚假唤醒,而且 signal 只保证「曾经被通知」,不保证条件现在成立。

为了虚拟线程而用 ReentrantLock。 在 JDK 25 默认配置下这条理由已经不成立;如果真实动机是「怕 pin」,先用 JFR 确认,而不是凭版本印象改代码。

用 LockingMode=1 回退。 这会把 pinning 带回来,而且参数已弃用。只在对照实验里用。

小结

  • ReentrantLock 相对 synchronized 多出:可中断等待、超时、公平、多条件队列、非阻塞尝试;互斥和可见性两者等价。
  • tryLock(500ms) 本机实测在 504ms 返回 false;lockInterruptibly() 被中断后抛 InterruptedException,中断标志被清除,锁仍由原持有者持有。
  • 公平锁按 FIFO 交接(实测 B 先于 C 获得锁);tryLock() 无参版本即使公平锁也允许插队,tryLock(0, SECONDS) 才遵守队列。
  • JDK 25 默认 LockingMode=2,synchronized 块内阻塞不再 pin 虚拟线程:20 次实验 0 次 jdk.VirtualThreadPinned;切到 LockingMode=1 则 20 次全部 pin。
  • 因此「虚拟线程必须用 ReentrantLock」已过时;是否用 Lock 取决于是否需要中断、超时、公平或多条件队列。
  • 不需要这些能力时,synchronized 更简单、更难写错,仍然是默认选择。

下一节处理「读多写少」:用读写锁把读操作并行化,再看 StampedLock 的乐观读如何用版本戳避免读锁开销。

阅读导航:上一节 9.1 自旋、阻塞与 park/unpark | 下一节 9.3 读写锁与 StampedLock

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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