一、对象的内存布局
在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头(Header)、实例数据(Instance Data)和对其填充(Padding)。
对象头:比如hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等
实例数据:存放类的属性数据信息,包括父类的属性信息
对齐填充:由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头已经被精心设计成正好是8字节的整数倍,因此,如果对象实例数据没有对齐的话,就需要通过对齐填充来补全。
对象内存布局如下图所示:
1.1 对象头详解
在Hotspot虚拟机对象的对象头中,又包括了两类信息。第一类是用于存放对象自身的运行时数据,就是上面的Mark Word
部分,对象头的另一部分是类型指针,即对象指向它的类型元数据的指针,此外,如果对象是一个Java数组,那么对象头还需要有一块记录数组长度的数据。
-
Mark Word
用于存放对象自身的运行时数据,如哈希码(hashCode)、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
-
Klass Pointer
类型指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。在32位虚拟机占用4byte,64位开启压缩指针或最大堆内存<32G时也占用4byte,否则8byte。JDK1.8中默认开启压缩指针后为4byte,当在JVM参数中关闭压缩指针(-xx:-UseCompressedOops)后,长度为8byte。
-
数组长度(只有数组对象有)
如果对象是一个数组,那在对象头中还必须有一块数据用于记录数组长度,占用4byte。
1.2 查看对象内存布局
JOL(JAVA OBJECT LAYOUT)可以用来查看new出来的Java对象的内存布局,以及一个普通的Java对象占用多少个字节。
引入依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
推荐使用0.10
这个版本,较高版本不展开显示具体的二进制数据
测试代码如下:
Object object = new Object();
// 查看对象内部信息
System.out.println(ClassLayout.parseInstance(object).toPrintable());
利用JOL查看64位系统空对象内存,默认开启指针压缩,总大小为16byte,前12byte为对象头
OFFSET:偏移地址,单位字节
SIZE:占用的内存大小,单位为字节
TYPE DESCRIPTION:类型描述,其中object header为对象头
VALUE:对应内存中当前内存的值,二进制32位
通过-XX:-UseCompressedOops
关闭压缩指针后,对象头大小为16byte
通过上面对象头的介绍,我们知道对象的锁状态记录在每个锁对象的对象头的Mark Word
中,而我们了解对象的内存布局也是为了能够更直观的看到锁对象当前对应的锁状态。
1.3 Mark Word
Mark Wod
是如何记录锁状态的呢?Hotspot通过markOop类型实现Mark Word
,具体实现位于markOop.hpp
文件中。由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。
简单理解就是:Mark Word
结构搞得这么复杂,是因为需要节省空间
,让同一内存区域在不同阶段有不同的用处。
markOop
的注释中描述了32位和64位虚拟机的Mark Word
的内存布局:
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
- hash:保存对象的哈希码,运行期间调用System.identityHashCode()来计算,不过是延迟计算,当真正调用
Object::hashCode()
方法时才会去计算,然后赋值到这里 - age:保存对象的分代年龄。保存对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。这里用4bit来表示,是因为在Hotspot虚拟机的垃圾回收算法中,如果GC次数超过15就会被转移到老年代。
- biased_lock:偏向锁标识。由于无锁和偏向锁的锁标识都是01,没办法区分,这里引入一位偏向锁标识
- lock:锁状态标识位。区分锁状态,比如11标识对象待GC回收状态
- JavaThread*:保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有锁对象时,对象这里就会被置为该线程的ID。在后面的操作中就不需要进行尝试获取锁的动作。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
- epoch:保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
32位JVM下的对象结构描述
64位JVM下的对象结构描述
- ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作,而不是操作系统的互斥,这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的
Mark Word
中设置指向锁记录的指针。 - ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
注:不论是轻量级锁的栈中锁记录还是重量级锁的Monitor对象,都会记录锁对象的hashCode
值,而唯独偏向锁中,没有分配记录hashCode
值的地方,所以,如果锁对象调用了hashCode()
方法,那么它一定不会处于偏向锁状态
Mark Work中锁标记枚举
enum { locked_value = 0, //00 轻量级锁
unlocked_value = 1, //001 无锁
monitor_value = 2, //10 监视器锁,也叫膨胀锁,也叫重量级锁
marked_value = 3, //11 GC标记
biased_lock_pattern = 5 //101 偏向锁
}
二、偏向锁
后面都会使用JOL工具来跟踪记录锁标记变化
偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,因此为了消除数据在无锁竞争下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。
/***StringBuffer内部同步***/
public synchronized int length() {
return count;
}
//System.out.println 无意识的使用锁
public void println(String x) {
synchronized (this) {
print(x); newLine();
}
}
当JVM开启了偏向锁模式(JDK6默认开启),新创建对象的Mark Word
中Thread ID
为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向(anonymously bisaed)
2.1 延迟偏向
偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。
偏向锁相关JVM参数:
//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0
//禁止偏向锁
-XX:-UseBiasedLocking
我们通过下面的代码来演示偏向锁的延迟偏向
log.info(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(4000);
log.info(ClassLayout.parseInstance(new Object()).toPrintable());
如上图所示,用红色框框标注出来的就是对象锁的状态,明明对象头的最后三位才是表示锁状态,为什么这里是红框中的三位表示锁状态呢,这里就涉及到计算机存储中一个大端对小端的问题,实际的高位在内存中的低位。
从上面的锁状态可以看出,一开始创建的对象,处于无锁状态,而主线程休眠4秒后,创建的对象处于偏向锁状态,但此时锁对象的偏向锁线程ID为0,表示不偏向任何一个线程,处于可偏向状态。
2.2 偏向锁状态跟踪
Thread.sleep(4000);
Object object = new Object();
new Thread(() ->{
log.info("开始执行……\n"+ClassLayout.parseInstance(object).toPrintable());
synchronized (object){
log.info("获取锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}
log.info("释放锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}).start();
通过上面的代码可以展示各个阶段,锁对象的锁状态,通过下面打印的信息可以看出,锁对象一开始处于偏向锁状态,线程获取到锁之后,由于没有其他线程竞争,依然处于偏向锁状态,当锁释放之后,也仍然还是偏向锁状态
2.3 偏向锁撤销
2.3.1 调用HashCode方法
我们在上面Mark Word部分,分析了偏向锁没有分配存储hashCode
值的位置,所以当调用hashCode()
方法时,将会使对象永远无法再回到偏向锁状态。
对象处于可偏向状态
当对象处于可偏向状态时(Thread ID为0),调用hashCode()
方法前后,锁状态变化如下:
Thread.sleep(4000);
Object object = new Object();
log.info("撤销前……\n"+ClassLayout.parseInstance(object).toPrintable());
object.hashCode();
log.info("撤销后……\n"+ClassLayout.parseInstance(object).toPrintable());
调用对象的hashCode()
方法之前,对象处于偏向锁状态,而调用hashCode()
方法之后,对象锁状态撤销,回到了无锁状态,这时候如果有线程竞争锁对象,对象的锁状态只能由无锁升级为轻量级锁。
对象处于已偏向状态
当对象处于已偏向状态时(Thread ID不为0),调用hashCode()
方法前后,锁状态变化如下:
Thread.sleep(4000);
Object object = new Object();
synchronized (object){
log.info("撤销前……\n"+ClassLayout.parseInstance(object).toPrintable());
object.hashCode();
log.info("撤销后……\n"+ClassLayout.parseInstance(object).toPrintable());
}
当对象处于偏向锁时,调用hashCode()
方法将会使偏向锁强制升级成重量级锁
2.3.2 调用wait/notify方法
当锁对象处于已偏向状态时,调用锁对象的wait(timeout)
方法,会升级成为重量级锁
Thread.sleep(4000);
Object object = new Object();
synchronized (object){
log.info("wait前……\n"+ClassLayout.parseInstance(object).toPrintable());
object.wait(10);
log.info("wait后……\n"+ClassLayout.parseInstance(object).toPrintable());
}
当锁对象处于已偏向状态时,调用锁对象的notify()
方法,会升级成为轻量级锁
Thread.sleep(4000);
Object object = new Object();
synchronized (object){
log.info("notify前……\n"+ClassLayout.parseInstance(object).toPrintable());
object.notify();
log.info("notify后……\n"+ClassLayout.parseInstance(object).toPrintable());
}
三、轻量级锁
偏向锁失败时,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种轻量级锁的优化手段,此时Mark Word
的结构也变为轻量级锁的结构。**轻量级锁所适应的场景是线程交替执行同步块的场合。**如果存在同一时间多个线程访问同一把锁的场景,就会导致轻量级锁膨胀为重量级锁。
上面说明了轻量级锁适用线程交替执行同步块的场景,换句话说就是当一个线程在执行同步代码块时,另一个线程也想来执行,它就会去执行一次CAS操作,这个时候刚好上一个线程执行完了,它的CAS操作成功,就可以顺利去执行同步代码块;如果上一个线程还没执行完,该线程的CAS就会失败,这个时候,锁对象就会升级成重量级锁。
3.1 轻量级锁状态跟踪
先让线程休眠4秒,使得偏向模式生效,然后再调用锁对象的hashCode()
方法使其撤销到无锁模式,这个时候再去获取锁和释放锁,观察锁状态变化的情况
Thread.sleep(4000);
Object object = new Object();
object.hashCode();
new Thread(()->{
log.info("开始执行……\n"+ ClassLayout.parseInstance(object).toPrintable());
synchronized (object){
log.info("获取锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}
log.info("释放锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}).start();
Thread.sleep(5000);
通过下面输出的对象内存信息,可以清晰地看到锁对象一开始处于无锁状态,获取锁之后处于轻量级锁状态,最后释放锁又回到了无锁状态。
思考:轻量级锁是否可以降级为偏向锁?
3.2 锁升级
3.2.1 偏向锁升级为轻量级锁
我们可以通过两个线程轻微竞争的方式来模拟偏向锁到轻量级锁的膨胀过程
在两个线程执行间隙,一定要让主线程休眠一下,防止剧烈竞争
Thread.sleep(4000);
Object object = new Object();
Thread thread1 = new Thread(()->{
log.info(Thread.currentThread().getName()+"开始执行……\n"+ClassLayout.parseInstance(object).toPrintable());
synchronized (object){
log.info(Thread.currentThread().getName()+"获取锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}
log.info(Thread.currentThread().getName()+"释放锁……\n"+ClassLayout.parseInstance(object).toPrintable());
},"Thread1");
thread1.start();
Thread.sleep(1);
Thread thread2 = new Thread(()->{
log.info(Thread.currentThread().getName()+"开始执行……\n"+ClassLayout.parseInstance(object).toPrintable());
synchronized (object){
log.info(Thread.currentThread().getName()+"获取锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}
log.info(Thread.currentThread().getName()+"释放锁……\n"+ClassLayout.parseInstance(object).toPrintable());
},"Thread2");
thread2.start();
Thread.sleep(10000);
下图展示了两个线程轻微竞争时锁对象的锁状态变化,线程1执行前、获取锁和释放锁这几个节点,锁状态都处于偏向锁状态,线程2执行前,锁对象也处于偏向锁状态,当获取锁时,此时处于轻量级锁状态,当线程2释放锁后,最后又回到了无锁状态。这就解释了上面思考的问题,轻量级锁不会降级成为偏向锁,只会降级成为无锁状态
注:代码测试的时候,有时候可能需要反复测试才能得到下面的结果
3.2.2 轻量级锁升级为重量级锁
下面代码展示了两个线程激烈的竞争
log.info(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(4000);
Object object = new Object();
Thread thread1 = new Thread(()->{
synchronized (object){
log.info(Thread.currentThread().getName()+"获取锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}
log.info(Thread.currentThread().getName()+"释放锁……\n"+ClassLayout.parseInstance(object).toPrintable());
},"Thread1");
thread1.start();
Thread thread2 = new Thread(()->{
synchronized (object){
log.info(Thread.currentThread().getName()+"获取锁……\n"+ClassLayout.parseInstance(object).toPrintable());
}
log.info(Thread.currentThread().getName()+"释放锁……\n"+ClassLayout.parseInstance(object).toPrintable());
},"Thread2");
thread2.start();
Thread.sleep(5000);
log.info(ClassLayout.parseInstance(object).toPrintable());
下面的输出结果展示了线程1获取锁时,此时锁对象处于偏向锁状态,而线程2去获取锁执行时,锁对象已经升级为重量级锁,线程1释放的时候,锁对象就已经是重量级锁,线程2释放锁之后,又回到了无锁状态。
注:代码最后面有一行Thread.sleep(5000)
的代码,如果这个休眠的时间长一点儿,打印出来线程2释放锁后就可能还是重量级锁,这是因为重量级锁是当ObjectMonitor
对象被回收时才会释放的,而对象回收是GC自主的行为。
注:重量级锁释放后变成无锁,此时有新的线程来调用同步块,会直接变成轻量级锁。不会从无锁变成偏向锁。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/153628.html