01
内存占用分析工具
工欲善其事,必先利其器。为了准确分析对象占用内存大小,我们将使用openJDK的JOL工具包。JOL全称为Java Object Layout,是分析JVM对象布局的工具,该工具大量使用了Unsafe、JVMTI来解码布局情况,所以分析结果是比较准确的。该工具使用起来也很方便,引入方式如下:
org.openjdk.jol
jol-core
0.8
具体的使用方式为:
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
…
System.out.println(ClassLayout.parseInstance(obj).toPrintable());//查看对象obj内部信息
System.out.println(GraphLayout.parseInstance(obj).toPrintable());//查看对象外部信息
System.out.println(GraphLayout.parseInstance(obj).totalSize());//获取对象obj总大小
本文中涉及到分析对象内存占用的内容,将均使用该工具进行。
02
Java对象格式
除了分析工具外,我们还需对Java对象的格式有初步的了解。对象存储在堆中,底层由C++代码定义,其结构大致可表示如下:
即,Java对象从整体上可以分为三个部分,对象头、实例数据和对齐填充。
对象头(Instance Header)
对象头是Java中对象都具有的属性,是JVM在编译和运行阶段读取的信息。总的来说,对象头包含如下三个部分:
第一部分被称为“Mark Word”,存储对象自身的运行时数据,如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳、对象分代年龄等。Mark Word是为了在极小的空间内存储尽量多的信息,因此被设计成一个非固定的数据结构,它会根据自己的状态复用自己的存储空间。这部分内容在32位机器和64位机器上分别占据4或8字节空间。
第二部分是类型指针(Klass Pointer),即对象指向它所属类(元数据)的指针,虚拟机通过该指针来确定这个对象是哪个类的实例。这部分内容受机器位数与是否开启“指针压缩”的影响可能占用4或8字节空间,稍后将结合实验对此做详细介绍。
第三部分是数组长度(length),需要注意的是该部分仅在数组对象中存在。JVM可以通过普通Java对象的元数据信息确定Java对象的大小,但无法从数组的元数据中确定数组的大小,因此数组长度被直接存储于该部分中,占用4字节空间。
Java对象头是理解JVM中对象存储方式最核心的部分,甚至是理解Java多线程、分代GC、锁等理论的基础,JVM底层的诸多实现细节均以此为出发点。但由于本文仅关心对象的内存占用,因此不过多展开,我们将在后续的文章中对此做进一步介绍。
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也就是我们在业务代码中所定义的各种类型的字段内容。在JVM中,对象的字段是由基本数据类型和引用类型组成的,其所占用的空间大小如下所示:
其中ref表示引用类型,它以特殊的方式(类似C指针)指向对象实体(具体的值),这类变量只是存储了一个内存地址,受机器位数与是否开启“指针压缩”的影响可能占用4或8字节空间,而实例数据部分的大小,实际上就是表格中这些字段类型的大小之和。需要特别说明的是,对于子类来说,从父类继承下来的内容也将被包含在实例数据部分中。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
HotSpot虚拟机会对类的属性按照如下优先级进行排列:长整型和双精度类型、整型和浮点型、字符和短整型、字节类型和布尔类型,最后是引用类型。从分配策略中可以看出,相同宽度的字段总是被分配到一起,且基本数据类型中更宽的字段会排在前面。在满足这个前提条件的情况下,父类中定义的变量在内存中的排列会出现在子类之前(我们将在下文实验中对此进行对比验证)。然而在实际的应用中,为了节省空间,CompactFields参数通常默认为开启,这使得子类之中较窄的属性也可能会插入到父类属性的空隙之中,从而影响对象内存大小。那么有的读者可能会疑惑,只是改变了字段的排列顺序,为什么会影响内存占用大小呢?这就要介绍对象结构的第三部分。
对齐填充(Padding)
对齐填充是底层CPU数据总线读取内存数据时的要求。通常CPU按照字单位读取,如果一个完整的数据体不做对齐处理,那么在内存中存储时,其地址极有可能横跨两个字。例如某数据块存储为2-5,而CPU按字读取,需要把0-3字块读取出来,再把4-7字块读出来,最后合并舍弃掉多余的部分。这种操作会很多且很频繁,但如果进行了对齐,则一次性即可取出目标数据,将会大大节省CPU资源。
HotSpot虚拟机中,默认的对齐位数是8,且与CPU架构无关,换句话说就是对象的大小必须是8字节的整数倍,因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。结合上文所介绍的内容,在某些子类继承父类的情况下,当CompactFields参数开启时,类属性的默认优先级排序被打乱,导致原本可能由padding填充的父类大间隙,改为部分由子类较窄的属性填充,padding占用空间减少从而影响对象占用内存的大小。
Tips
如何查看自己JVM环境的对齐位数?
可使用JMX中提供的HotSpot信息资源,完整代码如下(JDK 1.8):
package me.test;
import java.lang.management.ManagementFactory;
import com.sun.management.HotSpotDiagnosticMXBean;
public class HsTest {
public static void main(String[] args){
HotSpotDiagnosticMXBean hsdm =
ManagementFactory.getPlatformMXBean
(HotSpotDiagnosticMXBean.class);
System.out.println("当前环境的对齐位数:");
System.out.println(hsdm.getVMOption
("ObjectAlignmentInBytes").getValue());
}
}
输出结果:
当前环境的对齐位数:
8
接下来我们将通过多组实验向读者展示不同对象占用的空间大小,并进一步探讨可能影响对象占用大小的因素。
03
不同Java对象的实际占用空间
环境说明:运行环境为64位虚拟机;
JDK版本为1.8
实验1:基本数据类型与数组对象
首先,我们创建几个待分析的类对象实例,包含几个简单的基本数据类型与数组进行比较。
class A { }
class B {
private long s;
}
class C {
private int a;
private long d;
}
int[] aa = new int[3];
然后创建分析主函数:
public class Test {
public static void main(String[] args) {
A a = new A();
B b = new B();
C c = new C();
int[] aa = new int[3];
//查看对象内部信息`
System.out.println(ClassLayout.
parseInstance(a).toPrintable());
System.out.println(ClassLayout.
parseInstance(b).toPrintable());
System.out.println(ClassLayout.
parseInstance(c).toPrintable());
System.out.println(ClassLayout.
parseInstance(aa).toPrintable());
}
输出结果如下:
从分析结果中可看出:
A对象:A对象实际上是一个空对象,所以占据的内存空间就是对象头的大小。结合前文介绍,内存分布的第1、2行即为该对象头的Mark Word,从图中可看出占用大小(size)为8字节;第3行是A的类型指针,由于运行环境为64位虚拟机,且默认开启了“指针压缩”,故该部分占用4字节空间;目前大小为12字节,不符合8的整数倍对齐要求,所以在第4行添加了4字节的padding。最终A对象的总大小为8(Mark Word) + 4(类型指针) + 4(padding) = 16 bytes。
B对象:仅包含一个类型为long的属性,那么在A的基础上,我们可以轻易得出B的总大小为12(对象头) + 8(实例数据long) + 4(padding) = 24 bytes。但是细心的读者可能会发现,B的对齐填充并不像A那样在内存分布上排在最后,而是出现在对象头与实例数据之间,这是为什么呢?前文介绍过CPU按照字单位进行读取,B对象的对象头12 bytes,不是8的整数倍,为了防止属性s横跨两个字,所以先进行了内部padding,如下图所示。这样的情况在下文的类继承实验中将会更加显著。aa对象:是一个int数组对象,由于数组对象会在对象头中多一个数组长度存储部分,所以在aa对象的第4行存储该对象的长度,占用总大小为16(对象头)+3(arrLength) *4(int)+4(padding)=32 bytes。
C对象:包含一个int、一个long,所以大小为12(对象头)+4(实例数据int)+8(实例数据long)=24 bytes,刚好是8的整数倍,所以不需要对齐填充。
实验2:引用类型与指针压缩
引用类型:实际上是一个地址指针,32位机器上占用4字节,64位机器上占用8字节,但开启指针压缩后占用大小为4字节。在计算总占用内存时,除了计算引用类型的大小以外,还需计算该指针指向的对象大小。
指针压缩:64位虚拟机上,在JDK1.6以后,当堆内存小于32GB时,指针压缩默认为开启状态;若要关闭指针压缩,只需在运行时添加参数“-XX:-UseCompressedOops”。接下来我们将通过实验说明指针压缩如何影响对象的内存占用。
以如下对象为例:
class Person{
String name="Xiaoming";
boolean married = false;
long birthday = 731276007132L;
int age=25;
}
Person对象中包含了String这一引用类型的属性,所以这次添加了查看对象外部信息和对象总大小的函数,完整代码如下:
public class Test {
public static void main(String[] args) {
Person p=new Person();
//查看对象内部信息`
System.out.println("查看对象内部信息");
System.out.println(ClassLayout.
parseInstance(p).toPrintable());
//查看对象外部信息
System.out.println("查看对象外部信息");
System.out.println(GraphLayout.
parseInstance(p).toPrintable());
//获取对象总大小
System.out.println("获取对象总大小");
System.out.println("size : " +
GraphLayout.parseInstance(p).
totalSize());
}
}
64位虚拟机在默认情况下(即开启指针压缩),运行结果如下:
先看对象内部信息,开启指针压缩的情况下,对象头为8(Mark Word)+4(类型指针)=12 bytes;实例数据的大小为:4(age)+8(birthday)+1(married)+4(引用类型,name)=17 bytes;因此,对象本身大小为:12+17+3(padding)=32 bytes。Person对象自身的内存分布为:再看对象外部信息,计算引用类型字段的实际大小:“Xiaoming”,按照String对象的字段计算,对象头12 bytes,hash字段占用一个字的空间,即4 bytes,char[]是引用类型大小为4 bytes,共12+4+4+4(padding)=24 bytes,即上图中“外部信息”分析内容第2行所示。其中,char[]所引用的对象为数组类型,其大小为:12+4(length)+8(arrLength)
*2(char)=32 bytes,即上图中“外部信息”分析内容第3行所示。最终,开启指针压缩的情况下,Person对象占用内存的总大小为32+24+32=88 bytes。
顺带一提,String对象是Java中使用最频繁的对象之一,所以Java一直在不断地对String对象的实现进行优化,以便提升String对象的性能,在Java7/8中,String类的成员变量主要为char[]和hash。看下面这张图,一起了解一下String对象的优化过程。
接下来,我们通过在运行时添加参数“-XX:-UseCompressedOops”来关闭指针压缩,这时运行结果如下,大家可以将其与上个实验结果进行对比:
如前文所述,关闭指针压缩后,所有对象头中的类型指针由4 bytes变为8 bytes(第3和4行);Person的成员变量name是引用类型,其实质存储的是地址指针,因此也由4 bytes变为8 bytes(第9行),于是Person对象本身大小变为:8(Mark word)+8(类型指针)+8(birthday) + 4(age)+1 (married)+8(name)+3(padding)=40 bytes。即Person对象自身的内存分布为:
对象外部信息中,String对象的对象头增加为16 bytes,hash字段4 bytes,char[]由于是引用类型,大小增加为8 bytes,共16+4+8+4(padding)=32 bytes;同理,char[]所引用的数组对象大小为16+4(length)+8(arrLength)*2(char)+4(padding)=40 bytes 。最后,Person对象在指针压缩关闭的情况下,占用内存大小为:40+32+40=112 bytes,相比于开启状态多占用了24 bytes。
通过对比,我们可以总结出指针压缩对对象占用内存大小的影响:
64位机器上,UseCompressedOops默认为开启状态时,以下对象的指针会从8 bytes压缩为4 bytes:
(1)所有对象Header中的类型指针;
(2)所有对象实例数据中的指针属性;
(3)所有对象中的数组指针。
实验3:类继承与内存分配策略
发生类的继承时,对于子类来说,从父类继承下来的内容将被包含在实例数据部分中,并且从内存分布来看,父类的成员变量会排在子类之前。但是实际情况要比这个更加复杂一些,我们将通过两个实验做对比进行说明。
首先我们定义一个简单的对象来观察父类属性和子类属性在内存中的分布及占用大小:
class Pet {
char tag='a';
boolean health=true;
}
class Dog extends Pet {
long birthday = 1437010516321L;
int age=5;
}
在默认情况下(即开启指针压缩),查看Dog对象的内部信息结果如下:
有了之前的基础,Dog对象的大小可以很容易的计算出来:12(对象头)+2(tag)+1(health)+
8(birthday)+4(age)+5(padding)=32 bytes,由上图得出内存分布大致可以表示如下:
父类的tag和health属性在内存分布上排在子类之前,由于子类的第一个属性是8字节的long类型,所以JVM在birthday之前先进行了1 byte的内部padding对齐内存。
接下来我们对Pet对象和Dog对象进行一些扩充和调整:
class Pet{
double price=5000.32d;
int age=1;
float weight=50.32f;
}
class Dog extends Pet{
long birthday = 1437010516321L;
char tag='a';
boolean health=true;
}
在运行之前,我们不妨先推测一下它的内存分布和大小:按照上个实验获得的结论,父类属性将会排在子类属性之前,并对空隙进行内部padding,则Dog对象的内存分布与大小:12(对象头) + 4(内部padding) + 8(price) + 4(age) + 4(weight) + 8(birthday) + 2(tag) + 1(health) + 5(padding) = 48 bytes。
然而实际结果是这样吗?
运行分析函数后可得到如下结果:
内存分布图示如下:
结果显示,这与我们之前的实验结论完全不一致:子类的tag和health属性进入了父类的区域;父类中的price、age、weight属性没有按照从宽到窄的顺序排列;字段排列顺序不一致导致padding大小也产生了差别。
是我们估算错误还是JOL工具出错了?
JOL工具没有出错。默认情况下,JVM为了尽量减少内存空间的占用,CompactFields参数为开启状态,此时对象内属性的内存分布将遵循以下几个规则:
除了对象整体需要按8字节对齐外,每个成员变量都尽量使自身的大小在内存中对齐。比如int按4位对齐,long按8位对齐。
类属性按照如下优先级进行排列:长整型和双精度类型;整型和浮点型;字符和短整型;字节类型和布尔类型,最后是引用类型。这些属性都按照各自的单位对齐。
优先按照规则1和规则2处理父类中的成员,然后才是子类成员。
当父类最后一个成员和子类第一个成员的间隔不够4个字节时,就必须扩展到4个字节的基本单位。
如果子类第一个成员是double或者long类型,且父类没有用完8字节,JVM会破坏规则2,按照int/float,char/short,boolean/byte,引用类型(reference)的顺序,向未填满的空间填充。该规则同样适用于对象头与实例数据之间(例如,实验2)。
根据规则2,上述实验中的父类三个属性原本应按照double-int/float的顺序排列,但由于对象头与父类之间存在4 bytes空隙,且父类第一个成员是double类型,所以按照规则5,父类中较窄的int类型填充了该空隙;同理,父类与子类之间的4 bytes空隙由子类中较窄的tag(char)和health(boolean)填充。
其实我们的估算也没错误,但这样的计算方法只有当主动关闭CompactFields状态时才适用。该参数为false时,JVM将仅遵循规则1-4,此时,对象将占用更大的内存空间。在运行时,添加参数“-XX:-CompactFields”,再次运行分析函数,结果如下图。从图中可看出,CompactFields为false时,对象大小和字段分布都与我们估算的一致。
通过上述实验对比,我们可以发现,JVM为了减少内存占用采用了多种有效的策略,而我们在不修改默认配置的情况下,需要遵循规则1-5来估算Java对象大小。感兴趣的读者可以自己尝试在同时关闭指针压缩与CompactFields状态时估算对象大小。
04
总结
本文从“Java对象占用多大内存?”出发,首先引入分析工具及其使用方法,之后展开介绍了Java对象的基本结构,最后通过多组实验详细说明对象大小的估算方法以及影响对象大小的主要因素。希望本文能从技术上给您带来新的理解,并为在日常开发中遇到的问题提供一种可能的解决思路。文中如有错误的地方欢迎热心的同学指正,互相探讨!