目录
对象在内存中是如何布局的
如何查看对象在内存中的布局
markword数据结构
加锁后发生了什么
偏向锁
什么是偏向锁
偏向锁定时hashCode 哪去了?
为什么需要偏向锁
为什么从JDK15开始默认关闭偏向锁
什么是安全点
JDK8 为什么要延迟4S后开启偏向锁
锁升级流程
轻量级锁(Thin Lock)
自旋锁
自适应自旋锁
重量级锁(Fat Lock)
Synchronized 锁实现
监视器锁 (monitor)
总结
在聊到对象加锁这个话题,那就必须先聊聊对象在内存中的布局, 你知道一个对象在内存中是如何布局的吗?一个对象new出来以后,它在内存中主要分为一下四个部分:
markword | 这部分就是加锁的核心,占8个字节 |
klass pointer | 记录指向对象class文件的指针,占4个字节 |
instance data | 对象变量数据 |
padding | 对其数据,在64位版本虚拟机规范中要求对象大小必须是8的倍数,不足部分使用padding补齐 |
final Object monitor = new Object(); 这个monitor 在内存中的大小是多少字节呢?答案是16个字节,8+4+0=12,12不能不8整除,所以补齐后的大小为16;
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
我们可以通过JOL(Java Object Layout)来查看对象在内存中的布局;
org.openjdk.jol
jol-core
0.16
public static void main(String[] args) {
//JKD8延迟4S开启偏向锁
Thread.sleep(5000);
final Object monitor = new Object();
System.out.println(ClassLayout.parseInstance(monitor).toPrintable());
}
这个代码的运行结果就是上文中的布局信息。
在上文中提到markword是对象加锁的核心,那么这部分数据的结果是什么样子呢?下面就来介绍一下markword数据结构;这也一些大厂面试中经常会遇到的部分。
锁状态 | 56 bit | 1bit | 4bit | 1bit | 2bit | ||
---|---|---|---|---|---|---|---|
是否偏向锁 | 锁状态 | ||||||
无锁 | unused 25bit | hashcode 31bit | unused | 分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | epoch 2bit | unused | 分代年龄 | 1 | 01 | |
轻量级锁 | 指向战中锁记录的指针 | 00 | |||||
重量级锁 | 指向互斥锁的指针 | 10 | |||||
GC | 11 |
聊完了JOL以后,我们来看看对象加锁后到底发生了什么?我们通过如下一段代码来看看:
public static void main(String[] args) {
final Object obj = new Object();
System.out.println("启动后对象布局:\n" + ClassLayout.parseInstance(obj).toPrintable());
//JKD8延迟4S开启偏向锁
Thread.sleep(5000);
//可偏向 101
final Object monitor = new Object();
System.out.println("延迟5秒后对象布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
//偏向锁
synchronized (monitor) {
System.out.println("对象加锁后的布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
}
System.out.println("对象释放锁后的布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
}
输出的结果是:
启动后对象布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
延迟5秒后对象布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
对象加锁后的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007fd89d00e805 (biased: 0x0000001ff627403a; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
对象释放锁后的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007fd89d00e805 (biased: 0x0000001ff627403a; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
从代码的执行结果可以知道在启动后是不支持偏向锁的,延迟4S后,对象是可偏向的(biasable)在此之后首次获取到的就是偏向锁(biased),释放锁以后可以发现markword并没有什么变化。
偏向锁是HotSpot虚拟机中使用的一种锁优化技术,用于减少无竞争锁定的开销。它旨在避免在获取监视器锁时执行CAS操作。偏向锁是基于无竞争的假设下,即假设监视器一直归给定的线程所有,直到不同的线程尝试获取它。当改对象没有被其他线程访问过时,则初始锁定偏向于改线程,即将在markword 中记录改线程ID,表示改线程持有锁,后续改线程对该对象锁定时避免CAS操作,可以直接使用。
获取偏向锁的流程图如下:
通过上文介绍markword布局可以知道,偏向锁定时markword中保存的是偏向线程的ID,那么如果此时执行hashCode又是一个什么场景呢?下面我们通过执行代码看看。
public static void main(String[] args) throws Exception {
//JKD8延迟4S开启偏向锁
Thread.sleep(5000);
//可偏向 101
final Object monitor = new Object();
System.out.println("延迟5秒后对象布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
//偏向锁
synchronized (monitor) {
System.out.println("对象加锁后的布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
}
System.out.println("对象释放锁后的布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
System.out.println(monitor.hashCode());
System.out.println("执行hash后的对象布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
}
代码执行的结果如下图所示,可以发现执行hashCode()方法后,锁对象撤销了偏向锁,变为了无锁状态
延迟5秒后对象布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
对象加锁后的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007fde3e009005 (biased: 0x0000001ff78f8024; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
对象释放锁后的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007fde3e009005 (biased: 0x0000001ff78f8024; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
1937962514
执行hash后的对象布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000007382f61201 (hash: 0x7382f612; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
这个主要是因为我们很多代码中无意识地引入了同步锁,很多时候这些锁都是没有竞争的。如:
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
通过上文了解,好像偏向锁可以极大提升锁性能;理想很美好,现实很残酷;在实际使用过程中性能提升远远没有预期那么明显, 在早并发场景下禁用偏向锁后反而性能更好。为什么会这样呢,这个有多方面原因导致的。
第一:早期JDK集合API都是同步的,如HashTable ,Vector 等,这些API在现在代码中很少能够看到,而代替的是HashMap, ArrayList;
第二:在无竞争场景下,获取锁的成本很低,但是一旦有了竞争,撤销锁却是一件昂贵的事。撤销偏向锁不是在有竞争的时候就可以立即撤销,而是需要等到安全点(safe point)才能撤销也就是撤销偏向锁需要STW。
上文提到偏向锁需要等到安全点以后才能撤销,那么什么是安全点呢?安全点就是代码执行的一些特殊位置,当线程执行到这个位置时候表示线程是安全的。比较典型的就是GC处理时需要等待安全点才可以执行GC操作。在编译阶段编译器会在一些特殊的位置插入读取全局Safepoint Polling内存页,如果需要安全点被标记则Safepoing Pollling内存页不可读,使得当前现在阻塞在安全点。如果VM Thread发现还有线程still_running 则会自旋等待,直到所有线程都进入安全点,still_running 数量为0时才可以执行一些不安全操作。
通过上文我们很容易理解,因为在JVM启动过程中必然会出现大量锁竞争,偏向锁因为锁竞争导致偏向锁撤销的操作成本又非常高,为了提升性能默认延迟4S后开启偏向锁。
锁升级流程是 无锁->偏向锁->轻量级锁(thin lock)->重量级锁(fat lock); 下面我们通过代码来掩饰一些锁升级过程
public static void main(String[] args) throws Exception {
final Object obj = new Object();
System.out.println("启动后对象布局:\n" + ClassLayout.parseInstance(obj).toPrintable());
//JKD8延迟4S开启偏向锁
Thread.sleep(5000);
//可偏向 101
final Object monitor = new Object();
System.out.println("延迟5秒后对象布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
//偏向锁
synchronized (monitor) {
System.out.println("对象加锁后的布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
}
System.out.println("对象释放锁后的布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
System.out.println(monitor.hashCode());
System.out.println("执行hash后的对象布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
new Thread(() -> {
synchronized (monitor) {
System.out.println("线程2对象加锁后的布局:\n" + ClassLayout.parseInstance(monitor).toPrintable());
}
}).start();
Thread.sleep(1000);
synchronized (monitor) {
new Thread(() -> {
try {
monitor.wait();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
System.out.println("重量级锁:\n"+ClassLayout.parseInstance(monitor).toPrintable());
}
}
结果如下:
启动后对象布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
延迟5秒后对象布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
对象加锁后的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007ff66980d805 (biased: 0x0000001ffd9a6036; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
对象释放锁后的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007ff66980d805 (biased: 0x0000001ffd9a6036; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
1937962514
执行hash后的对象布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000007382f61201 (hash: 0x7382f612; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
线程2对象加锁后的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000070000f36c908 (thin lock: 0x000070000f36c908)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007ff66b01733a (fat lock: 0x00007ff66b01733a)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
通过执行的结果可以发现锁的升级流程是从 偏向锁(biased)->轻量级锁(thin Lock)->重量级锁(Fat Lock)
在轻量级锁中,获取锁的线程首先拷贝对象头中的markword到帧栈的锁记录中。拷贝成功后使用CAS更新markword 锁记录为当前线程的指针;如果更新成功就表示当前线程持有了改对象的锁,如果更新失败,就表示当前锁存在竞争,此时不是立即升级到重量级锁,而是继续尝试(自旋)获取轻量级锁,当失败多次后,就升级为重量级锁。为什么不继续自旋呢?因为自旋就意味着消耗CPU资源。
在轻量级锁中获取锁失败后会继续尝试获取锁,这个流程就称为自旋;如果锁的粒度很小持有锁的时间很短,通过轮询几次以后就可以获取锁这个成本相对重量级锁而言还是很低的。
使用-XX:-UseSpinning参数关闭自旋锁优化;-XX:PreBlockSpin参数修改默认的自旋次数。
自旋意味着占用CPU资源,如果线程多,CPU资源比较少时,CPU资源全部被自旋占用,反而造成了CPU资源的浪费。那么如何设置自旋的次数呢?这个是一件很困难的事情。自适用自旋锁就是为了解决锁竞争的时间不确定性这个问题的。JVM自己去根据锁竞争的情况去优化自旋次数。
在重量级锁中获锁和释放锁的流程和轻量级锁差不多,主要的区别是,获取锁失败后当前线程会阻塞;释放锁以后会唤醒阻塞线程。那么为什么这个是重量级锁呢?主要是因为线程的阻塞和唤醒涉及到线程切换以及系统调用引起的用户态和内核态切换等,这些都是成本比较高的操作,所以说是重量级锁。
针对上文中的代码编译后通过javap 查看字节码, 可以发现synchronized在字节码中是通过
monitorenter 进入同步代码, monitorexit 退出。
95: monitorenter
96: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
99: new #7 // class java/lang/StringBuilder
102: dup
103: invokespecial #8 // Method java/lang/StringBuilder."":()V
106: ldc #19 // String 对象加锁后的布局:\n
108: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
111: aload_2
112: invokestatic #11 // Method org/openjdk/jol/info/ClassLayout.parseInstance:(Ljava/lang/Object;)Lorg/openjdk/jol/info/ClassLayout;
115: invokevirtual #12 // Method org/openjdk/jol/info/ClassLayout.toPrintable:()Ljava/lang/String;
118: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
121: invokevirtual #13 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
124: invokevirtual #14 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
127: aload_3
128: monitorexit
129: goto 139
132: astore 4
134: aload_3
135: monitorexit
_object | 锁寄生的对象,锁资源不能凭空出现,必须寄生在一个对象上 |
_owner | 拥有锁的线程 |
_WaitSet | 等待锁资源线程,自旋获取锁 |
_EntryList | 未获取锁被阻塞的线程 |