深入理解Java对象内存模型&JVM内存模型
前言
首先,在前几篇的文章中,我们了解了运行时数据区的设计由来,并相继引出了(堆、方法区)线程共享、(虚拟机栈【线程栈】、本地方法栈、程序计数器)线程私有等相关概念,那堆、方法区、线程栈之间的相互指向是怎样的呢?
我们可以很清楚的知道线程栈 --> 堆【方法内部有一对象,地址指向堆】;方法区–> 堆【方法区存放的是静态变量、常量、即时编译的代码,那在类文件中创建了静态对象,方法区指向堆】;堆 -->方法区,在堆中存放的是具体数据,我们是如何知道由哪个类创建的,这就和Java对象内存模型有关系了,接下来一起深入理解Java对象内存模型吧!
Java对象内存模型
Java对象内部布局:
上面我们遗留了一个问题,堆是如何指向方法区,在我们Java对象内存布局中,可以清晰知道通过类型指针指向对象对应的类元数据的内存地址,进而找到相关信息。
数据是在内存和CPU交互的,通过寄存器计算,而数据在Java对象中是大端存储还是小端存储呢?大端和小端存储的好处各是什么?
如:int a =1
小端存储优点:如果a是long类型,小端存储下,转化为int类型时,高位部分可以直接截掉;便于数据之间的类型转换!
大端存储优点:便于数据类型的符号判断,因为最低地址位数据即为符号位,可以直接判断数据的正负号,Java中使用大端存储!
/**
* 判断当前环境字节是大端还是小端字节序存储
* Little-Endian 高位字节在前,低位字节在后。
* Big-Endian 低位字节在前,高位字节在后。
* 在x86的计算机中,一般采用的是小端字节序
*/
public class EndianTest {
public static void main(String[] args) {
Unsafe unsafe = EndianTest.getUnsafe();
long a = unsafe.allocateMemory(8);
try {
unsafe.putLong(a, 0x0102030405060708L);
byte b = unsafe.getByte(a);
ByteOrder byteOrder;
switch (b) {
case 0x01: byteOrder = ByteOrder.BIG_ENDIAN; break;
case 0x08: byteOrder = ByteOrder.LITTLE_ENDIAN; break;
default:
byteOrder = null;
}
System.out.println(byteOrder);
} finally {
unsafe.freeMemory(a);
}
}
public static Unsafe getUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
内存模型设计之klass Pointer
句柄池访问:
使用句柄池访问对象,会在堆中开辟一块内存作为句柄池句柄中存储了对象实例数据的内存地址,访问类型数据的内存地址(类信息、方法类型信息),对象实例数据一般在heap中开辟,类型数据一般存储在方法区中!
优点:reference存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变
缺点:增加了一次指针定位时间开销
直接对象访问:
直接指针访问方式指reference中直接储存对象在heap中的内存地址,但对应的类型数据访问地址需要 在实例中存储。
优点 :节省了一次指针定位的开销。
缺点 :在对象被移动时(如进行GC后的内存重新排列),reference本身需要被修改
直接访问比句柄访问快一倍,而在Java内存布局模型中,Java对象采用的是直接对象访问的方式去做的
内存模型设计之指针压缩
案例代码:
public class JOLSample {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println();
A a = new A();
System.out.println(a.hashCode());
ClassLayout layout2 = ClassLayout.parseInstance(a);
System.out.println(layout2.toPrintable());
}
// ‐XX:+UseCompressedOops 默认开启的压缩所有指针
// ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
// Oops : Ordinary Object Pointers
public static class A {
//8B mark word
//4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8B
int id; //4B
String name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
byte b; //1B
Object o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
}
}
从上文的Java对象内存布局中,我们知道引用类型是占8个字节,但在jdk1.6后默认开启了指针压缩,指针压缩,压缩是对应的引用地址存储大小,那么思考一个问题,为什么要引入指针压缩这项技术呢?
①在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
②在JVM中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得JVM只用32位地址既可以支持更大的内存配置(小于等于32G)
③为了减少64位平台下内存的消耗,启用指针压缩
④堆内存小于4G时,不需要启用指针压缩,JVM会直接去除高32位地址,即使用低虚拟地址空间
⑤堆内存大于32G时,压缩指针会失效,会强制使用64位即8字节来对象寻址,这就会出现1的问题
32位系统的CPU 最大支持2^32 = 4G
如果是64位系统,最大支持 2^64, 但是对其填充是按照8字节进行填充,指针压缩可以理解为在32位系统在64位上面使用,因为32位系统的CPU寻址空间最大支持4G,对其填充*8 = 32G,这就是内存>32G指针压缩失效的原因
内存模型设计之对齐填充
对齐填充的意义是提高CPU方法数据的效率,主要针对会存在该实例对象数据跨内存地址区域存储的情况!64位机器下一次读取8字节
例如在没有对齐填充的情况下,内存地址存放情况如下:
因为处理器只能0x00-0x07,0x08-0x0F这样读取数据,所以当我们想获取这个long类型的数据时,处理器必须要读两次内存,第一次(0x00-0x07),第二次(0x08-0x0F),很明显第二次读取的时候,long型数据并不是完整的。
例如在对齐填充的情况下,内存地址存放情况如下:
现在处理器只需要直接一次读取(0x08-0x0F)的内存地址就可以获得我们想要的数据了。
到这为止,貌似对齐填充已经很完善了,但实则还是会有问题,如果说我们还想再存储一个布尔类型,是又重头开始8字节去存储,还是用别的什么方式呢!按照我们所想的,放进0x07那个空位就很完美了,不然会产生空间碎片,而在HotSpot源码中就有这一策略:
当我们的策略为0时,这个时候我们的排序是 基本类型 > 填充字段 > 引用类型
当我们策略为1时,引用类型 > 基本类型 > 填充字段
策略为2时,父类中的引用类型跟子类中的引用类型放在一起 父类采用策略0 子类采用策略1
这样操作可以降低空间的开销 ,
JVM内存模型
总图:
在深入理解运行时数据区文章中,依次介绍了堆、线程栈、方法区、本地方法栈、程序计数器等相关概念作用。运行时数据区是一种规范,而JVM内存模式是对该规范的实现。其主要实现是针对线程共享而言的,那我们来看另一幅图:
我们知道98%的对象都是朝生夕死的,而我们触发GC种类有部分partialGC和FullGC,部分GC分为YoungGC和OldGC,首先可以暂且抛开部看这个内存模型,我们可以思考,如果我们堆总共只有一个大的区,这样能行嘛,这样会导致一个问题,一直存一直存,满了直接触发FullGC,这样垃圾收集时间会较长,会STW,影响用户线程,会造成卡顿。这时我们想到了可以分区,可以分成Old区和Young区,但为什么又将Young区分成了Eden区、s0区、s1区
Young区的划分是为了减少GC的悲观策略【进入old区】,在Java对象内存布局中,在对象头中有分代年龄的标记,首先对象是先被分配到eden区的,如果经过一次youngGC后,存活下来了,分代年龄+1,分代年龄到15就会被移动到老年代
为什么要有两个survivor区,假设只有一个的,我们知道eden区满了后,存活对象会被放入s0区,但是在s0区中的对象不一定是连续放的,会存在空间碎片问题,如果有另一个survivor区的话,可以互相倒腾,避免产生空间碎片!注意不一定是一样大小!
悲观策略如何产生,首先我们知道大对象会直接进入老年代;在jdk1.8时,survivor区所有年龄对象总和大于50%直接进入老年代;
什么时候会触发Full GC, Full GC是针对 元空间 + Old区 + Young区:
①自己手动调用System.gc();
②Meta Space区域空间不足
③之前每次晋升的对象的平均大小 > 老年代剩余空间 //JVM每次有对象晋升时,都会做相应计算统计
④Young GC之后,存活对象超过了老年代所剩空间大小
总结
看到最后,想必大家都理解了本文的所有内容,接下来大家跟着我的总结,一起回顾吧!
从最开始的线程栈、堆、方法区的指向,我们产生了这么一个问题,堆 --> 方法区,从而引出了Java对象内存布局的概念,而它则是有对象头、类型指针、实例数据,对齐填充等组成,在图片上解释了他们的各个区域是干什么的,作用是什么。数据和CPU交互,是如何存储的,从而引出了大端存储、小端存储及其特点,知道了Java对象是大端存储。
根据对象内存模型之Class Pointer引出了句柄池访问、直接对象访问的特点及优缺点,做了相应的图解根据Java对象内存模型证明了Java对象中是用直接对象访问方式进行的,效率比句柄池高一倍。
根据对象内存模型之指针压缩,我们从5点解释了为什么要使用指针压缩,并在最后解释了为什么超过32G就失效了。
根据对象内存模型之对齐填充,首先从未对齐填充图介绍了它们的是如何访问的,在64位机器下,是按8字节读取的,没有对齐填充会产生什么问题,满足了对齐填充又会产生什么问题,会产生浪费问题,有三种策略去解决了空间浪费问题!
JVM内存模型,首先给出了一张整体图,同时从堆和非堆方面去设计一幅图,紧接着抛开这幅设计不看,一步步自己去推导不这样会产生什么问题,提出相应问题并解决!