JVM将内存划分为程序计数器(Program Counter Register)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、堆(Heap)以及方法区(Method Area)。作为开发者,我们最关注的是虚拟机栈以及堆这两块区域。虚拟机栈所需要的内存空间在编译期间即可明确,而堆内存所需要的空间需要在运行时才可确定。堆内存用于存放我们在程序中创建的对象,一旦没有足够的空间用于存放这些对象,即会抛出OutOfMemoryError异常。在这种情况下,我们可以调整堆内存的大小,或者对程序进行优化。当我们采用后一种方式时,我们需要了解一个对象是如何占据堆内存空间的,或者说是了解一个对象是由哪些部分组成的。

对象的内存布局

HotSpot虚拟机中,对象在内存中的布局划分为3个区域:对象头(Header),实例数据(Instance Data)以及对齐填充(Padding)。

对象头

HotSpot虚拟机对象的对象头一般包含两部分信息,第一部分用于存储对象自身的运行时数据,例如HashCodeGC分代年龄等信息。在32位和64位的JVM中,这部分数据分别为32bit64bit,官方称这部分数据为Mark Word

另一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。在32JVM中,指针的长度为32bit,在未开启压缩指针的64JVM中,该指针的长度为64bit,如果开启压缩指针,那么为32bit

之前提到对象头一般包含两部分信息,这是因为如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,并且这部分数据也随着JVM位数的不同而不同:32位的JVM上,该区域的长度为32bit,在64位未开启压缩指针的JVM中,这部分数据的长度为64bit,否则为32bit

实例数据

实例数据部分是对象真正存储有效信息的区域,存储了代码中定义的各种字段的内容,包括从父类继承下来的字段和子类中定义的字段。

实例数据紧随对象头,为了提高存储空间的利用率,这部分数据的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略如下所示。

  • doubles & longs
  • ints & floats
  • shorts & chars
  • booleans & bytes
  • references

可以看出,相同宽度的字段总是被分配到一起,并且在满足这个条件的前提下,在父类中定义的字段会出现在子类字段之前。

对齐填充

对齐填充这部分不是必须存在的,这部分仅仅是起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,因此当对象实例部分数据没有对齐时,就需要对剩余的部分进行填充。

度量工具

JDK 5开始, Java提供了Instrumentation API,通过getObjectSize方法来获取对象的大小,但是getObjectSize方法存在如下两个缺陷,不能准确的计算对象的大小。

  • 不能直接调用getObjectSize方法,而是需要通过-javaagent参数指定一个特定的jar文件(包含Instrumentation代理)来启动Instrumentation的代理程序。
  • 如果一个对象中包含别的对象的引用,那么getObjectSize方法仅仅计算引用的大小,而不包括引用所指向的对象的大小。

由于上述两个缺陷,我们不能直接调用getObjectSize方法来计算对象的大小,但是利用Java的反射机制,我们可以完整的计算一个对象的大小。我们解析对象的每一个Field(使用getDeclaredFields),并遵从如下规则。

  • Field是基本数据类型时,我们不再计算该Field的大小,因为该Field的大小已经被包含在getObjectSize方法的返回值中。
  • Field是静态数据或者是常量池中包含的数据,那么我们忽略这些数据,因为这些数据并不是属于对象的。
  • 我们需要保存我们已经计算过的对象的引用,防止重复计算。
  • 如果对象所属的类存在父类,还需要计算父类中成员变量的大小。

jvm-obj-size 是以上思想的具体实现,jvm-obj-size 实现了基本的获取对象本身的大小(sizeOf,仅包含引用本身),以及获取对象真正的大小(fullSizeOf,包含引用所指向的对象)的方法,具体用法以及测试代码详见README文件。

参考