Java对象头
通常我们的Java对象在内存中都由两部分组成:对象头 + 对象中的成员变量,什么是对象头?
在32位虚拟机中:
普通对象对象头占8字节:
数组对象对象头占8 + 4字节
Mark Word:
用于存储对象的运行时信息,包括对象的类型、锁状态、GC 分代年龄等
Class Word:
指针,指向了这个对象从属的类对象
:::info
💡从上面可以看到,一个对象实际上对象头占用了不少空间,比如说Integer,一个int在内存中占4字节,在32位虚拟机,Integer在内存中占用的空间为8字节(对象头) + 4字节(value)。在内存很敏感的情况下多用基本类型少用包装类型
https://stackoverflow.com/questions/26357186/what-is-in-java-object-header
:::
在64为虚拟机中:
Java 对象的大小与操作系统的位数有关。在 32 位操作系统中,Java 对象头的大小为 8 字节;在 64 位操作系统中,Java 对象头的大小取决于 JDK 版本。在 JDK 8 中,64 位操作系统的 Java 对象头的大小为 16 字节;在 JDK 11 中,64 位操作系统的 Java 对象头的大小为 12 字节
Monitor(锁)
monitor通常被翻译为监视器或者操作系统中的管程
每个Java对象都可以关联一个Monitor对象,什么时候关联?当我们调用synchronized关键字给对象尝试加锁时就会尝试将monitor和我们的对象进行关联。monitor对象是操作系统提供的。关联时Java对象的对象头的Mark Word中就被设置指向Monitor对象的指针,对象头变化:
=》
图示:
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList(等待/阻塞队列,链表结构) BLOCKED 状态
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程
从字节码的角度分析在字节码指令上对monitor锁的体现
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
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引用,将来会关联monitor对象
3: dup
4: astore_1 // 将lock引用存到临时变量slot 1里
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // 拿到临时变量slot 1里的lock引用
15: monitorexit // 将 lock对象 MarkWord 重置(重置回hashcode分代年龄等), 唤醒 EntryList
16: goto 24 // 正常执行时,直接return
19: astore_2 // 临界区发生异常e 存入 slot 2
20: aload_1 // 拿到临时变量slot 1里的lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
22: aload_2 // 加载 slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any (防止出现异常时,加了synchronized锁不能解锁)
19 22 19 any
我们程序中加锁时,每个对象会关联一个Monitor,Monitor是真正的锁,它由操作系统提供,使用成本较高,从Java6开始对synchronized获取锁的方式进行了改进,从直接使用Monitor锁改成了可以使用轻量级锁、偏向锁进行优化…
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争,如果有竞争轻量级锁会升级为重量级锁),那么可以 使用轻量级锁来优化,轻量级锁不需要monitor锁,使用线程栈帧中的锁记录充当轻量级锁
轻量级锁对使用者是透明的,即语法仍然是synchronized,调synchronized的时候会优先使用轻量级锁去加锁,如果轻量级锁加锁失败,会用重量级锁加锁
假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
上面这段代码在加锁的流程上是怎样的:
- 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
- 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录,解锁时恢复回去
- 如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁
- 如果 cas 失败,有两种情况
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数,数据不再存储Mark Word,会存null,,表示锁重入的计数
- 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重 入计数减一
- 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀(亲两级锁升级为重量级锁)
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
假设有两个线程Thread-0和Thread-1同时执行下面代码,过程如下:
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
- 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
- 然后自己进入 Monitor 的 EntryList BLOCKED
- 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
自旋优化(重量级锁竞争时的优化)
重量级锁竞争的时候,还可以使用自旋来进行优化,自旋即:让线程先不进入阻塞entrylist,而是先进行几次循环,如果在循环过程中持锁的线程释放锁了就可以获取锁不用进入阻塞,避免上下文切换的发生(如果自旋多次仍然拿不到锁就进入阻塞)。
- 自旋需要使用cpu,适合多核cpu的场景,如果是单核没有意义。
- 在 Java 6 之后自旋锁是JVM调整为自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- Java 7 之后不能控制是否开启自旋功能 ,由JVM底层控制
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作
例如:
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized( obj ) {
// 同步块 C
}
}
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后加锁的时候发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
偏向状态
对象头格式
- 一个对象创建时: 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟,添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁,使用场景:明显有线程竞争时,可以禁用偏向锁
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
测试偏向锁:
// 添加虚拟机参数 -XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws IOException {
Dog d = new Dog();
// 利用 jol 第三方工具来查看对象头信息
ClassLayout classLayout = ClassLayout.parseInstance(d);
new Thread(() -> {
log.debug("synchronized 前");
System.out.println(classLayout.toPrintableSimple(true));
synchronized (d) {
log.debug("synchronized 中");
System.out.println(classLayout.toPrintableSimple(true));
}
log.debug("synchronized 后");
System.out.println(classLayout.toPrintableSimple(true));
}, "t1").start();
}
测试结果:
11:08:58.117 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
11:08:58.121 c.TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
11:08:58.121 c.TestBiased [t1] - synchronized 后
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
处于偏向锁的对象解锁后,线程 id 仍存储于对象头中,因为偏向锁表示这个锁对象就从属于这个线程,除非其他线程又用这个锁对象才会改变
偏向锁撤销
- 调用对象 hashCode
偏向锁的对象头 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销,变成轻量级锁,因为对象头中没地方存hashCode了, 正常状态对象一开始是没有 hashCode 的,第一次调用才生成
轻量级锁会在锁记录中记录 hashCode
重量级锁会在 Monitor 中记录 hashCode
- 其它线程使用对象
private static void test2() throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}, "t2");
t2.start();
}
输出
[t1] - 00000000 00000000 00000000 00000000 00011111 01000001 00010000 00000101 // 偏向锁
[t2] - 00000000 00000000 00000000 00000000 00011111 01000001 00010000 00000101 // 偏向锁
[t2] - 00000000 00000000 00000000 00000000 00011111 10110101 11110000 01000000 // 轻量级锁
[t2] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 // 不可偏向
- 调用 wait/notify
- 因为wait/notify机制只有重量级锁才有,所以会把锁升级为重量级锁
锁消除
public void b() throws Exception {
Object o = new Object();
synchronized (o) {
x++;
}
}
Java运行时有JIT即时编译器,会对反复调用的代码进行优化,上述代码中的o对象不会逃离方法,因此synchronized加锁没有意义,JIT会优化掉synchronized,即锁消除