引言
上一篇文章讲述了JVM中对象是如何被创建的,这篇文章来讲一下对象在JVM内存中的布局。
首先在HotSpot虚拟机中,对象存储在内存中被分为了三部分:对象头(Header)、实例数据(Instance Data) 、对齐填充(Padding)。
其中对象头又包含两部分信息:第一部分是存储对象自身运行时的数据,如HashCode、Gc年龄、锁状态等等,另一部分存储了类型指针,虚拟机可以通过这一指针来确定这个对象是哪个类的实例。
实例数据就是对象真正存储的有效信息。对其填充没有什么特别的含义,仅仅起的是占位作用。
后续我会逐一详解一下这三部分。
对象头(Header)
运行时数据(Mark Word)
主要包括哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等;这部分长度在32和64位 (未开启指针压缩) 虚拟机中分别占32bit和64bit,官方称之为Mark Word
。但是对象需要存储的运行时数据很多,其实已经超过32位、64位BitMap结构所能记录的限度,因此Mark Word
并非一个固定的数据结构,在不同系统下或不同状态下每一位所表示的含义可能有所不同。
32位对象头中的markword布局:
64位对象头中的markword布局:
代码验证
接下来我们通过JAVA代码来验证一下对象头,在这里推荐一个可以查看JAVA对象内存布局的神器JOL
。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>RELEASE</version>
</dependency>
Person实体
我们先定义一个Person类,之后用这个实体类测试就可以了,该类重写了toString方法并且定义了一个printf静态方法,如下:
package com.cjf.test;
import org.openjdk.jol.info.ClassLayout;
/**
* @author Chenjf
* @date 2020/8/6
**/
public class Person {
private String name;
private Integer age;
private String address;
private boolean man;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public Person() {
}
public Person(String name, Integer age, String address) {
this.name = name;
this.age = age;
this.address = address;
}
@Override
public String toString() {
final StringBuffer sb = new StringBuffer("Person{");
sb.append("name='").append(name).append('\'');
sb.append(", age=").append(age);
sb.append(", address='").append(address).append('\'');
sb.append(", hashCode='").append(hashCode()).append('\'');
sb.append(",").append(getClass().getName())
.append("@").append(Integer.toHexString(hashCode()));
sb.append('}');
return sb.toString();
}
public static void printf(Person p) {
// 查看对象的整体结构信息
// JOL工具类
System.out.println(ClassLayout.parseInstance(p).toPrintable());
}
}
无锁测试
我们先来个简单无锁测试,测试代码如下
public static void main(String[] args) throws Throwable {
//创建一个实例
Person person = new Person();
// 输入对象信息
System.out.println(person);
//输出信息
Person.printf(person);
}
输出结果如下:
看图之前我们先了解两个基本概念:大端存储 和 小端存储;
大端存储与小端存储模式主要指的是数据在计算机中存储的两种字节优先顺序。小端存储指从内存的低地址开始,先存储数据的低序字节再存高序字节;相反,大端存储指从内存的高地址开始,先存储数据的高序字节再存储数据的低序字节。
例如:
十进制数9877,如果用小端存储表示则为:
高地址 <- - - - - - - - 低地址
10010101[高序字节]
00100110[低序字节]
用大端存储表示则为:
低地址 <- - - - - - - - 高地址
00100110[低序字节]
10010101[高序字节]
各自优点:
小端存储:便于数据之间的类型转换,例如:long类型转换为int类型时,高地址部分的数据可以直接截掉。
大端存储:便于数据类型的符号判断,因为最低地址位数据即为符号位,可以直接判断数据的正负号。
结合上图中的输出结果可以看到,当前对象的hashCode为381259350,转为16进制为16 b9 8e 56,markword对应的值为01 56 8e b9 16 00 00 00,其中56 8e b9 16跟我们的hashCode好像呀有木有,这是就是因为它采用的是大端存储模式,所以是倒过来的。
因为我的机器是64位的系统,所以我们来对比一下64位对象头布局图;无锁状态下,0-25位未使用,26-56位存储hashCode,57 未使用,58-61 存储分代年龄,62 存储偏向锁标识(biased_lock),63-64 存储锁标识位。
0-25 未使用 26-56 存储hashCode,我们已经验证过没有问题。
57-64 位数据对应二进制为 0000 0001。其中57未使用我们不看;58-61 共计四位,所能表示最大数为15,所以新生代进入老年代的最大年龄只能是15,到达此年龄一定会进入老年代,但是未到达此年龄也有可能进入。
动态对象年龄判定
为了更好地适应不同程序内存状况,虚拟机并不硬性要求对象年龄达到MaxTenuringThreshold(15)才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入年老代。
因为我们没有触发gc,所以目前年龄还是0;当前为无锁状态,所以偏向标识为0,锁状态为01,输出结果和64位布局图完全一致。
偏向锁测试
测试代码如下:
public static void main(String[] args) throws Throwable {
//创建一个实例
Person person = new Person();
//输出信息
System.out.println(
"============================before lock============================");
Person.printf(person);
synchronized (person) {
System.out.println(
"============================locked============================");
Person.printf(person);
}
//输出信息
System.out.println(
"============================after lock============================");
Person.printf(person);
}
输出结果如下:
直接看红框的后三位代表锁信息,结果发现,卧槽!!!怎么是 before(001) locked(000) after(001),即无锁->轻量级锁->无锁,跟预想的不太一样啊。
于是我们翻一翻Hotspot源码:
我把源码下载下来了,开发工具用的是CLION(跟IDEA一个公司的产品)。
HotSpot源码在线地址 直接看1272行就行了。
翻译过来就是: 启用偏置锁定之前要等待的毫秒数, 等待的毫秒数使偏向锁之前
,意思就是jvm使用偏向锁是有延迟的,在系统启动4s后才开启偏向锁,为什么会这样,因为JAVA程序启动系统是要加载资源的,这些资源对象加偏向锁是没有意义的,为了减少加锁撤销锁的成本,所以启用了延迟加载偏向锁。
既然这样那我们在代码里面睡眠个5s不就好了,再来测试一下。
public static void main(String[] args) throws Throwable {
Thread.sleep(5000);
//创建一个实例
Person person = new Person();
//输出信息
System.out.println(
"============================before lock============================");
Person.printf(person);
synchronized (person) {
System.out.println(
"============================locked============================");
Person.printf(person);
}
//输出信息
System.out.println(
"============================after lock============================");
Person.printf(person);
}
输出结果如下:
我们还是直接看红框的后三位 before(101) locked(101) after(101),biased_lock为1,锁标识为01,即为偏向锁。
有同学会问,怎么加锁前后也是101啊,不应该是001吗,是这样的,JVM启用偏向锁后,在对象的初始化阶段就会直接把对象头的锁信息置为101代表偏向锁。所以在JVM启用偏向锁后,任何对象new出来时的对象头上的锁信息都是偏向锁。回到图中,因为加锁线程仍旧是主线程,所以没有产生锁竞争,所以对象头中的锁信息仍然是偏向锁。
除了手动睡眠5s外,我们还可以通过指定jvm参数来关闭延迟加载。
- 开启偏向锁:-XX:+UseBiasedLocking
- 指定偏向锁延迟: -XX:BiasedLockingStartupDelay=0
- 关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁和重量级锁以及其加锁状态下的对象头信息我们在本篇文章里就不展开了,下次会开个专题专门来讲各个锁原理。
类型指针(Klass Pointer)
即对象指向它的元数据的指针,虚拟机通过这个指针来确定是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针(通过句柄池访问)。
简单引申一下对象的访问方式,我们创建对象的目的就是为了使用它。所以我们的Java程序在运行时会通过虚拟机栈中本地变量表的reference数据来操作堆上对象。但是reference只是JVM中规范的一个指向对象的引用,那这个引用如何去定位到具体的对象呢?因此,不同的虚拟机可以实现不同的定位方式。主要有两种:句柄池和直接指针。
句柄访问:
使用句柄访问对象,会在堆中开辟一块内存作为句柄池,句柄中储存了对象实例数据(属性值结构体)的内存地址,访问类型数据的内存地址(类信息,方法类型信息),对象实例数据一般也在heap中开辟,类型数据一般储存在方法区中。
优点:reference存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变。
缺点:增加了一次指针定位的时间开销。
直接指针访问:
直接指针访问方式指reference中直接储存对象在heap中的内存地址,但对应的类型数据访问地址需要在实例中存储。
优点:节省了一次指针定位的开销。
缺点:在对象被移动时(如进行GC后的内存重新排列),reference本身需要被修改。
我们可以看到,通过句柄池访问的话,对象的类型指针是不需要存在于对象头中的,但是目前大部分的虚拟机实现都是采用直接指针方式访问。
此外如果对象为JAVA数组的话,那么在对象头中还会存在一部分数据来标识数组长度,否则JVM可以查看普通对象的元数据信息就可以知道其大小,看数组对象却不行。
代码验证
普通对象
接下来我们还是一起用代码来验证一下。
public static void main(String[] args) throws Throwable {
Person person = new Person();
//输出一下对象信息
System.out.println(person);
//输出对象内存地址
System.out.println(GraphLayout.parseInstance(person).toPrintable());
//输出对象内存布局
System.out.println(ClassLayout.parseInstance(person).toPrintable());
//随便加一行 在这打断点
System.out.println("断点");
}
输出结果:
在32位系统中,类型指针为4字节32位,在64位系统中类型指针为8字节64位,但是JVM会默认的进行指针压缩,所以我们上图输出结果中类型指针也是4字节32位。如果我们关闭指针压缩的话,就可以看到64位的类型指针了,所以我们通常在部署服务时,JVM内存不要超过32G,因为超过32G就无法开启指针压缩了(尤其是ElasticSearch服务),这边贴上一个指针压缩博客,感兴趣的也可以仔细研究一下。
JVM - 剖析Java对象头Object Header之指针压缩
开启关闭指针压缩 : -XX:+UseCompressedOops
输出结果如下:
好我们继续,我们虽然看到了类型指针,但是怎么证明这就的确就是指向对象它类元数据的指针呢?
的确,单从上面输出的内容我确实无法证明;一开始我以为这个指针就是class对象的内存地址,于是我输出了Person.class的内存地址之后发现根本不一样;所以我只能找别的办法来验证了。
还记得之前我们打的断点和输出的对象地址吗,接下来派上用场了。
我们先用jps,打印一下java对应的进程信息。
D:\Idea Projects\jdk8-demo>jps
12452 ObjectHeaderTest
9204 Jps
11016 RemoteMavenServer
12184 HSDB
3240 Launcher
18044
刚才debug的程序对应的pid为12452,接着我们使用管理员身份打开cmd窗口,输入以下指令打开hsdb。
HSDB(Hotspot Debugger),是一款内置于 SA 中的 GUI 调试工具,可用于调试 JVM 运行时数据,从而进行故障排除
java -classpath "%JAVA_HOME%/lib/sa-jdi.jar" sun.jvm.hotspot.HSDB
会弹出一个图形化界面,然后我们点击File点击再选择第一项后输入我们刚才用jps获取的pid进程号,12452
然后双击main,会看到一个Insperctor框,这里面显示的应该是主线程的的Thread对象信息,我们可以在上面的输入框中输入内存地址来查看任何对象。
输入Person对象的内存地址后,得到如下结果:
图中的_mark:252775905025 即为markword,不信我们可以转为16进制对比一下。
3a da 9e 37 01 和我们输出的markword是一致的只不过顺序颠倒过来了,还是大端存储小端存储的问题。
然后和_mark紧接着的就是 _metadata 我们可以看到 InstanceKlass for com/cjf/test/Person 表明这个类的类型是Person类型,同时证明了在JVM内存中对象的对象头中markword紧接着的就是能够表明对象类型的一串数据,为什么说是指针呢,因为JVM不可能把类元数据在每个对象的对象头中都存一遍吧,对象头总够就8字节这么大(指针压缩后为4字节),而且类元信息是存放在方法区的,所以这里仅仅存放一个指针能够定位到类元数据即可。
数组对象
验证完普通对象,我们再来验证一下数组对象;我们刚才说了,如果是数组对象的话,对象头还需要额外一部分数据存储数组大小,那就试试呗。
public static void main(String[] args) throws Throwable {
Person[] persons = new Person[10];
//输出一下对象信息
System.out.println(persons);
//输出对象内存地址
System.out.println(Integer.toHexString((int) VM.current().addressOf(persons)));
//输出对象内存布局
System.out.println(ClassLayout.parseInstance(persons).toPrintable());
//随便加一行 在这打断点
System.out.println("断点");
}
输出结果如下(我关闭了指针压缩):
紧接着klass pointer的数据为0a 00 00 00,输出值为10和我们定义的数组大小一样,说明了数组对象的对象头中会存放数组的长度信息。
接着我们再通过hsdb通过内存地址来看一下该对象,输出的地址为151c4620,重复刚才的步骤打开hsdb,然后输入内存地址。
类型指针为ObjArrayKlass,后面应该紧跟着数组中每一个对象的指针,因为我只定义了数组,所以目前0-9都是null,于是我定义了persons[0] = new Person();得到如下结果:
至此,对象头的内容就全部讲完了,实例数据和对齐填充我们下一篇文章再讲吧,拜拜~!