monitor 和 对象头
2025-01-22 08:19:30    2.7k 字   
This post is also available in English and alternative languages.

Synchronized 中的重量级锁使用了 monitor,了解 monitor 和 对象头,是了解 Synchronized 的关键。


1. 对象头

JVM 虚拟机使用一种叫做「普通对象指针」(ordinary object pointer,OOP)的数据结构来描述 Java 对象实例信息。OOP 一般由对象头(Object Header)实例数据(Instance Data)对齐填充(Padding) 三部分组成。

以32位虚拟机为例,普通对象的 Object Header 占8个字节共64位。实例数据(Instance Data) 存储的是对象的实际数据,因此大小与实际数据大小一致。对齐填充(Padding) 是可选项,用于将内存对齐为8字节的整数倍。

  • 普通对象的 Object Header

    以32位虚拟机为例,普通对象的 Object Header 占用 8 字节。其中4个字节是 Mark Word。另外4个字节是 Klass Word,用于指向对象的类型。

    普通对象 Object Header(图片来源:https://www.bilibili.com/video/BV16J411h7Rd?p=75) 普通对象的内存组成(图片来源:https://bbs.huaweicloud.com/blogs/314346)

  • 数组对象的 Object Header

    以32位虚拟机为例,数组对象的 Object Header 占用 12 字节,多出的4字节用来表示数组的长度。

    数组对象 Object Header(图片来源:https://www.bilibili.com/video/BV16J411h7Rd?p=75) 数组对象的内存组成(图片来源:https://bbs.huaweicloud.com/blogs/314346)

2. Mark Word

Mark WordObject Header 中的一部分,以32位虚拟机为例,Mark Word 占用4个字节(32位)。值得注意的是,Java 对象在不同的状态下,Mark Word 存储的值完全不同

不同状态的Mark Word(图片来源:https://www.bilibili.com/video/BV16J411h7Rd?p=75)
  • 初始状态 Mark Word 存储结构如下(对应上图中的第二行)

    对象的hashCode (25 bits)分代年龄 age (4 bits)是否是偏向锁 biased_lock (1 bit)锁标志位(2 bits)对象状态(State)
    254001(未与任何锁关联)Normal(未加锁)
  • 偏向锁状态 Mark Word 存储结构如下(对应上图中的第三行)

    线程ID threadepoch分代年龄 age (4 bits)是否是偏向锁 biased_lock (1 bit)锁标志位(2 bits)对象状态(State)
    2524101(未与任何锁关联)Biased
  • 轻量级锁状态 Mark Word 存储结构如下(对应上图中的第四行)

    栈帧中锁记录的地址 ptr_to_lock_record (30 bits)锁标志位(2 bits)对象状态(State)
    3000(轻量级锁)Lightweight Locked(轻量级锁)
  • 重量级锁状态 Mark Word 存储结构如下(对应上图中的第五行)

    ptr_to_heavyweight_monitor:就是指向 monitor 的指针。

    栈帧中锁记录的地址 ptr_to_heavyweight_monitor (30 bits)锁标志位(2 bits)对象状态(State)
    3010Heavyweight Locked

3. monitor

monitor 是操作系统提出来的一种高级原语,在操作系统层面称为「管程」,但其具体的实现模式,不同的编程语言都有可能不一样。它是 synchronized 实现的关键。monitor 从 Java 层面经常被称为「监视器锁」。

上面铺垫了这么多东西,其实就是为了明确 monitorObject HeaderMark Word 的关系。每个 Java 对象都可以关联一个 monitor 对象,通过上面的内容可以看出来,只有在重量级锁的情况下 Object Header 中 Mark Word 才会设置指向 monitor 对象的指针


monitor 结构(图片来源:https://bbs.huaweicloud.com/blogs/314346)

monitor 内部由三部分组成:

  • Owner:用于记录当前 monitor 的所属线程。
  • EntryList:是一个链表结构,用于记录阻塞在当前锁对象上的线程。
  • WaitSet:用于记录获取锁之后进入Waiting状态的线程。

当对象锁发生锁竞争时,在同一时刻只有一个线程能够获取到锁,其他线程会进入阻塞(BLOCKED)状态,此时这些被阻塞的线程就会进入EntryList 中等待锁持有者释放锁后被唤醒,再次参与锁竞争(非公平)。

如下示例:

1
2
3
synchronized (obj) {
// 临界区代码
}
monitor流程1(图片来源:https://bbs.huaweicloud.com/blogs/314346)
  1. 刚开始,monitor 中的 Owner 为 null。

  2. 当 Thread1 执行 synchronized (obj) 就会将 monitor 的 Owner(所有者) 设置为 Thread1,monitor 中只能有一个 Owner。

  3. 在 Thread1 上锁的过程中,如果 Thread2、Thread3、Thread4 也来执行 synchronized (obj),它们就会根据 obj 对象头中 Mark Wrod 找到 monitor 对象,发现 Owner 已经被 Thread1 持有,就会进入 monitor 对象中 EntryList(阻塞等待队列) 中,阻塞等待。

  4. 当 Thread1 执行完同步代码块的内容,然后唤醒 EntryList(阻塞等待队列) 中等待的线程重新竞争锁,竞争是非公平的。竞争成功的线程成为锁拥有者,失败的线程继续在阻塞队列中阻塞。


当对象获取到锁之后,由于某些资源并未准备完成,需要等待其他线程去准备资源,此时线程会通过 wait/notify 等方法进入等待/通知模式,在这种情况下线程释放锁之后会进入 WaitSet,当其他线程准备好资源之后会通知 WaitSet 中等待的线程,WaitSet 中的线程会进入到 EntryList 中,重新参与锁竞争。

monitor流程2(图片来源:https://bbs.huaweicloud.com/blogs/314346)

4. monitorente && monitorexit

知道了 monitor 是什么,也知道了 Java 对象与 monitor 之间的关系,但是还有一层疑问:程序在运行过程中是如何知道要给 Java 对象去关联一个 monitor 呢?

Java 的源代码在编译器编译之后生成的 Class 文件中存储的是字节码指令,程序执行本质上是一条条指令按照既定顺序的流水线工作,编译器在编译成 Java 字节码时做了记号,这个记号就是 monitorente/monitorexit

monitorenter 字节码指令理解为「加锁」,monitorexit 字节码指令理解为「释放锁」。

看一段简单的 synchronized 代码块:

1
2
3
4
5
6
7
static final Object LOCK = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (LOCK) {
counter++;
}
}

将上述代码反编译成字节码后如下:

转换的字节码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Constant pool:
#1 = Methodref #4.#28 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#29 // com/server/test/module03/Test.LOCK:Ljava/lang/Object;
#3 = Fieldref #5.#30 // com/server/test/module03/Test.counter:I
...
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // 获取 LOCK 对象的引用地址(Synchronized 开始)
3: dup // 复制一份 LOCK 对象的引用地址
4: astore_1 // 将复制的 LOCK 对象的引用地址存储到临时变量 slot 1 中,为解锁做准备。
5: monitorenter // 将 LOCK 对象 MarkWord 置为 monitor 指针(LOCK 对象与操作系统中的 monitor 对象产生关联)。
6: getstatic #3 // counter++; 操作,不关注
9: iconst_1 // counter++; 操作,不关注
10: iadd // counter++; 操作,不关注
11: putstatic #3 // counter++; 操作,不关注
14: aload_1 // 从临时变量 slot 1 中获取刚才存储的 LOCK 对象的引用地址。
15: monitorexit // 将 LOCK 对象 MarkWord 重置,唤醒 monitor 中 EntryList 中等待的其他线程。
16: goto 24
19: astore_2 // 将异常对象存储到临时变量 slot 2 中。
20: aload_1 // 从临时变量 slot 1 中获取刚才存储的 LOCK 对象的引用地址。
21: monitorexit // 将 LOCK 对象 MarkWord 重置,唤醒 monitor 中 EntryList 中等待的其他线程。
22: aload_2 // 从临时变量 slot 2 中取出异常对象。
23: athrow // 将异常对象抛出
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
...

可以将上述字节码代码分成两部分。0 ~ 16 是临界区中的代码逻辑,按部就班的,注释比较清楚不再赘述。19 ~ 23 是用来异常处理的,如果临界区代码中抛出异常,这段代码会释放锁,防止出现死锁。

上面反编译字节码代码中有一个Exception table,它会监测指定范围,如果范围内出现异常,就会进行一些额外的处理。以上面代码为例,Exception table 监测的范围如,6 ~ 16,即:counter++; 也就是同步代码块内的逻辑,如果出现异常会跳转到第19。


5. 扩展

5.1. monitorenter 字节码指令

当一个线程进入同步代码块时,它使用 monitorenter 字节码指令请求进入;如果当前对象的监视器计数器为0,则它会被准许进入。

如果当前对象的监视器计数器为1,则判断持有当前监视器的线程是否为自己;如果是,则进入,并增加计数(注意!支持重入)

否则进行等待(阻塞),直到对象的监视器计数器为0,才会被允计进入同步块。

The objectref must be of type reference.

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:

  • If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
  • If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
  • If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

阅读自:The Java Virtual Machine Instruction Set (Java虚拟机规范) Chapter 6.5.monitorenter


5.2. monitorexit 字节码指令

当前线程退出同步代码块时,需使用 monitorexit 字节码指令声明退出,该字节码指令会减少当前对象的监视器计数。

如果当前对象监视器计数变为0,那当前线程不再是监视器的所有者;其他正在阻塞的线程允许尝试进入监视器。

  • 多个 monitorexit 字节码指令可以与 monitorenter 字节码指令一起使用。
  • 同步代码块正常执行完成时,退出对象监视器是由Java虚拟机的 return 字节码指令处理。
  • 同步代码块执行时意外中断,退出对象监视器由Java虚拟机的 athrow 字节码指令隐式处理。
  • 同步代码块执行中抛出异常,退出对象监视器由Java虚拟机的异常处理机制来完成。

保证同步代码块不论是正常执行完成,还是突然中断或是异常结束,都能释放锁。

The objectref must be of type reference.

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.

The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

One or more monitorexit instructions may be used with a monitorenter instruction to implement a synchronized statement in the Java programming language . The monitorenter and monitorexit instructions are not used in the implementation of synchronized methods, although they can be used to provide equivalent locking semantics.

The Java Virtual Machine supports exceptions thrown within synchronized methods and synchronized statements differently:

  • Monitor exit on normal synchronized method completion is handled by the Java Virtual Machine’s return instructions. Monitor exit on abrupt synchronized method completion is handled implicitly by the Java Virtual Machine’s athrow instruction.
  • When an exception is thrown from within a synchronized statement, exit from the monitor entered prior to the execution of the synchronized statement is achieved using the Java Virtual Machine’s exception handling mechanism…

阅读自:The Java Virtual Machine Instruction Set (Java虚拟机规范) Chapter 6.5.monitorexit


6. Reference