在并发编程(六)中,我们纠正了 synchronized 的使用误区,了解了它从偏向锁到重量级锁的升级过程。但很多人依然会困惑:锁到底 “锁” 在了哪里? 是锁了代码,还是锁了对象?线程递归调用、方法嵌套时,JVM 又是如何精准识别临界区的?


一、锁的边界:字节码里的 monitorentermonitorexit

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) {
            // 操作同一个共享资源
        }
    }
}

在这个例子中,methodAmethodB 操作的是同一个共享资源,但它们使用了不同的锁对象 lock1lock2。这就导致线程 A 在执行 methodA 时,线程 B 依然可以进入 methodB,完全起不到互斥的作用,这就是 “分开加锁等于没加锁” 的本质原因 ——锁对象不一致

正确的做法是,让所有操作同一共享资源的方法,都使用同一个锁对象。


三、锁的物理载体:Java 对象的内存布局

既然锁是对象锁,那锁的信息到底存在对象的哪里?答案是:对象头(Object Header)

在 HotSpot 虚拟机中,一个对象在内存中的布局分为三部分:

  1. 对象头(Object Header):存储对象的运行时元数据,包括哈希码、GC 分代年龄、锁状态等。这是我们下一篇文章要深入剖析的重点。
  2. 实例数据(Instance Data):存储对象的成员变量信息。
  3. 对齐填充(Padding):为了让对象大小是 8 字节的整数倍,进行的字节填充,保证内存对齐。

synchronized 锁的所有状态信息,都记录在对象头的 Mark Word 字段中。当锁升级时,Mark Word 的二进制布局会发生变化,这正是 JVM 实现锁优化的物理基础。


这一篇我们从宏观视角,讲一讲 synchronized 锁的边界、本质和物理载体。下一篇《并发编程(十)》,我们将深入对象头的二进制细节,拆解 Mark Word 的结构,直观地看到锁从偏向锁到重量级锁的升级过程。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐