当前位置: 首页 > 科技观察

Java对象的内存布局

时间:2023-03-12 01:23:48 科技观察

本文转载自微信公众号《小菜也牛》,作者贾健。转载本文请联系小菜一牛公众号。今天讲一些抽象的东西——对象头,因为在学习过程中,发现很多地方都和对象头的知识点有关,比如JDK中的同步锁优化,JVM中的对象年龄升级等。深入理解这些知识的原理,有必要了解对象头的概念,可以为后面的synchronized原理和JVM知识的分享做准备。对象内存构成Java中new关键字创建的类的实例对象。对象存储在内存堆中,并为其分配一个内存地址。你有没有想过下面的问题:这个实例对象是如何存在于内存中的?一个Object对象在内存中占用多少?对象中的属性在内存中是如何分配的?在JVM中,一个Java对象存储在堆中时,由以下三部分组成:对象头:包括堆对象的布局、类型、GC状态、同步状态和标识哈希码等基本信息。Java对象和vm内部对象都有共同的对象头格式。实例数据(InstanceData):主要存储类的数据信息、父类的信息、对象字段的属性信息。对齐填充(Padding):对于字节对齐来说,填充数据不是必需的。对于对象头,我们可以在Hotspot官方文档(如下)中找到它的描述。可以发现,它是Java对象和虚拟机内部对象的通用格式,由两个词(计算机术语)组成。另外,如果对象是Java数组,那么对象头中必须有一段数据用来记录数组的长度,因为虚拟机可以通过普通Java的元数据信息来判断Java对象的大小对象,但是从数组的元数据信息中无法确定数组的大小。它提到对象头由两个词组成。这两个词是什么?我们还是在上面的Hotspot官方文档中查找,可以发现名词还有另外两个定义,分别是markword和klasspointer。从中可以找到对象头中的两个词:第一个词是markword,第二个词是klasspointer。MarkWord用于存储对象本身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。32位JVM中MarkWord的长度为32bit,64位JVM中为64bit。我们打开openjdk的源码包,对应路径/openjdk/hotspot/src/share/vm/oops,MarkWord对应C++代码markOop.hpp,从注释中可以看到它们的组成,所有代码都在这文章基于Jdk1。8位和64位操作系统。MarkWord在不同的锁定状态下存储的内容是不同的。在32位JVM和64位JVM中都是这样存储的。虽然在不同位数的JVM中它们的长度不同,但是基本的组成内容是一样的。的。锁标志(lock):区分锁状态,11表示对象正在等待GC回收,只有最后2个锁标志(11)有效。biased_lock:锁是否偏向。由于普通锁和偏向锁的锁ID都是01,所以没法区分。这里引入一个1位的偏向锁ID。Generationalage(年龄):表示对象被GC的次数。当数量达到阈值时,对象将被转移到老年代。对象的Hashcode(散列):运行时调用System.identityHashCode()进行计算,延迟计算,并将结果赋值在这里。对象上锁后,31位的计算结果不足以表示,hashcode会传给Monitor进行偏向锁、轻量级锁、重度锁。偏向锁的线程ID(JavaThread):在偏向模式下,当一个线程持有一个对象时,该对象会被设置为该线程的ID。在后续操作中,不需要再去尝试获取锁。epoch:Biasedlock在CAS锁操作过程中,biasedflag表示对象更偏向于哪个锁。ptr_to_lock_record:在轻量级锁状态下,指向栈中锁记录的指针。当锁获取没有竞争时,JVM使用原子操作而不是OS互斥锁。这种技术称为轻量级锁定。在轻量级锁的情况下,JVM通过CAS操作在对象的headerword中设置一个指向锁记录的指针。ptr_to_heavyweight_monitor:在重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,轻量级锁必须升级为一个监视器来管理等待线程。在重量级锁的情况下,JVM在对象的ptr_to_heavyweight_monitor中设置一个指向Monitor的指针。KlassPointer是类型指针,是对象指向其类元数据的指针。虚拟机使用这个指针来确定对象是哪个类的实例。实例数据如果对象有属性字段,这里会有数据信息。如果对象没有属性字段,这里就没有数据。根据字段类型,占用不同的字节。比如boolean类型占1个字节,int类型占4个字节等;对齐数据对象可以有或没有对齐数据。默认情况下,Java虚拟机堆中对象的起始地址需要对齐到8的倍数。如果一个对象使用小于8N字节,需要填充,以填满对象头和实例之后的剩余空间数据占用内存。如果对象头和实例数据已经占用了JVM分配的内存空间,那么就不需要进行对齐填充。所有对象分配的总字节SIZE需要是8的倍数,如果前面的对象头和实例数据占用的总SIZE不满足要求,会通过对齐数据的方式进行填充。为什么要对齐数据?字段内存对齐的原因之一是让字段只出现在同一CPU的缓存行中。如果字段未对齐,则可能存在跨越缓存行的字段。也就是说,字段的读取可能需要替换两个缓存行,而字段的存储可能同时污染两个缓存行。这两种情况都不利于程序的执行效率。实际上,填充它的最终目的是为了高效的计算机寻址。至此,我们了解了对象在堆内存中的整体结构布局。如下图,Talk的概念是廉价的,showmecode是抽象的。如果你说它是这样组成的,是真的吗?学习是需要持怀疑态度的,任何理论或概念只有经过自己的验证和实践才能被接受。幸运的是,openjdk为我们提供了一个工具包,可以用来获取对象信息和虚拟机信息。我们只需要引入jol-core依赖,如下org.openjdk.joljol-core0.8jol-core常用的三个方法ClassLayout.parseInstance(object).toPrintable():查看对象的内部信息。GraphLayout.parseInstance(object).toPrintable():查看对象的外部信息,包括被引用的对象。GraphLayout.parseInstance(object).totalSize():查看对象的总大小。为了简单起见,我们不使用复杂的对象,自己创建一个类D。首先通过jol-coreAPI看publicclassD{}在没有属性字段的情况下,我们打印出对象的内部信息publicstaticvoidmain(String[]args){Dd=newD();System.out.println(ClassLayout.parseInstance(d).toPrintable());}最终的打印结果显示有OFFSET、SIZE、TYPEDESCRIPTION、VALUE等几个名词头。它们的含义是OFFSET:偏移地址,单位字节;SIZE:占用内存大小,以字节为单位;TYPEDESCRIPTION:类型描述,其中objectheader为对象头;VALUE:对应内存中当前存储的值,32位二进制;可以看出d对象实例一共占用16字节,对象头(objectheader)占用12byte(96bit),其中markword占用8byte(64bit),klasspointe占用4byte,剩下的4byte是填充对齐。这里,由于默认启用了指针压缩,所以对象头占用12个字节。指针压缩的具体概念这里就不细说了。有兴趣的读者可以自行查阅官方文档。jdk8版本默认开启指针压缩。您可以通过配置vm参数-XX:-UseCompressedOops来启用和禁用指针压缩。如果关闭指针压缩,重新打印对象的内存布局,可以发现totalSIZE变大了。从下图可以看出,objectheader占用的内存大小变成了16字节(128位),其中markword占用8字节,klasspointe占用8字节。8byte,无对齐填充。开启指针压缩可以减少对象的内存使用。从两次打印的D对象的布局信息来看,关闭指针压缩后,对象头的SIZE增加了4个字节。这里,由于D对象没有属性,读者可以尝试增加几个属性字段看看,会明显发现SIZE变大了。因此,开启指针压缩理论上可以节省50%左右的内存。jdk8及以后版本默认开启指针压缩,无需配置。数组对象使用普通对象。下面我们看看数组对象的内存布局,比较异同。publicstaticvoidmain(String[]args){int[]a={1};System.out.println(ClassLayout.parseInstance(a).toPrintable());}打印内存布局信息,如下图,总SIZE为一共24个字节,对象头占16个字节,其中MarkWork占8个字节,KlassPoint占4个字节,arraylength占4个字节,因为int类型只有一个1,所以实例数据数组对象占4个字节,剩余对齐填充占4个字节。结束对象的内存布局和对象头的概念,尤其是对象头MarkWord的内容,在我们分析同步和JVM垃圾回收时代时会有很大的用处。在JVM中,大家是否还记得,在Survivor中,对象每存活一次MinorGC,它的年龄就会增加1,当它的年龄增加到一定程度,就会被提升到老年代。默认为15岁。我有想过为什么是15?在MarkWord中可以发现,用于标记对象世代年龄的分配空间是4位,4位最大可以表示的数是2^4-1=15。