本文最后更新于:2020年9月5日 晚上

JVM系列(二)-HotSpot虚拟机对象探秘

在我们了解完Java虚拟机的运行时数据区域之后,我们肯定想要进一步了解虚拟机内存中数据的其他细节,比如它们是【如何创建】、【如何布局】以及【如何访问】的。

接下来我们就以HotSpot虚拟机来讨论它在Java堆中对象分配、布局和访问的全过程。

1.1 对象的创建

当Java虚拟机遇到一条字节码new指令时:

  • 首先,将去检查这个指令的参数是否能在常量池中定位到一个类的【符号引用】,并且检查这个符号引用代表的类是否已被【加载】、【解析】和【初始化】过。如果没有,那么必须先执行相应的类加载过程。
  • 接着,在类加载检查通过后,接下来虚拟机将为新生对象【分配内存】。对象所需内存的大小在【类加载完成后】便可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
  • 然后,Java虚拟机还要对对象进行必要的设置。(如对象头等信息)

以上步骤完成后,从虚拟机角度来说,一个新的对象已经产生了。

注意:对于Java程序的视角来说,对象创建才刚刚开始,因为构造函数,即Class文件中的()方法还没有执行,所有的字段都是零值。一般来说,new关键字对应的字节码指令分别为new指令和invokespecial指令。只有new指令后接着执行()方法,按照我们的意图对对象进行初始化后,一个真正的对象才算是完全构造出来。

Java堆给对象分配内存的方式:

指针碰撞:

假设Java堆内存是规整的,所有用过的内存放一边,空闲的方另外一边,中间放着一个指针作为分界点的指示器。
那么分配内存时就只需将指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式就称为指针碰撞。

空闲列表:

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了。
虚拟机就必须维护一个列表,记录上哪些内存是可用的。
在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。

注:选择哪种方式是由【Java堆是否规整】决定,而Java堆是否规整又由所采用的【垃圾收集器】是都带有【压缩整理功能】决定,因此:使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

思考

对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。这种问题如何解决?

两种可选方案:

  1. 一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用【CAS配上失败重试】的方式保证更新操作的原子性。
  2. 另一种是把内存分配的动作【按照线程划分】在不同的空间之中进行,即每个线程在Java堆中预先分配一小块儿内存,称为本地线程分配缓冲(Thread LocalAllocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

1.2 对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:

1.对象头
2.实例数据
3.对齐补充

对象头

对象头部分包含两类信息:

  • 第一类【存储对象自身的运行时数据】。如哈希码(HashCode)、GC分代年龄、锁状态以及偏向线程的ID等。这部分数据数据长度在32位和64位的虚拟机(未开启指针压缩)中分别为32bit和64bit,官方称为’Mark word’。

补充:对象须要存储的执行时的数据许多。已经超出了32位、64位Bitmap结构所能记录的限度,可是对象头信息是【与对象自身定义的数据无关的额外存储成本】,考虑到虚拟机的空间效率。Mark work被设计成一个【动态定义的数据结构】,以便在极小空间内存储尽可能多的信息,他会依据对象状态复用自己的存储空间。

HotSpot中Mark Word的结构:

  • 另一部分是【类型指针】,即对象指向它的类型元数据的指针,Java虚拟机通常通过这个指针来确定该对象是哪个类的实例。

注意:并不是所有的虚拟机实现都必须在对象数据上保留类型指针,简言之,查找对象的元数据信息并不一定要经过对象本身。

补充:同时如果对象是一个Java数组,在对象头中还应该有一块用于【记录数组长度的数据】,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

实例数据

实例数据存储的是对象【真正的有效数据】,即各个类型字段的内容。无论是子类中定义的,还是从父类继承下来的都需要记录。这部分数据的【存储顺序】受到【虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)】以及字段在类中的定义顺序的影响。

HotSpot默认的分配策略是将相同宽度的字段分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对齐补充

这部分数据不是必然存在的,因为对象的大小总是8字节的整数倍,该数据仅用于补齐实例数据部分不足整数倍的部分,充当占位符的作用。

1.3 对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的具体对象。

reference类型只是一个指向对象的引用,并没有定义它是通过什么方式去定位、访问到堆中对象的具体位置。

所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有两种:

1.句柄
2.直接指针

句柄

此方式可能在堆中划分出一块内存作为句柄池,reference中存储的是对象的句柄地址。句柄中包含各【对象的实例数据】和【类型数据】各自的地址信息。

此方式的好处是reference中保存的是稳定的句柄的地址,因为对象的移动在GC过程中是非常普遍的行为,我们使用这种方式只会改变句柄中的实例数据指针,而reference本身不需要被修改。带来的缺点就是访问效率受影响。

指针

即对象引用中保存的直接的对象地址,但Java对中对象的内存布局就必须要考虑如何放置访问类型数据的相关信息。

该方式的优点是节省了一次指针定位的开销,访问速度快。缺点是当对象地址发生变化是引用中保存的数据也需要变化。

在HotSpot中主要使用第二种方式进行对象访问。(但有例外Shenandoah收集器会有一次额外的转发)

参考图书:《深入理解Java虚拟机-Java高级特性与最佳实战》第3版

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!博客中转载文章会注明出处,若有版权问题,请及时与我联系!谢谢!

JVM系列(三)-实战:OOM异常 上一篇
JVM系列(一)-运行时数据区域 下一篇