问题
一个Java对象到底占多少个字节?了解这个之前我们先来了解一下Java对象模型,这将对我们理解具有帮助。
一、JAVA对象模型
我们先了解一下,一个JAVA对象的存储结构。在Hotspot虚拟机中,对象在内存中的存储布局分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1.1 对象头(Header)
对象头,又包括三部分:Mark Word、(Klass Word)元数据指针、数组长度。
MarkWord:用于存储对象运行时的数据,比如HashCode、锁状态标志、GC分代年龄等。这部分在64位操作系统下,占8字节(64bit),在32位操作系统下,占4字节(32bit)。
下图为64位下Mark Word布局:
Klass Word:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
这部分就涉及到一个指针压缩的概念,在开启指针压缩的情况下,占4字节(32bit),未开启情况下,占8字节(64bit),现在JVM在1.6之后,在64位操作系统下都是默认开启的。
数组长度:这部分只有是数组对象才有,如果是非数组对象,就没这部分了,这部分占4字节(32bit)。
由于我们的虚拟机是分为32位和64位,那肯定它们的模型也是有区别的,下面我列出列32位虚拟机和64位虚拟机下的Java对象头内存模型。
32位虚拟机的Java对象头内存模型:
|-----------------------------------------------------------------------------------------------------------------|
| Object Header(64bits) | State |
|-----------------------------------------------------------------------------------------------------------------|
| Mark Word(32bits) | Klass Word(32bits) | |
|-----------------------------------------------------------------------------------------------------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | OOP to metadata object | Nomal |
|-----------------------------------------------------------------------------------------------------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | OOP to metadata object | Biased |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_lock_record:30 | 00 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_heavyweight_monitor:30 | 10 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| | 11 | OOP to metadata object | Marked for GC |
|-----------------------------------------------------------------------------------------------------------------|
64位未开启指针压缩虚拟机的Java对象头内存模型:
|-----------------------------------------------------------------------------------------------------------------|
| Object Header(128bits) | State |
|-----------------------------------------------------------------------------------------------------------------|
| Mark Word(64bits) | Klass Word(64bits) | |
|-----------------------------------------------------------------------------------------------------------------|
| unused:25|identity_hashcode:31|unused:1|age:4|biase_lock:0| 01 | OOP to metadata object | Nomal |
|-----------------------------------------------------------------------------------------------------------------|
| thread:54| epoch:2 |unused:1|age:4|biase_lock:1| 01 | OOP to metadata object | Biased |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_lock_record:62 | 00 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_heavyweight_monitor:62 | 10 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| | 11 | OOP to metadata object | Marked for GC |
|-----------------------------------------------------------------------------------------------------------------|
字段含义(非本章重点)
biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。
age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。
thread:持有偏向锁的线程ID。
epoch:偏向锁的时间戳。
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。
最初的时候,JVM是32位的,但是随着64位系统的兴起,JVM也迎来了从32位到64位的转换,32位的JVM对比64位的内存容量比较有限,但是我们使用64位虚拟机的同时,也带来了一个问题,64位下的JVM中的对象会比32位中的对象多占用1.5倍的内存空间,这是我们不想看到的。
采用8字节(64位)存储真实内存地址,比之前采用4字节(32位)压缩存储地址带来的问题。
- 增加了GC开销:64位对象引用需要占用更多的堆空间,留给其他数据的空间将会减少,从而加快了GC的发生,更频繁的进行GC。
- 降低CPU缓存命中率:64位对象引用增大了,CPU能缓存的oop将会更少,从而降低了CPU缓存的效率。
为解决这些问题,我们在64位中的JVM中可以开启指针压缩(UseCompressedOops)来压缩我们对象指针的大小来帮助我们节约内存空间。
当我们启用了-XX:+UseCompressedOops
之后,我们原本的OOP(Ordinary Object Pointer,普通对象指针)就会被压缩,当然也不是所有的对象都会被压缩,只有 以下几种的对象才会被压缩:对象的全局静态变量(类属性)、对象头信息、对象的引用类型、对象数组类型。
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
64位开启指针压缩虚拟机的Java对象头内存模型:
至于指针压缩原理参见:聊一聊JAVA指针压缩的实现原理(图文并茂,让你秒懂)
以上就是我们对Java对象头内存模型的解析,只要是Java对象,那么就肯定会包括对象头,也就是说这部分内存占用是避免不了的。所以,在64位虚拟机,Jdk1.8(开启了指针压缩)的环境下,任何一个对象,啥也不做,只要声明一个类,那么它的内存占用就至少是96bits,也就是至少12字节。而不开启指针压缩情况下,则占用16字节。
为了更加详细理解真相,我们结合实例来看一看。
为了查看对象的大小,我们需要借助OpenJDK提供的JOL包,可以帮我们在运行时计算某个对象的大小,是非常好的工具,点击这里可以进行该工具学习了解。
引入Maven依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
然后我们创建一个空的类,代码如下:
public class TestNullObjectSize {
public static void main(String[] args) {
//查看对象内部信息
System.out.println(ClassLayout.parseInstance(new TestNullObjectSize()).toPrintable());
}
}
打印信息如下:
这里我们发现结果显示:Instance size:16 bytes
,结果就是16字节,我们之前预测的开启了指针压缩的12字节不一样,为什么会这样呢?我们看到上图中有3行 object header
,每个占用4字节,所以头部就是12字节,这里和我们的计算是一致的,最后一行是虚拟机填充的4字节,那为什么虚拟机要填充4个字节呢?
还记得对象在内存中的存储布局分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
在上面简单例子中,对象头占12bytes,实例数据没有,占0字节,这两个我们都理解了,那么最后这个对齐填充又是什么呢?
1.2 内存对齐
想要知道为什么虚拟机要进行对齐填充,我们需要了解什么是内存对齐?
在开发人员眼中,我们看到的内存是这样的:
上图表示一个坑一个萝卜的内存读取方式。但实际上 CPU 并不会以一个一个字节去读取和写入内存。相反, CPU 读取内存是一块一块读取的,块的大小可以为 2、4、6、8、16 字节等大小。块大小我们称其为内存访问粒度。如下图:
假设一个32位平台的 CPU,那它就会以4字节为粒度去读取内存块。那为什么需要内存对齐呢?主要有两个原因:
- 平台(移植性)原因:不是所有的硬件平台都能够访问任意地址上的任意数据。例如:特定的硬件平台只允许在特定地址获取特定类型的数据,否则会导致异常情况。
- 性能原因:若访问未对齐的内存,将会导致 CPU 进行两次内存访问,并且要花费额外的时钟周期来处理对齐及运算。而本身就对齐的内存仅需要一次访问就可以完成读取动作。
下面用图例来说明 CPU 访问非内存对齐的过程:
在上图中,假设CPU 是一次读取4字节,在这个连续的8字节的内存空间中,如果我的数据没有对齐,存储的内存块在地址1,2,3,4中,那CPU的读取就会需要进行两次读取,另外还有额外的计算操作:
1、CPU 首次读取未对齐地址的第一个内存块,读取 0-3 字节。并移除不需要的字节 0。
2、CPU 再次读取未对齐地址的第二个内存块,读取 4-7 字节。并移除不需要的字节 5、6、7 字节。
3、合并 1-4 字节的数据。
4、合并后放入寄存器。
所以,没有进行内存对齐就会导致CPU进行额外的读取操作,并且需要额外的计算。如果做了内存对齐,CPU可以直接从地址0开始读取,一次就读取到想要的数据,不需要进行额外读取操作和运算操作,节省了运行时间。我们用了空间换时间,这就是为什么我们需要内存对齐。
回到Java空对象填充了4个字节的问题,因为原字节头是12字节,64位机器下,内存对齐的话就是128位,也就是16字节,所以我们还需要填充4个字节。(64位机器一次读取8字节,因为64位下填充为8字节的整数倍,这里12字节,显然填充到16字节效果最佳。)
二、非空对象占用内存计算
为了让读者更加理解对齐该问题,我们对测试类加入一个int类型的成员变量和一个Object对象。
import org.openjdk.jol.info.ClassLayout;
public class TestNullObjectSize {
private int num;
Object object=new Object();
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new TestNullObjectSize()).toPrintable());
}
}
打印如下:
我们可以看到,Instance size: 24 bytes
,也就是说该对象占用了24字节,通过前面我们已经知道了对象头=Mark Word+Klass Word=12字节,这里加入一个int成员变量,占4字节,另外需要注意这里有一个引用类型的变量object,也是占用4个字节,加起来一共是20字节,显然不是8的整数倍,因此进行填充,最后整个对象占24字节,与我们想象的一致。
But、But、But、But。。。。。。
因为我们实例化了Object(),这个对象一定会存在于内存中,所以我们还需要加上这个对象的内存占用16字节(空对象占用16个字节,这是我们之前分析过了的),那总共就是24bytes+16bytes=40bytes。
到底是不是这样呢?我们用代码说话,添加两行打印语句,整体代码如下:
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
public class TestNullObjectSize {
private int num;
Object object=new Object();
public static void main(String[] args) {
//打印对象内部信息
System.out.println(ClassLayout.parseInstance(new TestNullObjectSize()).toPrintable());
System.out.println("-------------------------------------------------------------");
//打印对象的所有相关内存占用
System.out.println(GraphLayout.parseInstance(new TestNullObjectSize()).toPrintable());
System.out.println("-------------------------------------------------------------");
//打印对象的所有内存结果并统计
System.out.println(GraphLayout.parseInstance(new TestNullObjectSize()).toFootprint());
}
}
打印信息:
通过打印信息我们可以看到最后的统计打印结果也是40字节,所以我们的分析正确。
指针压缩(64位才支持)
当我们启用了-XX:+UseCompressedOops
之后,我们原本的OOP(Ordinary Object Pointer,普通对象指针)就会被压缩,当然也不是所有的对象都会被压缩,只有 以下几种的对象才会被压缩:
对象的全局静态变量(类属性)而以下几种对象则不能被压缩:
对象头中Klass Word信息
对象的引用类型
对象数组类型
指向PermGen的Class对象指针指针压缩的大概原理: 通过对齐,还有偏移量将64位指针压缩成32位。零基压缩是针对压缩解压动作的进一步优化。 它通过改变正常指针的随机地址分配特性,强制堆地址从零开始分配。
局部变量
传参
返回值
NULL指针
三、总结
本文主要讲述了如何分析一个Java对象究竟占用多少内存空间,主要总结点如下:
- Java对象头部内存模型在32位虚拟机和64位虚拟机是不一样的,64位虚拟机又分为开启指针压缩和不开启指针压缩两种对象头模型,所以总共有3种对象头模型。
- 内存对齐主要是因为平台的原因和性能的原因,本文主要解析的是性能方面的原因。
- 空对象的内存占用计算注意要计算内存对齐,非空对象的内存计算注意加上引用内存占用和原实例对象的空间占用。
参考文章:
1、小姐姐问:Object obj=new Object()究竟占多少字节啊?
2、聊一聊JAVA指针压缩的实现原理(图文并茂,让你秒懂)
3、「每日五分钟,玩转JVM」:指针压缩
4 、java并发编程——JAVA对象头(含32位虚拟机与64位虚拟机)