Java 基础体系 · 第 63/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java synchronized 与 Monitor:锁升级、等待通知、可见性和死锁
synchronized 是 Java 语言内置的互斥与同步机制。它表面上是一个关键字,底层语义却涉及几个不同层次:
- 互斥:同一时刻,只有一个线程可以持有同一个 Monitor。
- 可见性:一个线程释放 Monitor 前对共享数据的修改,对之后获得同一 Monitor 的线程可见。
- 等待与通知:线程可以在某个对象的 Monitor 上等待条件成立,也可以通知等待线程重新检查条件。
- 死锁风险:多个线程以不同顺序获取多个 Monitor 时,可能永久循环等待。
- 实现优化:JVM 会对无竞争和低竞争场景进行快速路径优化;所谓“锁升级”主要是实现术语,不是 Java 语言规范承诺的固定状态机。
理解这些机制时,必须区分三类事实:
- JLS 规定的行为:例如进入和退出
synchronized的语义、wait的限制、内存模型中的 happens-before。 - JVM 常见实现:例如 HotSpot 中的轻量级路径、重量级 Monitor、锁膨胀。
- 工程经验:例如统一加锁顺序、使用
notifyAll、缩小临界区。
一、Monitor 是什么
1. 每个对象都可以作为锁
在 Java 语言层面,每个对象都可以关联一个 Monitor。下面的代码:
synchronized (lock) {
criticalSection();
}
表示线程必须先获得 lock 对象关联的 Monitor,才能执行代码块;离开代码块时,无论是正常返回还是抛出异常,都会自动释放该 Monitor。
这里的 lock 是一个对象引用,而不是变量名本身。例如:
Object a = new Object();
Object b = a;
synchronized (a) {
// 与 synchronized (b) 使用同一个 Monitor
}
因为 a 和 b 引用同一个对象,所以它们保护的是同一组互斥区域。
反过来,以下两个对象即使内容相同,也不是同一把锁:
new String("lock") != new String("lock")
锁的身份取决于对象身份,而不是 equals 返回值。
2. Monitor 的概念状态
可以把一个 Monitor 抽象成以下几个部分:
- Owner:当前持有 Monitor 的线程。
- Entry Set:尝试进入
synchronized、但暂时没有获得锁的线程。 - Wait Set:调用了该对象
wait方法、主动释放锁并等待条件变化的线程。
Entry Set 和 Wait Set 的区别非常重要:
- 线程在
Entry Set中,是因为它想获得锁但锁被别人持有。 - 线程在
Wait Set中,是因为它已经持有过锁,然后调用wait主动释放锁等待通知。
可以用如下状态图表示:
stateDiagram-v2
[*] --> Running
Running --> EntrySet: synchronized 进入失败
EntrySet --> Running: 获得 Monitor
Running --> WaitSet: wait()
WaitSet --> EntrySet: notify/notifyAll 或超时/中断
EntrySet --> Running: 重新获得 Monitor
Running --> [*]: 退出 synchronized
notify 不会直接把等待线程变成“正在执行”。它只会使某个等待线程具备重新竞争 Monitor 的资格;该线程仍然必须重新获得锁,才能从 wait 返回。
二、synchronized 的三种写法
1. 同步代码块
private final Object lock = new Object();
private int count;
public void increment() {
synchronized (lock) {
count++;
}
}
这里的锁对象是 lock。把锁对象声明为 private final 有两个直接作用:
- 外部代码无法拿到它,降低意外竞争或误用的可能。
- 引用不会在运行过程中改变,避免不同时间使用不同对象作为锁。
不应使用可被外部获得的对象作为内部锁,例如:
public synchronized void update() {
// 锁是 this,外部代码可以 synchronized (对象实例) 竞争这把锁
}
这并非一定错误,但锁的所有权不再完全由类自身控制。
2. 实例同步方法
public synchronized void increment() {
count++;
}
实例同步方法等价于:
public void increment() {
synchronized (this) {
count++;
}
}
因此,两个实例使用不同的 Monitor:
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.increment() 和 c2.increment() 不会因为都是同步实例方法而互相阻塞。
3. 静态同步方法
public static synchronized void reload() {
// 锁住 Counter.class
}
静态同步方法等价于:
public static void reload() {
synchronized (Counter.class) {
// ...
}
}
实例同步方法锁住实例对象,静态同步方法锁住对应的 Class 对象。两者通常不是同一把锁:
class Counter {
public synchronized void instanceMethod() {
// 锁 this
}
public static synchronized void staticMethod() {
// 锁 Counter.class
}
}
同一个线程可以同时进入这两个方法,因为它们默认使用不同的 Monitor。
三、可重入:同一个线程可以重复获得同一把锁
synchronized 使用的是可重入锁。如果线程已经持有某个 Monitor,它可以再次进入同一个 Monitor,而不会把自己阻塞。
public class ReentrantExample {
public synchronized void outer() {
inner();
}
public synchronized void inner() {
System.out.println("inner");
}
}
调用 outer() 时,线程先获得 this 的 Monitor;随后调用 inner(),再次请求同一个 Monitor。由于请求线程就是当前 Owner,所以可以继续执行。
可以把 Monitor 记录为一个递归计数:
| 操作 | Owner | 持有次数 |
|---|---|---|
进入 outer |
T1 | 1 |
进入 inner |
T1 | 2 |
离开 inner |
T1 | 1 |
离开 outer |
T1 | 0 |
只有持有次数从 1 降到 0 时,其他线程才有机会获得该 Monitor。
这也是 wait 语义中容易被忽略的部分:如果线程以递归方式持有同一个 Monitor 多次,调用 wait 时会释放全部持有层级;从 wait 返回前,必须重新恢复原来的持有层级。
四、锁升级:规范没有规定固定的升级链
1. “锁升级”不是 Java 语言语义
在 JVM 讨论中,经常可以看到如下描述:
无竞争快速路径
-> 轻量级锁
-> 重量级锁 / Monitor 膨胀
这个描述有助于理解性能,但不能当作 Java 语言规范中的固定状态转换。JLS 规定的是:
- 进入同步区域时必须获得对应 Monitor;
- 未获得 Monitor 的线程不能执行同步区域;
- 退出同步区域时必须释放 Monitor;
- 同一线程可以重入;
- 相关同步动作满足内存模型规则。
JLS 并不规定对象头必须采用哪种布局,也不规定 JVM 必须经历哪些“锁状态”。
因此,下面这些说法都不严谨:
- “每把锁一定会从偏向锁升级到轻量级锁再升级到重量级锁。”
- “调用一次
hashCode()就一定会导致锁膨胀。” - “看到某个锁状态就能推导 Java 代码的全部线程行为。”
2. 常见 HotSpot 实现中的优化思路
在常见的 HotSpot 实现中,JVM 会尽量让无竞争同步走快速路径,减少操作系统级阻塞和唤醒的成本。实现可能使用对象头中的元数据、线程栈上的锁记录以及 ObjectMonitor 等结构。
可以用一个抽象过程理解:
情况一:无竞争
线程 T1 执行:
synchronized (lock) {
value++;
}
如果没有其他线程同时竞争,JVM 可以通过快速路径确认 T1 获得了锁,进入临界区;退出时再快速释放。
情况二:短暂竞争
T1 持有锁,T2 很快到达:
T1: 获得 lock ───── 执行临界区 ───── 释放 lock
T2: 尝试获得 ── 短暂等待 ── 获得 lock
JVM 可能先采用自旋或其他轻量路径,避免 T2 立即进入阻塞状态。自旋是否发生、持续多久以及具体策略,属于实现细节。
情况三:持续竞争或需要等待
如果临界区执行时间较长,或者大量线程同时竞争,JVM 可能将锁膨胀为更完整的 Monitor 结构,把无法立即获得锁的线程放入竞争队列,并在必要时阻塞和唤醒线程。
T1 持有 Monitor
T2、T3 尝试进入
T2、T3 无法立即获得
T2、T3 进入等待竞争结构
T1 退出
T2 或 T3 获得 Monitor
这通常就是工程语境中的“轻量级锁升级为重量级锁”。
3. 偏向锁的版本边界
早期 HotSpot 曾使用偏向锁,优化“同一个线程反复进入、几乎没有竞争”的场景。它在 JDK 15 中默认禁用,后续 HotSpot 版本已移除相关机制。因此,在 Java 25 LTS 的讨论中,不应把“偏向锁 → 轻量级锁 → 重量级锁”当作当前 JVM 必经流程。
更准确的说法是:
Java 25 程序依赖的是 Monitor 语义;HotSpot 可以在此语义之下选择不同的快速路径和膨胀策略。
锁升级本身也不是可靠的业务信号。业务代码不能根据“当前是轻量级还是重量级”决定正确性;性能分析应通过基准测试、JFR、线程转储和 JVM 文档验证,而不是依赖对象头的非规范布局。
五、等待与通知:wait、notify、notifyAll
1. wait 的前提
wait 是 Object 的方法,不是 Thread 的方法:
lock.wait();
lock.notify();
lock.notifyAll();
调用 lock.wait() 的线程必须当前持有 lock 的 Monitor,否则抛出:
java.lang.IllegalMonitorStateException
正确用法是:
synchronized (lock) {
lock.wait();
}
错误用法是:
lock.wait(); // 没有持有 lock 的 Monitor
2. wait 做了什么
假设 T1 执行:
synchronized (lock) {
while (!condition) {
lock.wait();
}
consume();
}
调用 wait 后,发生以下步骤:
- T1 必须已经持有
lock的 Monitor。 - T1 将自己放入
lock的 Wait Set。 - T1 释放
lock的 Monitor。 - T1 进入等待状态。
- 其他线程可以获得
lock并修改条件。 - T1 因通知、超时或中断等原因被唤醒。
- T1 不会立即从
wait返回,而是重新竞争lock。 - T1 重新获得
lock后,wait才返回。 - 代码继续执行,并且仍处于
synchronized区域内。
这一点解释了为什么等待期间不会一直占用锁:如果 wait 不释放锁,其他线程就无法进入临界区修改条件,等待也就无法结束。
3. notify 只通知一个等待线程
synchronized (lock) {
condition = true;
lock.notify();
}
notify 会从该对象的 Wait Set 中选择一个线程使其重新参与竞争。选择哪个线程不是业务代码可以依赖的公平顺序。
如果有多个不同条件共享同一个 Monitor,notify 可能唤醒了一个当前条件仍不满足的线程。该线程会重新检查条件并继续等待,而真正需要被唤醒的线程可能仍然睡眠。
因此,多个条件共享同一个锁时,通常需要:
lock.notifyAll();
notifyAll 会使所有等待线程重新参与竞争;它们仍然需要逐个获得 Monitor,并通过条件判断决定自己是否继续执行。
4. 为什么必须使用 while,不能使用 if
错误写法:
synchronized (lock) {
if (!condition) {
lock.wait();
}
useResource();
}
正确写法:
synchronized (lock) {
while (!condition) {
lock.wait();
}
useResource();
}
while 必须存在,原因至少有三个:
wait允许因中断、超时或虚假唤醒返回。notifyAll可能唤醒多个线程,但资源或条件只满足其中一部分。- 线程从等待到重新获得锁之间,其他线程可能已经改变了共享状态。
完整因果链如下:
T1 检查 condition == false
T1 wait,释放锁
T2 获得锁,将 condition 改为 true
T2 notifyAll,释放锁
T3 先于 T1 获得锁,消费资源,使 condition 又变为 false
T3 释放锁
T1 获得锁并从 wait 返回
T1 必须再次检查 condition
如果 T1 使用 if,它会直接执行 useResource(),这可能违反前置条件。使用 while,T1 会重新等待。
5. wait 的超时和中断
synchronized (lock) {
long deadline = System.nanoTime() + timeoutNanos;
while (!condition) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
throw new TimeoutException("condition not satisfied");
}
TimeUnit.NANOSECONDS.timedWait(lock, remaining);
}
}
等待可能因为以下原因结束:
- 其他线程调用
notify或notifyAll; - 等待超时;
- 当前线程被中断;
- 允许的虚假唤醒。
如果等待线程被中断,wait 会抛出 InterruptedException。代码应根据业务语义处理它。常见的线程取消传播方式是恢复中断标志并返回:
try {
synchronized (lock) {
while (!condition) {
lock.wait();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
恢复中断标志很重要,因为捕获 InterruptedException 后,中断状态通常已经被清除;如果直接吞掉异常,线程池或上层取消逻辑可能无法感知中断。
六、一个可运行的生产者—消费者示例
下面的程序使用一个有限容量缓冲区,演示:
synchronized保护共享状态;while检查条件;wait在条件不满足时释放锁;notifyAll通知状态可能已经变化;- 中断通过
InterruptedException传播到线程入口。
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
public class MonitorBufferDemo {
static final class BoundedBuffer<T> {
private final int capacity;
private final Queue<T> queue = new ArrayDeque<>();
BoundedBuffer(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive");
}
this.capacity = capacity;
}
public synchronized void put(T value) throws InterruptedException {
if (value == null) {
throw new NullPointerException("value");
}
while (queue.size() == capacity) {
wait();
}
queue.add(value);
notifyAll();
}
public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
wait();
}
T value = queue.remove();
notifyAll();
return value;
}
}
public static void main(String[] args) throws InterruptedException {
BoundedBuffer<Integer> buffer = new BoundedBuffer<>(2);
Thread producer = Thread.ofPlatform().start(() -> {
try {
for (int i = 1; i <= 5; i++) {
buffer.put(i);
System.out.println("produced " + i);
TimeUnit.MILLISECONDS.sleep(20);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = Thread.ofPlatform().start(() -> {
try {
for (int i = 1; i <= 5; i++) {
int value = buffer.take();
System.out.println("consumed " + value);
TimeUnit.MILLISECONDS.sleep(50);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.join();
consumer.join();
}
}
在 Java 25 中可以使用以下命令编译运行:
javac MonitorBufferDemo.java
java MonitorBufferDemo
输出顺序不固定,但应满足两个性质:
每个 produced 数字最终对应一个 consumed 数字
缓冲区大小始终不超过 2
为什么 put 和 take 都使用 notifyAll?
put后,缓冲区可能从“非空”变成“非空且多一个元素”,等待消费者可能可以继续。take后,缓冲区可能从“满”变成“未满”,等待生产者可能可以继续。- 生产者和消费者共享同一个 Wait Set,单独使用
notify时,被唤醒的线程不一定是当前真正可运行的那类线程。
这个实现的代价是:每次状态变化都可能唤醒多个暂时无法继续的线程,造成竞争和上下文切换。它的优点是语义直接;在生产代码中,也可以使用 BlockingQueue,让专门的并发容器承担这些细节。
七、可见性:为什么解锁后数据能被其他线程看到
1. 可见性不是“变量立刻写回内存”
现代 CPU 和 JVM 会使用寄存器、缓存、指令重排等优化。没有同步时,一个线程写入的值不保证另一个线程何时能观察到。
例如:
class Task {
private boolean ready;
private int value;
void publish() {
value = 42;
ready = true;
}
void consume() {
if (ready) {
System.out.println(value);
}
}
}
如果 publish 和 consume 没有共同的同步关系,不能仅凭源码顺序断言 consume 看到 ready == true 时一定看到 value == 42。
2. Monitor 的 happens-before 规则
Java 内存模型定义了 happens-before 关系。对同一个 Monitor M:
线程 T1:
synchronized (M) {
x = 42;
} // 释放 M
线程 T2:
synchronized (M) {
read(x);
} // 获得 M
若 T1 的解锁发生在 T2 的加锁之前,则:
T1 在解锁前的所有动作
happens-before
T2 随后加锁成功后的所有动作
可简写为:
unlock(M) happens-before lock(M)
因此下面的共享状态可以被正确发布:
class PublishedState {
private final Object lock = new Object();
private int value;
private boolean ready;
public void publish() {
synchronized (lock) {
value = 42;
ready = true;
}
}
public int read() {
synchronized (lock) {
if (!ready) {
throw new IllegalStateException("not ready");
}
return value;
}
}
}
推导步骤是:
value = 42先于ready = true,因为同一线程内程序顺序构成 happens-before。ready = true以及之前的写入先于释放lock。- 释放
lock先于另一个线程获得lock。 - 另一个线程获得
lock后读取ready和value。 - 因此读取线程可以观察到发布线程在解锁前的状态。
3. 只有一边加锁不够
下面的代码不能得到同样的保证:
class BrokenState {
private final Object lock = new Object();
private int value;
private boolean ready;
public void publish() {
value = 42;
ready = true;
}
public int read() {
synchronized (lock) {
if (ready) {
return value;
}
return -1;
}
}
}
写线程没有释放同一个 Monitor,读线程的加锁就没有与写线程建立所需的同步边。synchronized 不是“读线程加锁后自动刷新所有线程数据”的全局刷新指令;它必须形成匹配的同步关系。
4. volatile 与 synchronized 的区别
如果状态只有一个简单的发布标志,可以考虑:
private volatile boolean ready;
private int value;
void publish() {
value = 42;
ready = true;
}
int read() {
if (ready) {
return value;
}
return -1;
}
对 volatile 变量的写入与后续读取建立可见性顺序,且普通写入 value = 42 位于 volatile 写之前,因此读取到 ready == true 时可以看到之前的 value 写入。
但 volatile 不提供复合操作的互斥。例如:
volatile int count;
count++; // 读取、加一、写回,不是原子操作
两个线程可能同时读取相同旧值并覆盖彼此结果。synchronized 同时提供:
- 互斥;
- 可见性;
- 复合操作的原子执行。
八、等待通知与可见性的关系
notify 的核心作用是改变等待线程的调度资格,不是直接把数据“推送”给线程。
典型流程是:
synchronized (lock) {
state = READY;
lock.notifyAll();
}
等待线程:
synchronized (lock) {
while (state != READY) {
lock.wait();
}
useState();
}
其中真正保证 state = READY 可见的关键路径是:
通知线程写入 state
-> 通知线程退出 synchronized,释放 lock
-> 等待线程重新获得 lock
-> 等待线程读取 state
notifyAll 使等待线程有机会重新竞争,但线程只有在重新获得同一个 Monitor 后才能从 wait 返回。因此,通知本身不能脱离 Monitor 使用,也不能替代条件检查。
以下代码仍然是错误的:
synchronized (lock) {
state = READY;
lock.notifyAll();
}
if (state == READY) {
useState();
}
通知后离开同步区域,随后读取 state 没有继续处于同步保护内。除非 state 本身通过 volatile、原子变量或其他同步机制发布,否则不能仅凭之前的 notifyAll 保证这次读取安全。
九、死锁:Monitor 之间形成等待环
1. 死锁的形式化条件
死锁是指一组线程永久等待,任何线程都无法继续。经典的必要条件包括:
- 互斥:资源同一时刻只能被一个线程持有。
- 占有且等待:线程持有一个资源,同时等待另一个资源。
- 不可剥夺:资源不能被外部强制夺走,只能由持有线程释放。
- 循环等待:线程之间形成闭环等待关系。
对 Monitor 来说,最容易形成死锁的就是循环等待:
T1 持有 A,等待 B
T2 持有 B,等待 A
2. 可运行的死锁示例
public class DeadlockDemo {
private static final Object A = new Object();
private static final Object B = new Object();
public static void main(String[] args) throws Exception {
Thread t1 = Thread.ofPlatform().name("lock-A-then-B").start(() -> {
synchronized (A) {
sleepQuietly(100);
synchronized (B) {
System.out.println("t1 acquired both");
}
}
});
Thread t2 = Thread.ofPlatform().name("lock-B-then-A").start(() -> {
synchronized (B) {
sleepQuietly(100);
synchronized (A) {
System.out.println("t2 acquired both");
}
}
});
t1.join();
t2.join();
}
private static void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
典型状态:
T1: 持有 A,等待 B
T2: 持有 B,等待 A
两个线程都不会进入最内层打印语句,也不会释放自己已经持有的锁。join 使主线程继续等待,因此程序不会自然退出。
Thread.sleep 不会释放 Monitor。示例中 T1 在持有 A 时睡眠,T2 在持有 B 时睡眠,这只是扩大了形成死锁的时间窗口;即使没有 sleep,死锁仍然可能发生。
3. 用锁顺序破坏循环等待
如果所有代码都按相同顺序获取锁:
先 A,后 B
则不可能出现:
T1: A -> B
T2: B -> A
例如:
static void transfer(Account from, Account to, int amount) {
Account first = from.id() < to.id() ? from : to;
Account second = from.id() < to.id() ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
这里通过稳定的账户 ID 建立全局顺序。只要所有涉及两个账户的操作都遵循这个顺序,就破坏了循环等待条件。
这不是说“嵌套锁永远错误”,而是说嵌套锁必须有可证明的顺序约束。如果锁的顺序取决于调用路径、参数顺序或运行时分支,就需要特别审查。
十、死锁诊断与误判
1. 使用线程转储
程序卡住时,可以先获取 Java 进程 PID:
jcmd
然后生成线程转储:
jcmd <pid> Thread.print
也可以使用:
jstack <pid>
具体输出会因 JDK 和运行模式不同而变化,但死锁通常表现为:
- 线程状态为
BLOCKED; - 输出包含“waiting to lock”或“locked”;
- 不同线程分别持有对方需要的锁;
- HotSpot 可能直接报告检测到的 Java-level deadlock。
示意关系:
"lock-A-then-B":
waiting to lock <B>
which is held by "lock-B-then-A"
"lock-B-then-A":
waiting to lock <A>
which is held by "lock-A-then-B"
2. BLOCKED、WAITING 和 TIMED_WAITING 不同
BLOCKED:线程正在等待进入synchronized,目标 Monitor 被其他线程持有。WAITING:线程可能正在执行无超时的wait、join或其他等待操作。TIMED_WAITING:线程可能正在执行带超时的wait、sleep或join。
看到线程处于等待状态不等于发生死锁。生产者在空缓冲区上等待、消费者在满缓冲区上等待,可能只是正常的条件等待。判断死锁需要找出持有关系和循环等待关系。
3. 为什么线程转储可能看不到“真正原因”
线程转储是某个时刻的快照,以下情况会造成误判:
- 锁竞争只是暂时发生,采样时已经消失;
- 线程在执行外部调用或 I/O,表现为持锁时间很长;
- 业务线程等待线程池、Future 或数据库连接,而不是直接等待 Monitor;
- 多个线程都在等待同一个条件,但没有形成锁的循环;
- 死锁发生在 Java Monitor 之外,例如数据库锁或本地代码锁。
因此应结合多次线程转储、JFR、应用日志和资源监控分析,而不能把所有“程序没有输出”都归因于 synchronized 死锁。
十一、常见错误及其因果
1. 锁住了会变化的引用
错误示例:
private Object lock = new Object();
void method() {
synchronized (lock) {
// ...
}
}
如果另一个线程重新赋值:
lock = new Object();
不同线程可能在不同对象上加锁,互斥关系立即失效。锁对象通常应声明为:
private final Object lock = new Object();
2. 对字符串常量或类对象加内部锁
synchronized ("LOCK") {
// ...
}
字符串常量可能被字符串池共享,其他无关代码也可能使用同一个对象。SomeClass.class 也可能被多个模块或库作为同步对象。
内部互斥通常应使用私有锁对象:
private final Object lock = new Object();
3. 在持锁期间执行慢操作
synchronized (lock) {
socket.read(); // 可能长时间阻塞
databaseCall(); // 可能等待连接或网络
callback.invoke(); // 可能重入或反向请求当前对象
}
持锁期间的阻塞会使其他线程进入 BLOCKED,并且可能扩大死锁范围。尤其是回调代码,调用方并不知道回调会获取哪些锁,容易形成反向锁顺序。
可以先在锁内复制必要状态,随后在锁外执行慢操作:
Callback callback;
Data snapshot;
synchronized (lock) {
snapshot = buildSnapshot();
callback = this.callback;
}
callback.onChanged(snapshot);
这不是无条件适用:如果回调必须与状态修改保持原子性,就不能简单移到锁外;此时应明确设计并发协议。
4. 把 sleep 当成等待条件
错误写法:
while (!ready) {
Thread.sleep(100);
}
它既没有通过 Monitor 保护共享状态,也没有明确建立可见性关系。即使把 ready 声明为 volatile,轮询仍可能造成延迟和无效唤醒。
条件等待应表达为:
synchronized (lock) {
while (!ready) {
lock.wait();
}
}
或者使用更高层的并发工具,例如 CountDownLatch、BlockingQueue、Condition 或 CompletableFuture,具体取决于条件模型。
5. 误以为 synchronized 可以被中断获取
线程在等待进入 synchronized 时,没有 Lock.lockInterruptibly() 这样的语言级接口可供调用方取消等待。线程可以在 Monitor 内执行 wait,但“等待获得 Monitor”本身不能像 ReentrantLock.lockInterruptibly() 那样直接响应中断并退出。
如果业务需要:
- 可中断地获取锁;
- 尝试获取锁并设置超时;
- 多个条件队列;
- 公平策略;
则 java.util.concurrent.locks.Lock 可能比 synchronized 更合适。但这并不意味着 Lock 自动更安全;它需要显式 unlock,通常必须使用 try/finally:
lock.lock();
try {
criticalSection();
} finally {
lock.unlock();
}
十二、Monitor 与 Java 25 的边界
1. Java 25 保证的是语义,不是对象头布局
Java 25 LTS 的应用代码可以依赖:
synchronized的互斥语义;- 同一线程的可重入语义;
wait必须由 Monitor Owner 调用;wait释放并重新获得 Monitor;notify和notifyAll的等待集语义;- Monitor 解锁与后续加锁之间的内存模型关系。
应用代码不应依赖:
- 对象头具体位布局;
- 某个锁一定何时膨胀;
- 自旋次数;
- Monitor 竞争队列的公平顺序;
- 某种内部锁状态与固定性能收益之间的一一对应关系。
2. Monitor 不是“自动保护所有相关字段”
synchronized 只保护使用同一 Monitor 的代码路径。下面的代码使用了两把不同的锁:
synchronized (lockA) {
value++;
}
synchronized (lockB) {
System.out.println(value);
}
这两个区域之间没有互斥关系,也不能仅凭它们都写了 synchronized 就推断可见性。同步协议必须回答一个具体问题:
哪些线程访问哪些共享状态,并且这些访问是否通过同一个同步边建立顺序?
3. Monitor 不能替代状态设计
如果一个条件由多个字段共同决定,例如:
size > 0 && !closed
那么检查这些字段和根据检查结果采取行动,必须处于同一个同步协议中。只同步单个字段的读取,可能导致线程看到不一致的状态。
条件等待的核心不是“等一下再试”,而是:
在锁保护下检查条件
条件不满足:原子地释放锁并等待
被唤醒后重新获得锁
重新检查条件
条件满足后执行操作
十三、如何选择 synchronized
synchronized 适合以下场景:
- 临界区边界清晰;
- 只需要互斥和基本的条件等待;
- 锁的生命周期与对象生命周期一致;
- 不需要尝试加锁或可中断加锁;
- 希望由语言结构自动释放锁,减少忘记解锁的错误。
如果代码需要多个独立条件队列、超时获取、可中断获取或复杂的锁组合,可以考虑 Lock 和 Condition。如果问题本质是队列、计数、一次性发布或阶段协调,优先考虑 BlockingQueue、Atomic*、CountDownLatch、Semaphore 等专门抽象。
无论使用哪种工具,正确性都依赖同一套推理:
- 明确共享状态。
- 明确保护状态的同步边界。
- 证明复合操作的原子性。
- 证明写入如何对读取线程可见。
- 对每个等待条件建立可重新检查的谓词。
- 对多个锁建立全局获取顺序,或证明不会形成循环等待。
synchronized 的性能表现可以随着 JVM 实现、硬件、竞争程度和临界区长度变化,但它的核心语义不会因为锁走了快速路径还是发生了 Monitor 膨胀而改变。真正需要掌握的不是某个非规范的锁状态名称,而是 Monitor 所建立的互斥、等待、通知、可见性和等待关系。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 线程生命周期:创建、状态、Interrupt、守护线程和协作退出
- 下一篇:Java Lock、ReadWriteLock、StampedLock 与 Condition:语义和陷阱
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论