写java时不管是我们自己new对象还是spring管理bean,尽管我们天天跟对象打交道,那么对象的结构和内存布局有多少人知道呢,这篇文章可带你入门,了解java对象内存布局。
本文涉及到JVM指针压缩的知识点,不熟悉的小伙伴可以看前面写过的一篇关于指针压缩的文章。
JVM之指针压缩
首先说明,本文涉及的JDK版本是1.8,JVM虚拟机是64位的HotSpot实现为准。
java对象结构
关于java对象我们知道, 对象的实例是存在堆上的, 对象的元数据存在方法区(元空间)上,对象的引用保存在栈上的。那么java对象的结构是什么样的呢,其实java对象由三部分构成。
- 对象头
对象头里也有三部分构成。
- Markword
存储对象的hashCode、垃圾回收对象的年龄以及锁信息等。
- 类型指针
对象指向的类信息地址即元数据指针,比如User对象指针指向User.class的JVM内存地址。注意:jdk1.8以后元数据是存在Metaspace里的,jdk1.8之前是在方法区里
- 数组长度
只有对象是数组的情况下,才有这部分数据,若对象不是数组,则没有这部分,不分配空间。
- 对象体
对象里的非静态属性占用的空间(包括父类的所有属性,不区分修饰类型),不包括方法,注意:是非静态属性,属于对象的属性,静态属性是属于类的不在对象上分配空间。如果属性是基本数据类型,则直接存对象本身,如果是引用类型,则存的是对象的指针。
- 对齐填充
默认情况下,如果对象头+对象体大小不是8字节的倍数,则通过该部分进行补齐,比如对象头+对象体大小只有30字节,则需要补齐到32字节,这里的对齐填充就是2字节。默认情况下,JVM中对象是以8字节对齐的,若对象头加上对象体是8的倍数时,则不存在字节对齐,否则会填充补齐到8的倍数。
对象结构如下图所示。
通过图中可以看出,数组对象只是在对象头里多了数组长度这一项,普通对象(非数组对象)没有这项,也不分配内存空间。
对象结构及占用空间大小如下图所示。
涉及指针压缩的地方有两个,一个是对象头里的类型指针,一个是对象体里的引用类型指针,这篇文章里有详细的介绍:JVM之指针压缩。
对象头
对象头包含三部分
- Markword:存储对象自身运行时数据如hashcode、gc分代年龄及锁信息等,64位系统总共占用8个字节。
- 类型指针:对象指向类元数据地址的指针,jdk8默认开启指针压缩,64位系统占4个字节
- 数组长度:若对象不是数组,则没有该部分,不分配空间大小,若是数组,则为4个字节长度
对象头占用空间大小如下表所示。
Markword
存储对象自身运行时数据如hashcode、gc分代年龄及锁信息等,64位系统总共占用8个字节,也就是64bit,64位的二进制0和1。
解释如下:
- 对象的hashCode占31位,重写类的hashCode方法返回int类型,只有在无锁情况下,是在有调用的情况下会计算该值并写到对象头中,其他情况该值是空的。
- 分代年龄占4位,最大值也就是15,在GC中,当survivor区中对象复制一次,年龄加1,默认是到15之后会移动到老年代。
- 是否偏向锁占1位,无锁和偏向锁的最后两位都是01,使用这一位来标识区分是无锁还是偏向锁。
- 锁标志位占2位,锁状态标记位,同是否偏向锁标志位标识对象处于什么锁状态。
- 偏向线程ID占54位,只有偏向锁状态才有,这个ID是操作系统层面的线程唯一id,跟java中的线程id是不一致的。
类型指针
类型指针指向类的元数据地址,JVM通过这个指针确定对象是哪个类的实例。32位的JVM占32位,4个字节,64位的JVM占64位,8个字节,但是64位的JVM默认会开启指针压缩,压缩后也只占4字节。
64位虚拟机中在堆内存小于32GB的情况下,UseCompressedOops是默认开启的,该参数表示开启指针压缩,会将原来64位的指针压缩为32位。
-XX:+UseCompressedClassPointers //开启压缩类指针
-XX:-UseCompressedClassPointers //关闭压缩类指针
这个JVM参数依赖UseCompressedOops这个参数,UseCompressedOops开启,UseCompressedClassPointers默认开启,可手工关闭,UseCompressedOops关闭,UseCompressedClassPointers不管开启还是关闭都不生效即不压缩。
数组长度
如果对象是普通对象非数组对象,则没有这部分,不占用空间。
如果对象是一个数组,则将数组的长度存到对象头里,表示数组的大小。
对象体
对象体里放的是非静态的属性,也包括父类的所有非静态属性(private修饰的也在这里,不区分可见性修饰符),基本类型的属性存放的是具体的值,引用类型及数组类型存放的是引用指针。
对齐填充
虚拟机为了高效寻址,采用8字节对齐,所以对象大小不是8的倍数时,会补齐对应的位置,比如对象头+对象体是32字节时,则不需要对齐填充,对象头+对象体是12字节时,则需补齐4位。
对象大小的计算
对象的大小跟指针压缩是否开启有关,可通过以下两个参数控制。
UseCompressedClassPointers:压缩类指针(开启时类指针占4字节,关闭时类指针占8字节)
UseCompressedOops:压缩普通对象指针(开启时引用对象指针占4字节,关闭时引用对象指针占8字节)
这两个参数默认是开启的,即-XX:+UseCompressedClassPointers,-XX:+UseCompressedOops,也可手动设置,如下所示
-XX:+UseCompressedClassPointers //开启压缩类指针
-XX:-UseCompressedClassPointers //关闭压缩类指针
-XX:+UseCompressedOops //开启压缩普通对象指针
-XX:-UseCompressedOops //关闭压缩普通对象指针
32位HotSpot VM是不支持UseCompressedOops参数的,只有64位HotSpot VM才支持。
Oracle JDK从6 update 23开始在64位系统上会默认开启压缩指针。
以下表格展示了对象中各部分所占空间大小,单位:字节。
类型 | 所属部分 | 占用空间大小(压缩开启) | 占用空间大小(压缩关闭) |
---|---|---|---|
Markwork | 对象头 | 8 | 8 |
类型指针 | 对象头 | 4 | 8 |
数组长度 | 对象头 | 4 | 4 |
byte | 对象体 | 1 | 1 |
boolean | 对象体 | 1 | 1 |
short | 对象体 | 2 | 2 |
char | 对象体 | 2 | 2 |
int | 对象体 | 4 | 4 |
float | 对象体 | 4 | 4 |
long | 对象体 | 8 | 8 |
double | 对象体 | 8 | 8 |
对象引用指针 | 对象体 | 4 | 8 |
对齐填充 | 对齐填充 | 对象头+对象体是8的倍数?0 :8 -(对象头+对象体)% 8 | 对象头+对象体是8的倍数?0 :8 -(对象头+对象体)% 8 |
对象大小计算公式
对象大小=对象头 + 对象体(对象是数组时,对象体的大小=引用指针占用空间大小*对象个数) + 对齐填充
64位操作系统32G内存以下,默认开启对象指针压缩,对象头是12字节,关闭指针压缩,对象头是16字节。内存超过32G时,则自动关闭指针压缩,对象头占16字节。
对象分析
有了以上的理论知识,我们通过实际案例进行对象分析。
使用 JOL 工具分析 Java 对象大小
maven依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
常用类及方法
查看对象内部信息: ClassLayout.parseInstance(obj).toPrintable()
查看对象外部信息:GraphLayout.parseInstance(obj).toPrintable()
查看对象占用空间总大小:GraphLayout.parseInstance(obj).totalSize()
查看类内部信息:ClassLayout.parseClass(Object.class).toPrintable()
使用到的测试类:
@Setter
class Goods {
private byte b;
private char type;
private short age;
private int no;
private float weight;
private double price;
private long id;
private boolean flag;
private String goodsName;
private LocalDateTime produceTime;
private String[] tags;
public static String str;
public static int temp;
}
非数组对象,开启指针压缩
64位JVM,堆内存小于32G的情况下,默认是开启指针压缩的。
public static void main(String[] args) {
Goods goods = new Goods();
goods.setAge((short) 10);
goods.setNo(123456);
goods.setId(111L);
goods.setGoodsName("方便面");
goods.setFlag(true);
goods.setB((byte)1);
goods.setPrice(1.5d);
goods.setProduceTime(LocalDateTime.now());
goods.setType('A');
goods.setWeight(0.065f);
goods.setTags(new String[] {"food", "convenience", "cheap"});
Goods.str = "test";
Goods.temp = 222;
System.out.println(ClassLayout.parseInstance(goods).toPrintable());
}
计算对象大小:
先不看输出结果,按上面的公式计算一下对象的大小:
对象头:8字节(Markword)+4字节(类指针)=12字节
对象体:1字节(属性b)+ 2字节(属性type)+ 2字节(属性age)+ 4字节(属性no)+ 4字节(属性weight)+ 8字节(属性price)+ 8字节(属性id)+ 1字节(属性flag) + 4字节(属性goodsName指针) + 4字节(属性produceTime指针) + 4字节(属性tags指针)= 42字节(注意:静态属性不参与对象大小计算)
对齐填充:8 -(对象头+对象体)% 8 = 8 - (12 + 42) % 8 = 2字节
对象大小=对象头 + 对象体 + 对齐填充 = 12字节 + 42字节 + 2字节 = 56字节。
执行看运行结果:
com.star95.study.jvm.Goods object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x2000c043
12 4 int Goods.no 123456
16 8 double Goods.price 1.5
24 8 long Goods.id 111
32 4 float Goods.weight 0.065
36 2 char Goods.type A
38 2 short Goods.age 10
40 1 byte Goods.b 1
41 1 boolean Goods.flag true
42 2 (alignment/padding gap)
44 4 java.lang.String Goods.goodsName (object)
48 4 java.time.LocalDateTime Goods.produceTime (object)
52 4 java.lang.String[] Goods.tags [(object), (object), (object)]
Instance size: 56 bytes
Space losses: 2 bytes internal + 0 bytes external = 2 bytes total
这里有一个特殊的地方,打印输出的属性顺序跟代码里的顺序不一致,这是因为JVM进行优化,也就是指令重排序,会根据属性类型的大小、执行的先后顺序对结果是否有影响、最小填充大小等因素计算出对象最小应占用的空间。
非数组对象,关闭指针压缩
计算对象大小:
关闭压缩指针,类指针和引用对象指针都占8字节,推算一下对象大小:
对象头:8字节(Markword)+8字节(类指针)=16字节
对象体:1字节(属性b)+ 2字节(属性type)+ 2字节(属性age)+ 4字节(属性no)+ 4字节(属性weight)+ 8字节(属性price)+ 8字节(属性id)+ 1字节(属性flag) + 8字节(属性goodsName指针) + 8字节(属性produceTime指针) + 8字节(属性tags指针)= 54字节(注意:静态属性不参与对象大小计算)
对齐填充:8 -(对象头+对象体)% 8 = 8 - (16 + 54) % 8 = 2字节
对象大小=对象头 + 对象体 + 对齐填充 = 16字节 + 54字节 + 2字节 = 72字节。
运行时增加JVM参数如下:
-XX:-UseCompressedClassPointers -XX:-UseCompressedOops
public class ObjectLayOut1 {
public static void main(String[] args) {
Goods goods = new Goods();
goods.setAge((short) 10);
goods.setNo(123456);
goods.setId(111L);
goods.setGoodsName("方便面");
goods.setFlag(true);
goods.setB((byte)1);
goods.setPrice(1.5d);
goods.setProduceTime(LocalDateTime.now());
goods.setType('A');
goods.setWeight(0.065f);
goods.setTags(new String[] {"food", "convenience", "cheap"});
Goods.str = "test";
Goods.temp = 222;
System.out.println(ClassLayout.parseInstance(goods).toPrintable());
}
}
执行看运行结果:
com.star95.study.jvm.Goods object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 8 (object header: class) 0x00000000175647b8
16 8 double Goods.price 1.5
24 8 long Goods.id 111
32 4 int Goods.no 123456
36 4 float Goods.weight 0.065
40 2 char Goods.type A
42 2 short Goods.age 10
44 1 byte Goods.b 1
45 1 boolean Goods.flag true
46 2 (alignment/padding gap)
48 8 java.lang.String Goods.goodsName (object)
56 8 java.time.LocalDateTime Goods.produceTime (object)
64 8 java.lang.String[] Goods.tags [(object), (object), (object)]
Instance size: 72 bytes
Space losses: 2 bytes internal + 0 bytes external = 2 bytes total
数组对象开启指针压缩
计算对象大小:
默认是开启压缩指针的,类指针和引用对象指针都占4字节,推算一下对象大小:
对象头:8字节(Markword)+ 4字节(类指针) + 4字节(数组长度)= 16字节
对象体:4字节 * 3 = 12字节
对齐填充:8 -(对象头+对象体)% 8 = 8 - (16字节 + 12字节)% 8= 4字节
对象大小=对象头 + 对象体 + 对齐填充 = 16字节 + 12字节 + 4字节 = 32字节。
public class ObjectLayOut1 {
public static void main(String[] args) {
Goods goods = new Goods();
goods.setAge((short) 10);
goods.setNo(123456);
goods.setId(111L);
goods.setGoodsName("方便面");
goods.setFlag(true);
goods.setB((byte)1);
goods.setPrice(1.5d);
goods.setProduceTime(LocalDateTime.now());
goods.setType('A');
goods.setWeight(0.065f);
goods.setTags(new String[] {"food", "convenience", "cheap"});
Goods.str = "test";
Goods.temp = 222;
Goods[] goodsArr = new Goods[3];
goodsArr[0] = goods;
System.out.println(ClassLayout.parseInstance(goodsArr).toPrintable());
}
}
执行看运行结果:
[Lcom.star95.study.jvm.Goods; object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x2000c18d
12 4 (array length) 3
16 12 com.star95.study.jvm.Goods Goods;.<elements> N/A
28 4 (object alignment gap)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
数组对象关闭指针压缩
计算对象大小:
关闭压缩指针,类指针和引用对象指针都占8字节,推算一下对象大小:
对象头:8字节(Markword)+8字节(类指针) + 4字节(数组长度)=20字节
对象体:8字节 * 3 = 24字节
对齐填充:8 -(对象头+对象体)% 8 = 8 - (20+ 24) % 8 = 4字节
对象大小=对象头 + 对象体 + 对齐填充 = 20字节 + 24字节 + 4字节 = 48字节。
运行时增加JVM参数如下:
-XX:-UseCompressedClassPointers -XX:-UseCompressedOops
public class ObjectLayOut1 {
public static void main(String[] args) {
Goods goods = new Goods();
goods.setAge((short) 10);
goods.setNo(123456);
goods.setId(111L);
goods.setGoodsName("方便面");
goods.setFlag(true);
goods.setB((byte)1);
goods.setPrice(1.5d);
goods.setProduceTime(LocalDateTime.now());
goods.setType('A');
goods.setWeight(0.065f);
goods.setTags(new String[] {"food", "convenience", "cheap"});
Goods.str = "test";
Goods.temp = 222;
Goods[] goodsArr = new Goods[3];
goodsArr[0] = goods;
System.out.println(ClassLayout.parseInstance(goodsArr).toPrintable());
}
}
执行看运行结果:
[Lcom.star95.study.jvm.Goods; object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 8 (object header: class) 0x0000000017e04d70
16 4 (array length) 3
20 4 (alignment/padding gap)
24 24 com.star95.study.jvm.Goods Goods;.<elements> N/A
Instance size: 48 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
通过以上对象分析,我们看到在开启压缩指针的情况下,对象的大小会小很多,节省了内存空间。
总结
通过以上的分析,基本已经把java对象的结构讲清楚了,另外对象占用内存空间大小也计算出来,有助于进行JVM调优分析,64位的虚拟机内存在32G以下时默认是开启压缩指针的,超过32G自动关闭压缩指针,主要目的都是为了提高寻址效率。
另外,本文是通过JOL工具计算对象占用空间的大小,不包括引用对象实际占用的内存大小,因为计算时是按引用对象的指针占用空间大小计算的,可能跟其他工具计算的结果不一样,具体跟工具的计算逻辑有关,比如跟JDK自带的jvisualvm工具通过堆dump出来看到的对象大小不一样,感兴趣的可自行验证。