竹笋

首页 » 问答 » 灌水 » JVM成神路之对象内存布局对象历程强
TUhjnbcbe - 2024/6/4 20:01:00

内存模型

一、Java对象在内存中的布局

Java源代码中,使用new关键字创建出的对象实例,我们都知道在运行时会被分配到内存上存储,但分配的时候是直接在内存中“挖”一个对应大小的坑,然后把对象实例丢进去存储吗?其实并不然,Java对象一般在内存中的布局通常由对象头、实例数据、对齐填充三部分组成,如下:

对象布局

Oracle官方给出的JVM内存模型

JVM内存分为线程私有区和线程共享区

在HotSpot虚拟机源码的hotspot/src/share/vm/oops/目录下,instanceOop、instanceKlass、oop几个C++的文件描述了对象的定义。

1.1、对象头(ObjectHeader)

Java对象头其实是一个比较复杂的东西,它通常也会由多部分组成,其中包含了MarkWord和类型指针(ClassMetadataAddress/KlassWord),如果是数组对象,还会存在数组长度。如下:

完整对象布局

下面我们重点分析对象头的构成,JVM采取2个字宽/字长存储对象头,如果对象是数组,额外需要存储数组长度,所以数组对象在32位虚拟机中采取3个字宽存储对象头。而64位虚拟机采取两个半字宽+半字宽对齐数据存储对象头,而在32位虚拟机中一个字宽的大小为4byte,64位虚拟机下一个字宽大小为8byte,64位开启指针压缩(-XX:+UseCompressedOops)的情况下,MarkWord为8byte,KlassWord为4byte。

而关于这块的内容很多资料都含糊不清,几乎都是基于32位虚拟机而言的,那么我在这里分别列出32位/64位的对象头信息,对象头结构及存储大小说明如下:

其中32位的JVM中对象头内MarkWord在默认情况下存储着对象的HashCode、分代年龄、是否偏向锁、锁标记位等信息,而64位JVM中对象头内MarkWord的默认信息存储着HashCode、分代年龄、是否偏向锁、锁标记位、unused,如下:

由于对象头的信息是与对象自身定义的成员属性数据没有关系的额外存储成本,因此考虑到JVM的空间效率,MarkWord被设计成为一个非固定的数据结构,以便可以复用方便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,除了上述列出的MarkWord默认存储结构外,还有如下可能变化的结构:

32/64bit虚拟机markword结构

markword信息:

unused:未使用的区域。

identity_hashcode:对象最原始的哈希值,就算重写hashcode()也不会改变。

age:对象年龄。

biased_lock:是否偏向锁。

lock:锁标记位。

ThreadID:持有锁资源的线程ID。

epoch:偏向锁时间戳。

ptr_to_lock_record:指向线程栈中lock_record的指针。

ptr_to_heavyweight_monitor:指向堆中monitor对象的指针。

LockRecord:LockRecord存在于线程栈中,翻译过来就是锁记录,它会拷贝一份对象头中的markword信息到自己的线程栈中去,这个拷贝的markword称为DisplacedMarkWord,另外还有一个指针指向对象。

简单总结一下,对象头主要由MarkWord、KlassWord和有可能存在的数组长度三部分组成。MarkWord主要是用于存储对象的信息以及锁信息,KlassWord则是存储指向元空间中类元数据的指针,当然,如果当前对象是数组,那么也会在对象头中存储当前数组的长度。

1.2、实例数据(InstanceData)

实例数据是指一个聚合量所有标量的总和,也就是是指当前对象属性成员数据以及父类属性成员数据。举个例子:

publicclassA{intia=0;intib=1;longl=8L;publicstaticvoidmain(String[]args){Aa=newA();}}

上述案例中,A类存在三个属性ia、ib、l,其中两个为int类型,一个long类型,那么此时对象a的实例数据大小则为4+4+8=16byte(字节)。

那此时再给这个案例加点料试试看,如下:

publicclassA{intia=0;intib=1;longl=8L;Bb=newB();publicstaticvoidmain(String[]args){Aa=newA();}publicstaticclassB{Objectobj=newObject();}}

此时对象的实例数据大小又该如何计算呢?需要把B类的成员数据也计算进去嘛?实则不需要的,如果当类的一个成员属于引用类型,那么是直接存储指针的,而引用指针的大小为一个字宽,也就是在32位的VM中为32bit,在64位的VM中为64bit大小。所以此时对象的实例数据大小为:4+4+8+8=24byte(未开启指针压缩的情况下是这个大小,但如果开启了则不为这个大小,稍后详细分析)。

1.3、对齐填充(Padding)

对齐填充在一个对象中是可能存在,也有可能不存在的,因为在64bit的虚拟机中,《虚拟机规范》中规定了:为了方便内存的单元读取、寻址、分配,Java对象的总大小必须要为8的整数倍,所以当一个对象的对象头+实例数据大小不为8的整数倍时,此刻就会出现对齐填充部分,将对象大小补齐为8的整数倍。

如:一个对象的对象头+实例数据大小总和为28bytes,那么此时就会出现4bytes的对齐填充,JVM为对象补齐成8的整数倍:32bytes。

1.4、指针压缩(CompressedOops)

指针压缩属于JVM的一种优化思想,一方面可以节省很大的内存开支,第二方面也可以方便JVM跳跃寻址(稍后分析),在64bit的虚拟机中为了提升内存的利用率,所以出现了指针压缩这一技术,指针压缩的技术会将Java程序中的所有引用指针(类型指针、堆引用指针、栈帧内变量引用指针等)都会压缩一半,而在Java中一个指针的大小是占一个字宽单位的,在64bit的虚拟机中一个字宽的大小为64bit,所以也就意味着在64位的虚拟机中,指针会从原本的64bit压缩为32bit的大小,而指针压缩这一技术在JDK1.7之后是默认开启的。

指针压缩失效:指针压缩带来的好处是无可厚非,几乎能够为Java程序节省很大的内存空间,一般而言,如果不开启压缩的情况下对象内存需要14GB,在开启指针压缩之后几乎能够在10GB内存内分配下这些对象。但是压缩技术带来好处的同时,也存在非常大的弊端,因为指针通过压缩技术后被压缩到32bit,而Java中32bit的指针最大寻址为32GB,也就代表着如果你的堆内存为32G时出现了OOM问题,你此时将内存扩充到48GB时仍有可能会出现OOM,因为内存超出32GB后,32bit的指针无法寻址,所有压缩的指针将会失效,发生指针膨胀,所有指针将会从压缩后的32Bit大小回到压缩前的64Bit大小。

1.5、JOL对象大小计算实战

为了方便观察到对象的内存布局,首先导入一个OpenJDK组织提供的工具:JOL,maven依赖如下:

!--mvnrepository.

1
查看完整版本: JVM成神路之对象内存布局对象历程强