并发编程(九):synchronized 锁的底层边界与对象锁本质
在并发编程(六)中,我们纠正了 synchronized 的使用误区,了解了它从偏向锁到重量级锁的升级过程。但很多人依然会困惑:锁到底 “锁” 在了哪里? 是锁了代码,还是锁了对象?线程递归调用、方法嵌套时,JVM 又是如何精准识别临界区的?
一、锁的边界:字节码里的 monitorenter 和 monitorexit
1. 为什么需要锁标记?
我们看代码时,能清晰地看到 synchronized 包裹的代码块范围,但对于 JVM 来说,它只是一串连续的字节码指令。当线程执行一个加锁方法时,可能会调用其他方法,甚至递归调用自身,这使得锁的边界变得模糊。
为了让 JVM 明确知道 “哪段代码需要互斥访问”,javac 编译器在编译后,会在字节码层面插入两条关键指令:
monitorenter:插入在同步代码块的开始位置,代表锁的上边界。monitorexit:插入在同步代码块的正常结束处和异常处,代表锁的下边界。
JVM 严格保证:每一个 monitorenter 指令,都必须有一个对应的 monitorexit 指令与之配对,确保锁在任何情况下都能被正确释放。
2. 字节码反编译
我们来看一段简单的代码:
public class SyncDemo {
public void syncBlock() {
synchronized (this) {
System.out.println("Hello");
}
}
}
使用 javap -c SyncDemo.class 反编译后,我们可以看到 syncBlock 方法的字节码:
public void syncBlock();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter // 锁的上边界
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String Hello
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit // 锁的下边界(正常结束)
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // 锁的下边界(异常结束)
20: aload_2
21: athrow
22: return
可以看到:
- 第 3 行是
monitorenter,标志着临界区的开始。 - 第 13 行和第 19 行是两个
monitorexit,分别对应正常执行结束和异常抛出时的锁释放,确保万无一失。
3. Monitor 竞争流程图
当线程执行到 monitorenter 指令时,它会尝试获取与对象关联的 Monitor(监视器 / 管程)的所有权。如果 Monitor 已被其他线程持有,当前线程就会进入阻塞状态,直到 Monitor 被释放。
整个竞争流程可以用下图表示:
Monitor:每个 Java 对象都关联一个 Monitor,它是实现锁互斥和线程协作的核心。
竞争:当锁被占用时,其他线程进入阻塞队列(Entry Set)等待。
唤醒:锁被释放后,JVM 会从等待队列中唤醒一个线程,让它重新尝试获取锁。
二、锁的本质:对象锁,而非代码锁
1. 三种锁形式,一个核心本质
在 Java 中,synchronized 有三种常见的使用形式,但它们的本质都是对象锁:
| 使用形式 | 锁对象 | 核心说明 |
|---|---|---|
| 普通同步方法 | this(当前实例对象) |
锁的是调用该方法的对象实例。 |
| 静态同步方法 | 当前类的 Class 对象 |
锁的是整个类,所有实例共享这一把锁。 |
| 同步代码块 | synchronized(obj) 中的 obj |
可以是任意对象,粒度最灵活。 |
这就是 “锁住一个就锁住全部” 的含义:只要多个线程竞争的是同一个对象锁,它们对所有使用该锁的同步代码的访问,都是互斥的。
2. 误区纠正:“分开加锁等于没加锁”
很多人会犯这样的错误(简单示例)
// 错误示例:两个方法使用了不同的锁
public class BadSync {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void methodA() {
synchronized (lock1) {
// 操作共享资源
}
}
public void methodB() {
synchronized (lock2) {
// 操作同一个共享资源
}
}
}
在这个例子中,methodA 和 methodB 操作的是同一个共享资源,但它们使用了不同的锁对象 lock1 和 lock2。这就导致线程 A 在执行 methodA 时,线程 B 依然可以进入 methodB,完全起不到互斥的作用,这就是 “分开加锁等于没加锁” 的本质原因 ——锁对象不一致。
正确的做法是,让所有操作同一共享资源的方法,都使用同一个锁对象。
三、锁的物理载体:Java 对象的内存布局
既然锁是对象锁,那锁的信息到底存在对象的哪里?答案是:对象头(Object Header)。
在 HotSpot 虚拟机中,一个对象在内存中的布局分为三部分:
- 对象头(Object Header):存储对象的运行时元数据,包括哈希码、GC 分代年龄、锁状态等。这是我们下一篇文章要深入剖析的重点。
- 实例数据(Instance Data):存储对象的成员变量信息。
- 对齐填充(Padding):为了让对象大小是 8 字节的整数倍,进行的字节填充,保证内存对齐。
synchronized 锁的所有状态信息,都记录在对象头的 Mark Word 字段中。当锁升级时,Mark Word 的二进制布局会发生变化,这正是 JVM 实现锁优化的物理基础。
这一篇我们从宏观视角,讲一讲 synchronized 锁的边界、本质和物理载体。下一篇《并发编程(十)》,我们将深入对象头的二进制细节,拆解 Mark Word 的结构,直观地看到锁从偏向锁到重量级锁的升级过程。
更多推荐



所有评论(0)