synchronized
synchronized 通过对象的监视器(monitor)协调线程对共享数据的访问。监视器让同一时刻只有一个线程持有锁,并保证解锁前的写入对后续持锁线程可见。本文先确定锁对象,再解释同步字节码和可见性,随后介绍 HotSpot 优化与条件等待。
1. 面试核心
先记住这几条,再读正文- 锁对象。 实例同步方法锁定
this,静态同步方法锁定声明类的Class对象,同步块锁定括号中指定的对象。 - 重入。 同一线程可以重复获取同一个 monitor,只有退出次数与进入次数相同后才完全释放锁。
- 可见性。 线程解锁前的写入,对随后取得同一 monitor 的线程可见。卡 · 对象 monitor 与可重入
- 等待与重新获取。
wait()会释放目标 monitor 并进入等待,返回前重新获取它并恢复原持有次数。卡 · monitor 的等待与通知 - 版本基线。 Java 17/21 的 HotSpot 默认不使用偏向锁。
2. 对象 monitor 与可重入
理解互斥时,要先找出所有读写路径使用的锁对象。下面的同步块要求线程先取得 lock 对象的 monitor,再修改 state:
public void update() {
synchronized (lock) {
state = next(state);
}
}
一个线程持有这个 monitor 时,其他线程要等它释放,才能进入同一对象保护的同步区域。当前持有者可以再次获取同一 monitor,这称为可重入。每进入一次,持有次数增加一次;每退出一次,持有次数减少一次。只有次数降到零,其他线程才能取得锁。
锁对象还要保持一致。下面两段代码能否互斥,取决于两个引用是否指向同一个对象:
synchronized (left) { /* 修改 state */ }
synchronized (right) { /* 也修改 state */ }
若 left 和 right 指向不同对象,两段代码就使用不同的 monitor,能够同时修改 state。所以,需要互斥的访问路径应共享同一个稳定的锁对象。
3. 同步方法与同步代码块
synchronized 写在不同位置时,选择锁对象的方式也不同。需要区分以下三种写法:
- 实例同步方法锁定当前对象
this。 - 静态同步方法锁定声明类的 Class 对象。
- 同步块锁定括号表达式得到的对象。
同步块通过 monitorenter 获取锁,再通过 monitorexit 释放锁,编译后的异常路径也会执行释放。同步方法则由 Class 文件中的 ACC_SYNCHRONIZED 标志表示,JVM 在调用时获取锁。方法正常返回或异常退出时,JVM 都会释放 monitor。
因此,同一实例的两个同步方法会竞争同一个 monitor。不同实例的方法分别锁定各自对象,可以并行执行。实例同步方法与静态同步方法使用的锁对象也不同,分别保护它们的访问范围。
4. 可见性保证
锁既让线程互斥访问,也让后来的线程看到前面的修改。线程甲释放 monitor 后,线程乙随后取得同一 monitor,就能看到甲解锁前的写入。这是解锁与后续加锁之间的 happens-before 保证。
具体推导规则见 Java 内存模型。
读写双方都要遵守同一套同步规则,只在写入时加锁,普通读取仍然缺少上述保证。锁保护的代码称为临界区,它要覆盖必须一起完成的读写,才能保持业务状态一致。
临界区也要控制耗时。若在持锁期间调用远程服务,其他需要同一把锁的线程就要一起等待网络响应。
5. HotSpot 的锁优化
HotSpot 会根据竞争情况选择不同的获取方式,以减少线程等待和切换的开销。理解这些优化时,可以区分以下三类竞争情况:
- 没有竞争时,可以走快速获取路径,减少锁管理开销。
- 竞争时间很短时,可以自旋,也就是暂时循环尝试获取锁,避免立即挂起线程。
- 持续竞争等情况需要更完整的等待管理时,可以让锁膨胀为 monitor,并管理阻塞与唤醒。
具体锁表示和获取路径取决于 JDK 版本、虚拟机、启动参数与竞争情况。它们是实现优化,程序正确性仍依赖 monitor 的互斥和可见性规则。
Java 17/21 的 HotSpot 默认不使用偏向锁。Java 8 资料中的“偏向锁 → 轻量级锁 → 重量级锁”用于解释历史实现,不能当作每次加锁必经的流程。对象头位数等实现细节也应放在具体版本中讨论。
Java 21 中,虚拟线程在持有 monitor 时阻塞会固定其载体线程,这称为 pinning。具体影响见 虚拟线程。
6. 锁消除与锁粗化
除了改变获取路径,JIT 即时编译器还会优化实际执行的加锁次数。JIT 把运行中的热点代码编译成本地机器码时,可以分析哪些同步操作能够省去。
- 锁消除:编译器证明锁对象只由当前线程访问时,可以去掉相关加锁与解锁。因为其他线程接触不到这个对象,省去锁仍能保持原有行为。
- 锁粗化:编译器把相邻、重复获取同一把锁的区域合并,减少反复进入和退出的成本。
能否优化取决于编译器分析结果和实际执行路径,所以源码仍应表达正确的同步范围。为了期待优化而增加同步,或手动把慢 I/O 纳入临界区,都会增加不必要的工作或等待。
可以通过 JIT 编译日志检查优化结果,再结合 JFR 记录和基准测试观察竞争与耗时。JFR 是 JVM 的运行事件记录工具,能帮助判断等待发生在哪里。
7. monitor 的等待与通知
线程发现业务条件尚未满足时,需要释放锁并等待,让其他线程有机会改变状态。对象的 wait、notify 和 notifyAll 用来完成这种等待与通知,调用时必须持有目标 monitor。
条件检查也放在同一把锁内。比如,消费者等待队列非空时,可以这样写:
synchronized (lock) {
while (queue.isEmpty()) {
lock.wait();
}
return queue.removeFirst();
}
本片段所在方法需处理或声明 InterruptedException。队列为空时,wait() 将线程加入该对象的等待集合,并完全释放 lock。等待集合记录的是正在等待通知的线程。唤醒后,线程先重新获取 monitor,再恢复原持有次数,随后继续检查队列。
生产者应在同一把锁内更新队列,再发送通知。notify() 选择一个等待者,notifyAll() 通知全部等待者。通知者仍持有 monitor,直到完全退出同步区域,所以被通知的线程还要等待重新获取锁。
这里使用 while,是因为线程可以在没有有效通知时返回,这称为虚假唤醒。此外,其他消费者也可以在它取得锁之前取走数据。因此,醒来后要重新确认队列非空,再删除元素。
wait 只释放目标 monitor,线程持有的其他锁仍然保留。若生产者需要这些锁才能加入数据,双方就会互相等待。通知也不会保存给未来的等待者,所以应先持锁检查状态,条件未满足时再进入等待。
8. 常见问题
8.1 等待进入 monitor 时可以中断或设置超时吗?
等待进入 monitor 时,interrupt() 会记录中断请求,但获取过程仍继续等待。synchronized 也没有获取超时参数。这与已经持锁后调用 wait() 的中断响应不同。需要可中断获取、超时或多个条件队列时,可以使用 ReentrantLock。
8.2 为什么锁对象引用常声明为 final?
final 固定锁字段的引用,避免运行中换成另一个对象。这样,各条访问路径才能持续使用同一个 monitor。它保证的是引用稳定,monitor 的互斥规则仍由 synchronized 提供。
9 · 面试题卡片
9.1 synchronized 如何实现互斥与可见性
出现公司:蚂蚁集团
考察重点
- 解释 monitor 怎样让一个线程独占同步区域,并允许当前持有者重入。
- 区分同步块的字节码指令与同步方法的标志。
- 说明解锁前的写入为什么对随后取得同一把锁的线程可见。
相关内容:对象 monitor 与可重入至HotSpot 的锁优化。
参考回答
synchronized 通过 monitor 的独占获取实现互斥,并通过解锁到后续加锁的关系保证可见性。
线程先取得锁对象的 monitor,再进入同步区域,当前持有者可以重复获取。退出次数与进入次数相同后,其他线程才能取得锁,并看到此前持有者解锁前的写入。
同步块使用 monitorenter、monitorexit 指令,同步方法使用 ACC_SYNCHRONIZED 标志。正常返回和异常退出都会释放锁。HotSpot 会优化获取过程,但仍要保持这些语言语义。
9.2 wait 为什么要持锁,notify 后等待者何时继续?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释持锁检查怎样避免丢失通知,以及等待者为什么要在通知后重新获取锁并检查条件。
相关内容:monitor 的等待与通知。
参考回答
持锁能协调条件检查与进入等待,等待者收到通知后还要重新获取锁才能继续。
检查和等待都在同一 monitor 下完成,可以避免生产者在两者之间修改状态并发出通知。
wait 把线程加入等待集合并释放目标 monitor,给生产者修改状态的机会。notify 发出后,生产者仍持有锁,等待者要等它释放,再重新竞争。返回后还要用 while 检查条件,以处理虚假唤醒和其他线程抢先改变状态的情况。