目录
一. 前言
二. Java 对象的内存布局
三. Java 对象结构
3.1. 对象头
3.1.1. Mark Word
3.1.2. 类型指针(Class Metadata Pointer)
3.1.3. 数组长度(Length)
3.2. 实例数据
3.3. 对齐填充(Padding)
四. JVM 之 指针压缩
4.1. 压缩指针的由来
4.2. 如何压缩指针
4.3. 如何进一步扩大寻址空间
4.4. JVM 压缩指针参数
五. Java 对象大小计算
5.1. 对象大小计算公式
5.2. 对象分析
5.3. 非数组对象,开启指针压缩
5.4. 非数组对象,关闭指针压缩
5.5. 数组对象开启指针压缩
5.6. 数组对象关闭指针压缩
六. 总结
一. 前言
作为一名 Java 程序员,我们在日常工作中使用这门面向对象的编程语言时,做的最频繁的操作大概就是去创建一个个的对象了。对象的创建方式有很多,可以通过 new、Spring 管理 Bean、反射、clone、反序列化等不同方式来创建,但最终使用时对象都要被放到内存中,那么你知道在内存中的 Java 对象是由哪些部分组成以及是怎么存储的吗?这篇文章可带你深入了解 Java 对象的内存布局。
二. Java 对象的内存布局
Java 对象的内存布局分为两种,普通对象和数组对象。
通过图中可以看出,数组对象只是在对象头里多了数组长度这一项,普通对象(非数组对象)没有这一项,也不分配内存空间。
三. Java 对象结构
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐补全。
3.1. 对象头
对象头由三部分组成:
- Mark Word:存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容。
- Class Metadata Pointer:类型指针指向它的类元数据的指针。
- Length:记录数组长度。只有对象是数组的情况下,才有这部分数据,若对象不是数组,则没有这部分,不分配空间。
3.1.1. Mark Word
用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持久的锁、偏向线程的 ID 等,通过存储的内容得知对象头是锁机制和 GC 的重要基础。
32位虚拟机的 Mark Word 的字节分配:
- 无锁 —— 对象的 hashcode:25bit;存放对象分代年龄:4bit; 存放是否偏向锁的标志位:1bit; 存放锁标志位为01:2bit。
- 偏向锁 —— 在偏向锁中划分更细。开辟 25bit 的空间,其中存放线程 ID:23bit;存放Epoch:2bit;存放对象分代年龄:4bit;存放是否偏向锁标志:1bit (0表示无锁,1表示偏向锁);锁的标志位为01:2bit。
- 轻量级锁 —— 在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00。
- 重量级锁 —— 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标志位,其标志位为10。
- GC 标记 —— 开辟 30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。其中无锁和偏向锁的锁标志位都是01,只是在前面的 1bit 区分了这是无锁状态还是偏向锁状态。
64位虚拟机的 Mark Word 的字节分配:
- 无锁 —— unused:25bit;对象的 hashcode:31bit;Cmc_free:1bit;存放对象分代年龄:4bit; 存放是否偏向锁的标志位:1bit; 存放锁标志位为01:2bit。
- 偏向锁 —— 偏向线程 ID:54bit;存放Epoch:2bit;Cmc_free:1bit;存放对象分代年龄:4bit;存放是否偏向锁标识:1bit (0表示无锁,1表示偏向锁);锁的标志位为01:2bit。
- 轻量级锁 —— 在轻量级锁中直接开辟 62bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00。
- 重量级锁 —— 在重量级锁中和轻量级锁一样,62bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为10。
- GC 标记 —— 开辟 62bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。其中无锁和偏向锁的锁标志位都是01,只是在前面的 1bit 区分了这是无锁状态还是偏向锁状态。
3.1.2. 类型指针(Class Metadata Pointer)
类型指针指向类的元数据地址,JVM 通过这个指针确定对象是哪个类的实例。32位的 JVM 占32位,4个字节,64位的 JVM 占64位,8个字节,但是64位的 JVM 默认会开启指针压缩,压缩后也只占4字节。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的 JVM 将会比32位的JVM 多耗费50%的内存。所以会默认开启指针压缩(如不开启,类型指针将占用8字节),UseCompressedOops 是默认开启的,该参数表示开启指针压缩,会将原来64位的指针压缩为32位(即由原8字节压缩到4字节大小)。
-XX:+UseCompressedClassPointers //开启压缩类指针
-XX:-UseCompressedClassPointers //关闭压缩类指针
// 这个JVM参数依赖UseCompressedOops这个参数,UseCompressedOops开启,UseCompressedClassPointers默认开启,可手工关闭,
// UseCompressedOops关闭,UseCompressedClassPointers不管开启还是关闭都不生效即不压缩。
3.1.3. 数组长度(Length)
如果对象是普通对象(非数组对象),则没有这部分,不占用空间。如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着 JVM 架构的不同而不同:32位的JVM上,长度为32位(4字节);64位 JVM 则为64位(8字节)。64位 JVM 如果开启+UseCompressedOops 选项,该区域长度也将由64位压缩至32位。
3.2. 实例数据
普通对象:实例数据。
数组对象:数组中的实例数据。
实例数据存放的是非静态的属性,也包括父类的所有非静态属性(private 修饰的也在这里,不区分可见性修饰符),基本类型的属性存放的是具体的值,引用类型及数组类型存放的是引用指针。
实例数据不同的类型所占的空间不同:
数据类型 | 占用空间 |
---|---|
char | 2个字节 |
boolean | 1个字节 |
byte | 1个字节 |
short | 2个字节 |
int | 4个字节 |
long | 8个字节 |
float | 4个字节 |
double | 8个字节 |
对象引用 | 对象指针压缩默认是被开启的,占用4个字节,配置 JVM 参数 -XX:+UseCompressedOops 后,占用8个字节 |
64 位的 JVM 默认开启普通对象指针压缩 -XX:+UseCompressedOops (OOP 即 ordinary object pointer),由原8字节压缩到4字节大小。
3.3. 对齐填充(Padding)
用于补齐对象内存长度的。因为 JVM 要求 Java 代码的对象必须是 8bit 的倍数。如果一个对象用不到 8N 个字节则需要对其填充,以此来补齐对象头和实例数据占用内存之后剩余的空间大小。如果对象头和实例数据已经占满了 JVM 所分配的内存空间,那么就不用再进行对齐填充了。所有的对象分配的字节总 SIZE 需要是8的倍数,如果前面的对象头和实例数据占用的总 SIZE 不满足要求,则通过对齐数据来填满。
四. JVM 之 指针压缩
4.1. 压缩指针的由来
计算机操作系统分32位和64位,这里的位在计算机里是用0和1来表示的,用32个(或64个)二进制0和1的组合来表示内存地址。
以32位为例,在普通的内存中,对象的大小最小是以1字节来计算的,通过0和1的排列组合,能够表示寻址的内存空间最大就是个,换算成内存空间就是 / 1024 / 1024 / 1024 = 4G,也就是说32位的操作系统最大能寻址的内存空间只有4G。
同理,64位的操作系统(查阅资料显示其实没有用到64位,最多只用到了48位,这个可自行查阅资料,反正肯定比32位大的多) / 1024 / 1024 / 1024 / 1024 = 256TB,这样内存就足够大了,但是目前还没有厂商能生产出这么大的内存。
4G 对于现在的 Java 应用系统来说,内存已经算小的了,那我们就会想到使用64位的系统,这样内存就可以更大了,但是当我们准备将32位系统切换到64位系统,起初我们可能会期望系统性能会立马得到提升,但现实情况可能并不是这样的,为什么呢?
- 32位系统对象指针是4字节,64位系统对象指针是8字节(1位表示1bit,8个bit 表示1字节),这样64位系统中的对象引用占用的内存空间是32位系统中的两倍大小,因此间接的导致了在64位系统中更多的内存消耗以及更频繁的 GC 发生,GC 占用的 CPU 时间越多,那么我们的应用程序占用 CPU 的时间就越少,响应会变慢,吞吐量会降低。
- 对象的引用变大了,那么 CPU 可缓存的对象相对就少了,降低了 CPU 缓存命中率,增加了对内存的访问,CPU 对 CPU 缓存的访问速度可比对内存的访问速度快太多了,所以大量的对内存访问,会降低 CPU 的执行效率,增加了执行时间,从而影响性能。
既然32位系统内存不够,64位内存够但又影响性能,那有没有折中方案来解决这两个问题呢,于是聪明的 JVM 开发者想到了利用压缩指针,在64位的操作系统中利用32位的对象指针引用获得超过 4G 的内存寻址空间。
4.2. 如何压缩指针
由于在 JVM 里,对象都是以8字节对齐的(即对象的大小都是8的倍数),所以不管用32位还是64位的二进制表示,末尾3位始终都是0。既然 JVM 已经知道了这些对象的内存地址后三位始终是0,那么这些无意义的0就没必要在堆中继续存储。相反,我们可以利用存储0的这3位 bit 存储一些有意义的信息,这样我们就多出3位 bit 的寻址空间,也就是说如果我们继续使用32位来存储指针,只不过后三位原本用来存储0的 bit 现在被我们用来存放有意义的地址空间信息,当寻址的时候,JVM 将这32位的对象引用左移3位即可(后三位补0)。我们原本32位的内存寻址空间一下变成了35位,可寻址的内存空间变为 / 1024 / 1024 / 1024 = 32G,也就是说在64位系统 JVM 的内存可扩大到 32G 了,基本上可满足大部分应用的使用了。
所以在64位系统下,通过压缩指针我们可以继续使用32位来处理(引用指针由8字节可降低到4字节),存储的时候右移3位,寻址的时候左移3位,如下图所示:
这样一来,JVM 虽然额外的执行了一些位运算但是极大的提高了寻址空间,并且将对象引用占用内存大小降低了一半,节省了大量空间,况且这些位运算对于 CPU 来说是非常容易且轻量的操作,可谓是一举两得。
4.3. 如何进一步扩大寻址空间
前边提到我们在 Java 虚拟机堆中对象起始地址均需要对齐至8的倍数,不过这个数值我们可以通过 JVM 参数 -XX:ObjectAlignmentInBytes 来改变(默认值为8)。当然这个数值的必须是2的次幂,数值范围需要在 8 - 256 之间。
正是因为对象地址对齐至8的倍数,才会多出3位 bit 让我们存储额外的地址信息,进而将 4G 的寻址空间提升至 32G。
同样的道理,如果我们将 ObjectAlignmentInBytes 的数值设置为16呢?
对象地址均对齐至16的倍数,那么就会多出4位 bit 让我们存储额外的地址信息。寻址空间变为 / 1024 / 1024 / 1024 = 64G。
通过以上规律,我们就能知道,在64位系统中开启压缩指针的情况,寻址范围的计算公式:4G * ObjectAlignmentInBytes = 寻址范围。
但是并不建议大家贸然这样做,因为增大了 ObjectAlignmentInBytes 虽然能扩大寻址范围,但是这同时也可能增加了对象之间的字节填充,导致压缩指针没有达到原本节省空间的效果。
4.4. JVM 压缩指针参数
可以通过以下命令查看 Java 命令默认的启动参数:
java -XX:+PrintCommandLineFlags -version
通过下面这个命令,可以看到所有JVM参数的默认值:
java -XX:+PrintFlagsFinal -version
关于压缩指针的两个参数:
- UseCompressedClassPointers:压缩类指针(开启时类指针占4字节,关闭时类指针占8字节);
- UseCompressedOops:压缩普通对象指针(开启时引用对象指针占4字节,关闭时引用对象指针占8字节)。
Oops 是 Ordinary object pointers 的缩写,这两个参数默认是开启的,即 -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位系统上会默认开启压缩指针。
五. Java 对象大小计算
5.1. 对象大小计算公式
以下表格展示了对象中各部分所占空间大小,单位:字节。
类型 | 所属部分 | 占用空间大小(压缩开启) | 占用空间大小(压缩关闭) |
---|---|---|---|
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字节。
5.2. 对象分析
使用 JOL 工具分析 Java 对象大小:
<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;
}
5.3. 非数组对象,开启指针压缩
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字节(Mark Word)+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 进行优化,也就是指令重排序,会根据属性类型的大小、执行的先后顺序对结果是否有影响、最小填充大小等因素计算出对象最小应占用的空间。
5.4. 非数组对象,关闭指针压缩
关闭压缩指针,类指针和引用对象指针都占8字节,推算一下对象大小:
- 对象头:8字节(Mark Word)+ 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
5.5. 数组对象开启指针压缩
默认是开启压缩指针的,类指针和引用对象指针都占4字节,推算一下对象大小:
- 对象头:8字节(Mark Word)+ 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
5.6. 数组对象关闭指针压缩
关闭压缩指针,类指针和引用对象指针都占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 出来看到的对象大小不一样,感兴趣的可自行验证。