10、JVM 实战 - 对象的实例化、内存布局与访问定位

1、对象的实例化

创建对象的方式:

1、 new:最常见的方式;

  • 变形:xxx的静态方法
  • xxxBuilder/xxxFactory的静态方法

2、 Class的newInstance:JDK9标记过时,反射的方式,只能调用空参的构造器,权限必须是public;

3、 Constructor的newInstance:反射的方式,可以调用空参,带参的构造器,权限没有要求;

4、 使用clone:不调用任何构造器,当前类需要实现Cloneable接口,实现clone方法;

5、 使用反序列化:从文件、网络等获取一个对象的二进制流;

6、 第三方库Objenesis;

创建对象的步骤:

 

1、 判断对象对应的类是否被常量池加载;

  • 当虚拟机遇到一条字节码new指令时。首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载解析初始化过。如果没有,在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key值进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常。

2、 (类加载通过后)为对象分配内存;

  • 首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。

  • 如果Java堆内存中不规整,虚拟机就必须维护一个列表,记录哪些内存可用,哪些不可用。分配的时候在列表中找一个足够大的空间分配,然后更新列表。这种分配方式叫空闲列表(Free List)。→标记-清除算法。

  • 假设Java 堆中内存是规整的 ,所有被使用过的内存放在一边,空闲的内存放在另一边,中间放一个指针作为分界点指示器。那么内存分配就是指针指向空闲的方向,挪动一段与对象大小相等的距离。指针碰撞(Bump The Pointer)。→标记-清除-压缩算法。

  • 选择哪种由Java堆是否规整决定,Java堆是否规整由所采用的的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。

  • 当使用Serial,ParNew等带有压缩整理过程的收集器,指针碰撞简单高效;

  • 当使用CMS基于清除(Sweep)算法收集器时,只能采用空闲列表来分配内存;(CMS为了能在多数情况下分配内存更快,设计了一个Linear Allocatioin Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区后,在它里面仍可使用指针碰撞方式分配)

3、 处理并发安全问题;

  • 对象创建是非常频繁的行为,还需要考虑并发情况下,仅仅修改一个指针所指向的位置也是不安全的,例如正在给对象A分配内存,指针还未修改,对象B又使用原来的指针分配内存。解决问题有两种可选方案:CAS同步处理、本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)

  • 对分配内存空间的动作进行同步处理。实际上虚拟机采取CAS配上失败重试的方式保证更新操作的原子性。

  • 把内存分配的动作按照线程划分到不同的空间中进行,每个线程在Java堆中,预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。

  • 虚拟机是否使用TLAB,可以通过-XX: +/-UseTLAB参数来设定。

4、 初始化分配到的空间;

  • 内存分配完成后,虚拟机将分配到的内存空间(不包括对象头)都初始化为零值。如果使用了TLAB,这个工作可以提前到TLAB分配时进行。
  • 这步操作保证对象的实例字段在Java代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值。

5、 设置对象的对象头;

  • 接下来Java虚拟机还要对对象进行必要的设置,例如对象是哪个类的实例、如何才能找到类的元数据信息,对象的哈希码(实际上对象的HashCode会延后真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放到对象的对象头(Object Header)。

6、 执行init方法进行初始化;

  • 上面工作完成后,从虚拟机角度来说,一个新的对象已经产生了,但是从Java程序的视角来说,对象创建才刚刚开始,对象的构造方法(Class文件中init()方法)还未执行,所有字段都是默认的零值。new指令之后接着执行init方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来。

 

2、对象的内存布局

 

对象的内存布局主要分为3部分:

1、对象头

  • 这部分数据的长度在32位和64位的虚拟机(未开启指针压缩中)分别是32bit和64bit,【Mark Word】运行时元数据

  • 哈希值

  • GC分代年龄

  • 锁状态标志

  • 线程持有的锁

  • 偏向线程ID

  • 偏向时间戳

  • 对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,根据对象状态的不同,Markword可以复用自己的空间。

  • 类型指针(Klass Word):即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确认该对象属于哪个类的实例。

说明:如果是数组,还需要记录数组的长度。

 

2、实例数据

对象的实例数据部分,是对象的真正存储的有效信息,即我们在程序代码中定义的各种类型的字段内容,无论是父类继承下来,还是子类中定义的字段都要记录下来。

1、 这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响;
2、 分配策略参数-XX:FieldsAllocationStyle;
3、 HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(OrdinaryObjectPointers);
4、 从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放;
5、 在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前;
6、 如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认也是true),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省空间;

3、对齐填充

  • 仅起占位符作用,因为HotSpot虚拟机自动内存管理系统,要对对象的起始地址要求8字节的整数倍。
  • 对象头已经精心设计为8字节的整数倍,1倍或者2倍
  • 对象实例数据部分若未对齐,需对齐填充

3、对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式分为两种:

  • 直接指针(HotSpot 使用):指向对象,代表一个对象在内存中的起始地址。
  • 句柄:可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

1、使用句柄

使用句柄,Java堆中将划出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄包含对象实例数据与类型数据各自的具体信息。

 

2、直接指针

使用指针,reference中存储的直接就是对象地址,如果访问对象本身,不需要多一次的间接访问的开销。

 

两种方式各有优势:

  • 使用句柄最大好处是 reference 中存放的是稳定句柄地址,在对象被移动(垃圾搜集时会产生)时只改变句柄中实例数据指针,reference本身不用改变。
  • 使用指针最大好处就是速度快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,所以积少成多也是一项可观的执行成本。
  • HotSpot主要是用指针,进行对象访问(例外情况,如果使用Shenandoah收集器的话,也会有一次额外的转发)。

4、直接内存

  • 不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域

  • 直接内存是在java堆外的,直接向系统申请的内存区间

  • 来源于NIO(non-blocking IO),通过存在堆中的DirectByteBuffer操作Native内存

  • 通常,访问直接内存的速度会优于Java堆,即读写性能高

  • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存

  • 但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存

  • 也可能导致OOM异常

  • 直接内存在堆外,所以大小不受限于-Xmx指定的最大堆大小

  • 但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存

  • 缺点:

  • 分配回收成本较高

  • 不受JVM内存回收管理

  • 直接内存大小可以通过MaxDirectMemorySize设置。

  • 如果不指定,默认与堆的最大值-Xmx参数值一致。