一个Java对象在内存中大的布局非为三个内容,对象头,实例数据,对齐填充。
在高并发场景下,可能会产生很多对象,那么我们就需要计算对象的内存大小,好知道内存配置到底需要配置多大。因此,需要了解Java对象内存布局。
实例数据: 每一个Java对象的大小,并不仅仅取决于所谓的实例部分,还有对象头和对齐填充的大小。实例数据包含了对象的所有成员变量,即类的属性,大小由各个变量类型决定,不同类型的属性字节数也不同,相同字节数的是并齐储存的。
而另一个影响对象大小的就是对象头。
对象头 - Class Pointer: 对象头中Class Pointer和操作系统的位数有关的。Class Pointer就是指向方法区对应对象所对应的元数据的一个内存地址。
对象头 - Length: 除此之外,如果这个对象是一个数组对象,对象头中还会有一个4字节的专门用来标记数组长度这一块的存在。
对象头 - Mark Word: 对象头中还有Mark Word,如果学习垃圾回收,需要去统计每一次垃圾回收的对象的一个年龄,这个分代年龄就是在Mark Word中储存的;还有对象的哈希码、以及并发状态下的锁的状态记录都是在Mark Word中储存的。Mark Word中记录一些列的标记位(哈希码、分代年龄、锁状态标志等),记录的这些东西实际上就是我们运行时数据信息。
对齐填充: 为了保证对象的大小为8字节的整数倍。将对象头和实例数据的大小相加,以8为倍数向上补齐就是这个对象的大小。即:对象头+实例数据=30,则对齐填充,向上取8的倍数进行补齐,就是32,这个对象大小就是32。
因为Java是一门面向对象的语言,所以对于内存的结构这方面,一定需要了解更加深层的东西。从查看Java对象对象整体结构信息入手。
使用JOL工具类查看对象整体结构信息。引入依赖,RELEASE版本即可,案例当前使用的RELEASE版本对应是0.17。
<dependency>
<groupId>org.openjdk.jolgroupId>
<artifactId>jol-coreartifactId>
<version>RELEASEversion>
dependency>
写一个公共输出类
import org.openjdk.jol.info.ClassLayout;
public class Worker {
private Integer id;
private String username;
private String password;
public Integer getId() {
return id;
}
public String getPassword() {
return password;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setId(Integer id) {
this.id = id;
}
@Override
public String toString() {
return super.toString();
}
public static void printf(Worker worker) {
// JOL工具类查看对象的整体结构信息
System.out.println(ClassLayout.parseInstance(worker).toPrintable());
}
}
定义测试类测试打印输出
public class Test {
public static void main(String[] args) {
Worker work = new Worker();
// 输入信息
System.out.println(work.hashCode());
System.out.println(work);
// 输出信息
Worker.printf(work);
}
}
输出内容如下:
1198108795
com.haokai.common.jvm.Worker@4769b07b
com.haokai.common.jvm.Worker object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000004769b07b01 (hash: 0x4769b07b; age: 0)
8 4 (object header: class) 0xf800c143
12 4 java.lang.Integer Worker.id null
16 4 java.lang.String Worker.username null
20 4 java.lang.String Worker.password null
Instance size: 24 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
上述结果:
1198108795:表示哈希码
OFF:表示偏移量
SZ:占用的字节量的大小
TYPE DESCRIPTION:类型描述
VALUE:值
object header:对象头
Space losses:空间损失(内部0字节+外部0字节=总计0字节)
一个数据从内存当中过来的时候,也就是从CPU传过来的时候,一般会放在所谓的寄存器里面,然后每个寄存器都是32个字节,在一个寄存器里面,右边通常保持低位,左边属于高位,如果说寄存器中的高位和内存中的高位地址相对应,那么这种存储方式就是小端储存的(桉顺序去储存来的),大部分的处理器都是按顺序来的,属于小段存储,少部分是反着的,那么就是大端储存。
小端存储:便于数据之间的类型转换,例如:long类型转换为int类型时,高地址部分的数据可以直接截掉。
大端存储:便于数据类型的符号判断,因为最低地址位数据即为符号位,可以直接判断数据的正负号。
将上面输出的哈希码转换为16进制,得出4769b07b。在线进制转换网址:https://tool.oschina.net/hexconvert
可以看到JVM的值是和我们计算出来的hashcode的值反过来的,因此它是大端存储。
大部分的情况下习惯用小端储存,因为小端储存是便于数据转换的。
比如说long类型转换为int类型的时候,实际上是做精确度的降低,也就是精确度的损失,比如原来存的16进制如下:
高位:10000000
低位:01000000
当转换成int的时候,直接舍弃调高位。
而大端储存的优势是,便于数据类型的符号判断,也就是说最低的地址位就可以判断成为符号位,0就是正数,1就是负数,而我们所谓的hashcode的值它是确定的,是不需要做类型转换的,那么这个时候,一般情况下,包括Java中的变量都是采用小端储存的方式,但是Mark Word没有那么多类型转换的状态,它的定义比较单一,这个时候,JVM对于哈希码就要用到大端储存。
Java对象内存布局对象头中的Class Pointer是对象指向方法区中代表它元数据的指针,Pointer就是指针的意思,JVM会通过指针来确定我们到底是哪个类的一个实例。
我么创建出来的对象,肯定是为了使用,必然会去操作它,那么会用到局部变量表中的引用类型reference,引用类型操作堆上的一个对象。而引用类型reference在JVM中仅仅只是一个引用,它的实现有很多种,那么引用类型的对象如何定位到对象?
引用类型的对象如何定位到对象的常规方式有两种,一种叫句柄池访问对象,一种叫直接指针访问对象。
使用句柄访问对象,首先要了解句柄池是在堆中开辟的一块内存空间作为句柄池,句柄池中有很多很多的句柄,每个句柄之间又包含了:
①指向对象实例的指针:储存了对象实例数据,即属性值结构体的内存地址,对象实例数据一般也在heap中开辟。
②指向对象类型数据的指针:访问类型数据的内存地址(类信息,方法类型信息),对象类型数据一般储存在方法区中。
当局部变量表中的引用类型reference指向句柄池,并且定位到句柄池中间目标对象的句柄,然后再根据该句柄中的句柄信息,找到对象。
优点: reference存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变。
缺点: 增加了一次指针定位的时间开销。即会有两次IO操作。
直接指针访问方式,局部变量表中的引用类型reference指向对象实例数据,对象实例数据中需要有额外的内存开销需要用来存放指向对象类型数据的指针(来存放对象在方法区的类信息地址),也就是需要开辟出一个额外的空间来存放,这个空间就是Class Pointer。
优点: 节省了一次指针定位的开销。即只有一次IO操作,比句柄池访问对象快一倍。
缺点: 在对象被移动时(如进行GC后的内存重新排列),reference本身需要被修改。
hotsport用的就是直接指针访问的方式,这种方式是一种空间换时间的方式,而hotsport大部分场景下用的都是这种场景。
实例数据: 每一个Java对象的大小,并不仅仅取决于所谓的实例部分,还有对象头和对齐填充的大小。实例数据包含了对象的所有成员变量,即类的属性,大小由各个变量类型决定,不同类型的属性字节数也不同,相同字节数的是并齐储存的。
将上面的Worker类的属性增加一个引用对象的属性,private Worker worker,并新增对应的get、set方法。
import org.openjdk.jol.info.ClassLayout;
public class Worker {
private Worker worker;
private Integer id;
private String username;
private String password;
public Worker getWorker() {
return worker;
}
public void setWorker(Worker worker) {
this.worker = worker;
}
public Integer getId() {
return id;
}
public String getPassword() {
return password;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setId(Integer id) {
this.id = id;
}
@Override
public String toString() {
return super.toString();
}
public static void printf(Worker worker) {
// JOL工具类查看对象的整体结构信息
System.out.println(ClassLayout.parseInstance(worker).toPrintable());
}
}
然后进行输出,输出内容如下:
1198108795
com.haokai.common.jvm.Worker@4769b07b
com.haokai.common.jvm.Worker object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000004769b07b01 (hash: 0x4769b07b; age: 0)
8 4 (object header: class) 0xf800c143
12 4 com.haokai.common.jvm.Worker Worker.worker null
16 4 java.lang.Integer Worker.id null
20 4 java.lang.String Worker.username null
24 4 java.lang.String Worker.password null
28 4 (object alignment gap)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
可以看到Worker.worker是在偏移量12的位置输出的,可以看到它的value是null,它的size是4字节,而size只有4字节意味着它不可能将这个实例数据worker的真正的具体的所有里面包含的东西放到这个里面,它放的肯定是它的指针,也就是地址值。
为什么这样设计?如果直接放完整的实例数据会有什么问题?
如果放的是实例数据,空间会很冗余,比如A对象有这个Worker属性,所以会有worker实例,B对象也有,如果实例数据存储的内容是直接放实例数据就会造成重复存储,并造成空间浪费。所以它会使用存放指针的方式,可以看到指针所占空间只有4字节,指针会定位到具体的数据。这样无论多少个对象引到这个数据,只需要在引用对象中保留被引用对象的一个指针即可,这样都可以通过一次IO寻址,就能够定位到具体的数据,这里是增加时间的。这是一个时间换空间的思想。因为多了一次IO,但是可以规避掉大量不必要的重复存储,因此是可以容忍的。注意:使用指针的方式并不一定节省空间(比如你的这个引用的对象只使用一次),但是有可能会省空间。
可以看到类型指针所占空间只有4字节,而下面的java.lang.Integer Worker.id等也是4个字节,为什么?
这个就涉及到计算机原理,在32位的处理器中,每次能处理32 bit,也就是4字节,也就是说,如果是4个字节的时候,只需要处理1次就可以了,而64位的计算机怎么做?
在32位系统中,类型指针为4字节32位,在64位系统中类型指针为8字节64位,但是JVM会默认的开启指针压缩技术,也就是堆内存大于4G的时候,默认开启,小于4G,就默认32位,直接舍弃高位,只用低位,用一半,64的一半就是32位。
由于指针压缩所以上面输出结果中类型指针也是4字节32位。如果我们关闭指针压缩的话,就可以看到64位的类型指针了。
-- 开启指针压缩,前面-XX别删-,UseCompressedOops前面的+表示开启
-XX:+UseCompressedOops
-- 关闭指针压缩,前面-XX别删-,UseCompressedOops前面的-表示关闭
-XX:-UseCompressedOops
1198108795
com.haokai.common.jvm.Worker@4769b07b
com.haokai.common.jvm.Worker object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000004769b07b01 (hash: 0x4769b07b; age: 0)
8 8 (object header: class) 0x0000020c096e4028
16 8 com.haokai.common.jvm.Worker Worker.worker null
24 8 java.lang.Integer Worker.id null
32 8 java.lang.String Worker.username null
40 8 java.lang.String Worker.password null
Instance size: 48 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
可以看到类型容量变成了8字节,为什么要开启指针压缩?
①其实在堆中,32位的一个对象的一个引用指针是占4个字节的,64位引用占8个字节,也就是说64位的引用对象大小是32位的两倍,也就是说,64位的JVM实际上在支持更大的一个堆空间的同时,留给其它数据的空间会减少,从而会加快GC的发生,也就是会更加频繁的GC,所以我们要对它进行压缩,让它变成4个字节,这样占用的空间小了,GC的频率也会变小。
②可以降低CPU缓存的命中率。CPU对于64位对象引用是8字节,对比32位4字节是增大了,而CPU实际上是会缓存我们的普通对象指针的(普通对象指针就是常说的oop),从而因为缓存增大而降低oop缓存效率,因此为了保证能达到32位的性能,普通对象指针必须保留成32位的。如何用32位的oop引来更大的堆内存呢?就是将8字节进行压缩,将其变成4。
32位OS配合32位CPU,代表的就是它的寻址空间位是232=4G,所以说32位OS最大支持4G内存空间。如果说想要支持4G以上的,需要特殊的方法,比如特殊内核。但是用特殊内核的话,它的效率要比原生的64位要低。
这个时候,64位OS就是264=17179869184G,只是理论值,实际中不会用到这么大的内存,64位windows系统最大只支持128G。而当前主流主板只能加到16G。
类型指针压缩后,如果对象引用为4字节,是否意味着大小会限制在4G?
由于对象在分配的时候,是按8字节对齐的(为什么按8字节对齐?因为这样既可以被8整除,当然也可以被4整除,既满足32位CPU每次处理4字节,又满足64位CPU每次处理8字节),就是所谓的对齐填充。因此这个时候,可以给到的实际的空间就是4G×8字节的对齐填充=32G,这也是为什么超过32G不好用的原因。所以说超过32G,指针压缩不生效。所以我们通常在部署服务时,JVM内存不要超过32G,因为超过32G就无法开启指针压缩了。
字面上的意思就是补位,JVM管理内存64位都是以8字节对齐的,那么按照8字节对齐,是否就是按照4字节对齐的?
64位处理器每次处理8字节的数据,并且只能按照一种特殊的方式进行访问,要么是0-7,要么是8-F,这个是硬件造成的,没有办法,也就是按照固定的索引从0开始,每次访问8个偏移量。那么如果没有对齐填充就可能会存在数据跨内存地址区域存储的情况。32位也是一样的,只不过是每次读取4字节的数据。
在没有对齐填充的情况下,因为处理器只能0x00-0x07,0x08-0x0F这样读取数据,所以当我们想获取这个long型的数据时,处理器必须要读两次内存,第一次(0x00-0x07),第二次(0x08-0x0F),然后将两次的结果才能获得真正的数值。
在有对齐填充的情况下,处理器只需要直接一次读取(0x08-0x0F)的内存地址就可以获得我们想要的数据了。对齐填充存在的意义就是为了提高CPU访问数据的效率,这是一种以空间换时间的做法;虽然访问效率提高了(减少了内存访问次数),但是在0x07处产生了1bit的空间浪费。
那么后面如果有1bit的数据,比如又来了一个byte类型的,可以直接填充到0x07处,JVM就是这样做的。而且JVM不单单是空间换时间,它内心还有很多策略对其进行了优化。
因此,在一定程度上,我们可以去排列我们的数据,尽量用上中间的冗余空间,这个顺序并不是我们代码里一个类中定义属性的顺序。
Java对齐填充排序策略可以看OpenJDK的源码:https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/636cc78f0f74/src/share/vm/runtime/globals.hpp。
product(intx, FieldsAllocationStyle, 1,
"0 - type based with oops first, 1 - with oops last, "
"2 - oops in super and sub classes are together")
可以看到有三种排序分配策略,尽可能将对齐填充浪费的空间进行填充,保证我们的对象填充的尽可能地完善。默认是1。
取值为0时,引用类型会放在原始类型的前面,也就是说基本类型到填充的一个字段,也就是对齐填充,再到所谓的引用类型;
取值为1时,就是引用类型,再到基本类型,再到填充字段,这个也是默认的;
取值为2时,表示父类的引用类型和子类的在一起,父类采用0,子类采用1。
父类属性对齐填充有空位,子类的属性是否可以对父类的空位进行对齐填充?
答案是绝对不可以。网上很多说是可以的,包括深入了解JVM第三版也说是可以插得,实际上是不可以的,不信的可以去试试,根本不可能插进去。父类的数据和子类的数据本身就会有隔离,实际上在取数据的操作中,也很怕将两类数据混为一谈,一旦子类数据填充进父类数据,那就会导致取数据的时候含产生一些混乱,会加大CPU的一些消耗。
运行时数据区主要是逻辑上的实现,仅仅是一个规范,一个概念,而不是具体的实现方式。比如本地方法栈和虚拟机栈能不能共同用一个空间?能否不给本地方法栈一个单独的内存空间呢?
实际上有虚拟机正在使用的就是这样的操作。所以运行时数据区它只是是逻辑上存在的一个这样定义的概念,但物理层面上不存在将虚拟机栈和本地方法栈进行物理区分。
如何遵循这个规范,去落地实现呢?如何将方法区、堆、虚拟机栈、本地方法栈、程序计数器这些区域在真实的物理内存当中如何去进行落地实现的呢?
这就需要深入去了解下JVM内存模型。
JVM1:官网了解JVM;Java源文件运行过程、javac编译Java源文件、如何阅读.class文件、class文件结构格式说明、 javap反编译字节码文件;类加载机制、class文件加载方式
JVM2:类加载机制、class文件加载方式;类加载的过程:装载、链接、初始化、使用、卸载;类加载器、为什么类加载器要分层?JVM类加载机制的三种方式:全盘负责、父类委托、缓存机制;自定义类加载器
JVM3:图解类装载与运行时数据区,方法区,堆,运行时常量池,常量池分哪些?String s1 = new String创建了几个对象?初识栈帧,栈的特点,Java虚拟机栈,本地方法发栈,对象指向问题
JVM4:Java对象内存布局:对象头、实例数据、对齐填充;JOL查看Java对象信息;小端存储和大端存储,hashcode为什么用大端存储;句柄池访问对象、直接指针访问对象、指针压缩、对齐填充及排序
JVM5:JVM内存模型与运行时数据区的关系,堆为什么分区,分代年龄,Young区划分,Survivor区为什么分为S0和S1,如何理解各种GC:Partial GC、Full GC、Young GC
JVM6:JVM内存模型验证;使用visualvm查看JVM视图;Visual GC插件下载链接;模拟JVM常见错误,模拟堆内存溢出,模拟栈溢出,模拟方法区溢出
JVM7:垃圾回收是什么?从运行时数据区看垃圾回收到底回收哪块区域?垃圾回收如何去回收?垃圾回收策略,引用计数算法及循环引用问题,可达性分析算法