Title

基于hotspot的堆的对象分配,布局和访问

内容摘抄自《深入理解Java虚拟机 第三版》

1.1对象创建

Java是一面向对象的编程语言。而对象的创通常(例外:复制,反序列化)仅仅是一个new关键字而已。

对象创建过程

当Java虚拟机遇到一条字节码new指令时,首先检索这个指令的参数是否能在常量池中定位一个类的符号的引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化。如果没有,那个必须执行相应的类的加载过程

在类加载检查通过后,接下来虚拟机将为对象分配内存,对象所需的内存大小在类加载完成便可确认,为对象分配空间的任务实际上就是将堆中的一块确定大小的内存划分出来。

假设堆中内存时绝对规整的,所有被使用过的内存放一边,所有空闲的内存放另一边,中间放个一个指针作为分解指示器,所以分配内存就仅仅是将指针向空闲的内存移动一段与对象大小相等的距离,这种分配称为指针碰撞(Bump The Point)。

如果堆中内存是不规整的,已被使用的内存和未被使用过的内存相互交错在一起,那就没把法进行简单的指针碰撞了,这时虚拟机就维护了一张列表,记录那些内存块是可用的,分配的时候从列表中找到一块足够大的空间划分给实例对象,并更新列表记录,这种分配称为空闲列表(Free List)

选择那种分配方式是由Java堆是否规整决定的,而Java堆是否规整又由虚拟机采用的垃圾收集器是否带有空闲压缩算法整理(Compact)决定的。

因此,使用带有Serial,ParNew等带有压缩整理过程的收集器时,系统采用指针碰撞,既简单,有高效

使用CMS这种基于清除(Sweep)的算法的收集器时,理论上只能采用空闲列表来分配内存(理论上,但是实际上CMS为了多数情况下分配内存更快,设计了Linear Allocation Buffer的分配缓冲区,通过空闲列表拿到一大块缓冲区之后,在缓冲里面任然可以使用指针碰撞来分配)

除了如何划分空间外,还有另一个问题需要考虑:对象创建在虚拟机是非常频繁的行为,仅仅修改一个指针的位置,在并发下并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针。而解决这个问题又两个方案

  • 对分配空间的操作进行同步处理

    实际上虚拟机采用CAS(compare and swap)配上失败重试方式保证原子性

  • 把内存分配的工作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预选分配一块小内存,称为本地内存分配缓冲(Thread Local Allocation Buffer,TLAB),那个线程分配内存,就在那个线程本地缓冲区分配,只有缓冲区用完了,分配新缓冲区时才同步。虚拟机是否使用TLAB,由-XX:+/-UseTLAB参数决定

内存分配完成后,虚拟机必须将分配到的内存(但不包括对象头)都初始化为零值,如果使用了THAB的话,这项工作可提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接运行,使程序能访问这些字段的的数据类对应的零值。

2021-09-07更
接下来,Java虚拟机还要对对象进行必要设置,例如这个对象时那个类的实例,如何才能找到这个类的元数据,对象的哈希码(实际上对象的哈希码会延后到真正调用hashCode()方法时才计算),对象的GC分代年龄等信息。这些信息放在对象的对象头(Object Header)中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同设置方式。

上面工作都完成时,从虚拟机看来,一个新对象已经产生了。但是从Java程序来看,对象才刚刚开始构造函数,几Class文件中的<Init>()方法还没有执行,所有的字段默认都是零值,对象所需的其他资源和状态信息也没有按照预定的意图构造好。一般来说,new指令之后接着执行<Init>()方法,去进行初始化,这样一个对象才算被完整构造出。

image-20210907234019406

image-20210907234125463

image-20210907234410583

1.2 对象的内存布局

在Hotspot虚拟机中,对象在堆中的存储布局可以分为三个部分:对象头(Object header),实例数据(Instance Data)和对齐填充(Padding)

对象头的信息又分为两类。第一类适用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超过了32,64位的BitMap结构所能记录的最大限度,但是对象头的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象的哈希码,4个比特用于存储对象的分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定,重量级锁定,GC标记,可偏向)下对象的存储内容如下:

存储内容 标志位 状态
对象哈希码,对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
纸箱重量级锁指针 10 膨胀(重量级锁)
空,不需要记录信息 11 GC标记
偏向线程ID,偏向时间戳,对象分代年龄 01 可偏向

对象头的另外一部分是类型指针,即对象指向它的数据类型元数据的指针,Java虚拟机通过这个指针来确定该对象是那一个类的实例。并不是所有虚拟机实现都必须在对象数据上保留指针类型,换句话说,查找元数据信息并不一定经过对象本身。此外,如果对象是一个Java数组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通的Java对象的元数据确定Java对象的大小,但是如果数据长度是不确定的,将无法通过元数据中的信息推断数组的大小。

接下来实例数据部分是对象的真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容,无论是父类继承的,还是在子类中定义的字段都必须记录。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldAllocationStyle参数)和字段在Java源码定义的顺序的影响。HotSpot默认的分配顺序为longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Points),以上的默认分配策略可以看到,相同宽度的字段总是被分配在一起,在满足这个前提下,父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数的值为true(默认就为true),那么子类中较宽的字段也允许插入父类变量的空隙之中,以便节省空间。

对象的第三部分是对齐补充,这并不是必然存在的,也没有特殊含义,它仅仅起着占位符作用。由于HotSpt虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,换句话说,任何对象的大小必须是8字节的整数倍。对象头部分已经被设计成证号为8字节整数倍(1或者2倍),因此如果对象的实例数据部分没有对齐的话,就需要对齐补充。

2021-09-07 更

1.3 对象的定位访问

创建对象自然是为了后续使用对象。Java程序通过栈上的Reference来操作堆上的具体数据。由于Reference类型在《Java虚拟机规范》只定义了它是指向对象的引用,并没有规定这个引用通过什么方式定位,访问到堆中具体位置,所以对象的访问也是有虚拟机的实现而定的,主流实现有使用句柄和直接引用指针两种:

  • 如果使用句柄访问的话,Java堆可能划分出一块内存作为句柄池,reference中存储的的就是对象的句柄池地址,而句柄池包含了实例对象的数据与类型数据的相应地址信息如下图 1-3-1
  • 如果使用直接指针访问的话,Java堆中对象就要考虑如何放置类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销如图1-3-2

image-20210909003321902

​ 图1-3-1

image-20210909003535731

​ 图1-3-2

两种各有优劣:

  • 句柄访问的最大好处就是reference存储的是句柄地址,在对象被移动(垃圾回收时移动对象行为很普遍)时只会改变句柄的实例数据指针,而reference本身不需要改变
  • 使用直接指针访问最大好处是快,他节省了一次指针定位的时间开销,由于对象访问在Java身份频繁,这类节省积少成多也是一种极为可观的执行成本。

就HotSpot而言主要采用直接指针访问对象(也有例外,如果使用了Shenandoah收集器的话也有一次额外转发),但是在各种语言,框架中使用句柄访问也十分普遍
2021-09-09

posted @ 2021-09-07 00:10  apeGcWell  阅读(115)  评论(0)    收藏  举报