作为一名Java程序员,我们在日常工作中使用这种面向对象的编程语言时,最频繁的操作大概就是一个一个地创建对象。虽然创建对象的方式有很多种,可以通过new、反射、克隆、反序列化等不同的方式来创建,但是对象最终使用的时候一定要放在内存中,所以你知道里面有哪些java对象记忆是由哪些部分组成的?它们是如何存储的?本文将基于代码进行实例测试,详细讨论对象在内存中的组成和结构。全文目录结构如下:对象内存结构概述JOL工具介绍对象头实例数据对齐填充字节总结,我们简单回顾一下一个对象的创建过程:1.jvm加载对象所在的class文件进入方法区2.jvm读取main方法入口,将main方法压入栈,执行对象创建代码3.main方法中栈内存中分配的对象引用,堆中分配的内存和放入创建的对象中,栈中的引用指向堆中的对象。所以当对象被实例化时,它被存储在堆内存中。这里对象由3部分组成,如下图所示:简单说明一下各部分的作用:对象头:对象头存储了对象在运行时的状态信息和指向类的元数据的指针该对象属于。如果对象是数组对象,会额外存储对象的数组长度实例数据:实例数据存储对象真正有效的数据,即各个属性字段的值,如果有父类,它还将包括父类字段。字段的存储顺序会受到数据类型的长度和虚拟机分配策略的影响Alignmentpaddingbytes:在java对象中,对齐paddingbytes的原因是64位jvm中对象的大小为要求是8字Section对齐,所以当对象的长度小于8字节的整数倍时,需要对对象进行填充操作。注意图中的alignmentpadding部分用的是虚线。这是因为填充字节不是固定的部分。这个在后面计算物体大小时会详细说明。2.JOL工具介绍先介绍一下我们要用到的工具。openjdk官网提供了一个查看对象内存布局的工具jol(javaobjectlayout),可以在maven中引入:org.openjdk.joljol-core0.14代码中使用jol提供的方法查看jvm信息:System.out.println(VM.current().details());通过打印的信息可以看出,我们使用的是64位的jvm,并且启用了指针压缩,对象默认使用8字节对齐。通过jol查看对象内存布局的方法将在后面的例子中详细展示。下面开始正式学习对象内存布局。3.Objectheader首先看Objectheader的组成部分。根据普通对象和数组对象的不同,结构也会有所不同。只有当对象是数组对象时才会有数组长度部分,普通对象没有这部分,如下图:对象头中的markword占8字节,klass指针占4字节默认启用指针压缩时,数组对象的数组长度占用4个字节。了解了对象头的基本结构后,现在以一个不包含任何属性的空对象为例,查看其内存布局,创建一个User类:publicclassUser{}使用jol查看对象头的内存布局:publicstaticvoidmain(String[]args){Useruser=newUser();//查看对象的内存布局System.out.println(ClassLayout.parseInstance(user).toPrintable());}执行代码查看打印信息:OFFSET:偏移地址,单位为字节SIZE:占用内存大小,以字节为单位TYPE:Class中定义的类型DESCRIPTION:类型描述,Objectheader表示对象头,alignment表示对齐填充对应内存当前对象一共占用16字节,因为8字节的markword加上4字节的类型指针不满足对齐到8字节,所以需要填充4字节:8B(markword)+4B(klasspointer)+0B(instancedata)+4B(padding)这样,我们可以直观的理解内存中最简单的没有属性的空对象的基本构成。在此基础上,我们再进一步了解对象头中的各个组成部分。3.1MarkWord在对象头中,markword一共有64位,用于存放对象本身的运行时数据,被标记的对象处于以下五种状态之一:3.1.1Lockupgradebased关于markword在jdk6之前,通过synchronized关键字加锁时,都是使用无差别的重量级锁。重量级锁会导致线程串行执行,导致CPU频繁切换用户态和核心态。随着synchronized的不断优化,提出了锁升级的概念,引入了偏向锁、轻量级锁、重量级锁。在markword中,锁(lock)标志占2位,结合1位偏向锁(biased_lock)标志,这样后3位可以用来标识当前对象持有的锁的状态,并判断什么剩余位存储的信息。基于markword的锁升级过程如下:1.锁对象刚创建时,没有线程竞争,对象处于解锁状态。在上面打印的空对象的内存布局中,根据sizeend,最后8位为00000001,表示处于解锁状态,处于无偏状态。这是因为jdk中的偏向锁有4秒的延时启动,也就是说jvm启动后4秒后创建的对象才会打开偏向锁。我们通过jvm参数取消这个延迟时间:-XX:BiasedLockingStartupDelay=0此时最后3位为101,表示当前对象的锁没有被持有,处于偏向状态。2、在没有线程竞争的情况下,第一个获得锁的线程通过CAS将自己的threadId写入到对象的markword中。如果后面线程再次获取锁,需要比较当前线程的threadId和对象的markword中的threadId是否一致,一致则直接获取,锁对象一直保持线程的偏向,也就是说不会主动释放偏向锁。使用代码测试同一个线程重复获取锁:publicstaticvoidmain(String[]args){Useruser=newUser();synchronized(user){System.out.println(ClassLayout.parseInstance(user).toPrintable());}System.out.println(ClassLayout.parseInstance(user).toPrintable());synchronized(user){System.out.println(ClassLayout.parseInstance(user).toPrintable());}}执行结果:可以看到当一个线程加锁、解锁和重新获取对象的锁时,markword不会改变,偏向锁中的当前线程指针始终指向同一个线程。3.当两个或多个线程交替获取锁,但不是并发获取对象锁时,偏向锁升级为轻量级锁。该阶段线程采用CAS自旋方式尝试获取锁,避免线程阻塞导致用户态和内核态切换时cpu的消耗。测试代码如下:publicstaticvoidmain(String[]args)throwsInterruptedException{Useruser=newUser();synchronized(user){System.out.println("--MAIN--:"+ClassLayout.parseInstance(user).toPrintable());}Threadthread=newThread(()->{synchronized(user){System.out.println("--THREAD--:"+ClassLayout.parseInstance(user).toPrintable());}});thread.start();thread.join();System.out.println("--END--:"+ClassLayout.parseInstance(user).toPrintable());}先来看看结果:整个加锁状态的变化过程如下:主线程先给用户对象加锁,第一把锁是101偏向锁。子线程等待主线程释放锁,然后锁定用户对象。此时偏向锁升级为00轻量级锁,解锁后,用户对象无线程竞争,回到001无锁状态,处于无偏状态。如果后面有线程试图获取用户对象的锁,它会直接加轻量级锁,而不是加偏向锁。4.当两个或多个线程在同一个对象上并发同步时,为了避免无用的自旋消耗cpu,轻量级锁会升级为重量级锁。此时,markword中的指针指向monitor对象(也称为monitor或monitorlock)的起始地址。测试代码如下:publicstaticvoidmain(String[]args){Useruser=newUser();newThread(()->{synchronized(user){System.out.println("--THREAD1--:"+ClassLayout.parseInstance(user).toPrintable());try{TimeUnit.SECONDS.sleep(2);}catch(InterruptedExceptione){e.printStackTrace();}}}).start();newThread(()->{同步(用户){System.out.println("--THREAD2--:"+ClassLayout.parseInstance(user).toPrintable());try{TimeUnit.SECONDS.sleep(2);}catch(InterruptedExceptione){e.printStackTrace();}}}).start();}查看结果:可以看到当两个线程同时竞争用户对象的锁时,会升级为10个重量级锁。3.1.2其他信息说明markword中的其他重要信息:hashcode:解锁状态下的hashcode采用延迟加载技术,只有在第一次调用hashCode()方法时才会进行计算和写入。验证这个过程:publicstaticvoidmain(String[]args){Useruser=newUser();//打印内存布局System.out.println(ClassLayout.parseInstance(user).toPrintable());//计算hashCodeSystem.out.println(user.hashCode());//再次打印内存布局System.out.println(ClassLayout.parseInstance(user).toPrintable());}可以看到在调用hashCode()方法之前,31位的hash的值不存在,全部填0。调用该方法后,根据大小端,填充的数据为:1011001001101100011010010101101。二进制转十进制,对应哈希值1496724653。需要注意的是只有在Object.hashCode时才会写入markword()方法或者未被重写的System.identityHashCode(Object)方法被调用,用户自定义的hashCode()方法不会被写入。您可能会注意到,当对象被锁定时,markword中没有足够的空间来保存hashCode。这时候hashCode会被移到重量级锁的ObjectMonitor中。epoch:Timestampgenerationalageofbiasedlocks(age):在jvm的垃圾回收过程中,对象每经过一次YoungGC,age都会加1,这里4位代表最大的generationalage为15,即也是这个原因对象年龄超过15后会被移到老年代,可以在启动时加入参数改变年龄阈值:-XX:MaxTenuringThreshold当设置的阈值超过15时,启动时会报错:3.2KlassPointer类型指针KlassPointer是指向方法区中Class信息的指针,虚拟机通过这个指针标识对象属于哪个类的实例。在64位JVM中,支持指针压缩功能。根据是否启用指针压缩,KlassPointer占用的大小会有所不同:未启用指针压缩时,类型指针占用8B(64bit)启用指针压缩时,类型指针占用4B(32bit))jdk6以后的版本默认开启指针压缩,可以通过启动参数开启或关闭:#开启指针压缩:-XX:+UseCompressedOops#关闭指针压缩:-XX:-UseCompressedOops或使用TaketheUser以刚才的类为例。关闭指针压缩后,再次查看对象的内存布局:虽然对象的大小还是16字节,但是组成发生了变化。8字节的tagword加上8字节的类型指针就已经可以满足对齐条件了。因此不需要填充。8B(markword)+8B(klasspointer)+0B(instancedata)+0B(padding)3.2.1指针压缩原理了解了指针压缩的作用之后,我们来看看指针压缩是如何实现的。首先,在不启用指针压缩的情况下,一个对象的内存地址是用64位来表示的。此时可描述的内存地址范围为:0~2^64-1启用指针压缩后,使用4个字节,即32位可以表示2^32个内存地址。如果这个地址是真实地址,由于CPU寻址的最小单位是Byte,那么就是4GB内存。这对我们来说远远不够,但是我们之前说过,java中的对象默认使用8字节对齐,也就是说一个对象占用的空间必须是8字节的整数倍,从而创造了一个条件,使得jvm在定位对象时不需要使用真实的内存地址,而是定位到java经过8字节映射后的地址(可以说是映射地址的若干个)。映射过程也很简单。由于使用8字节对齐后每个对象的地址偏移量的后3位必须为0,所以存储时可以擦除0的后3位(转为位即擦除后24位),在此基础上,去掉最高位,完成指针从8字节到4字节的压缩。实际使用时,在压缩指针后加3位0,实现到真实地址的映射。压缩完成后,指针的32位中的每一位可以代表8个字节,相当于将原来的内存地址扩大了8倍。因此,在8字节对齐的情况下,32位最多可以表示2^32*8=32GB的内存,内存地址范围为:0~(2^32-1)*8由于最大内存可以表示的是32GB,所以如果配置的最大堆内存超过这个值,指针压缩就会失败。配置jvm启动参数:-Xmx32g查看对象内存布局:此时指针压缩失败,指针长度恢复为8字节。那么如果业务场景的内存超过32GB怎么办?您可以通过修改默认对齐长度再次扩展它。我们修改对齐长度为16字节:-XX:ObjectAlignmentInBytes=16-Xmx32g可以看到压缩后指针占用4字节,同时对象被填充对齐为16字节。根据上面的计算,指针压缩只有在最大堆内存配置为64GB时才会失败。指针压缩小结:通过指针压缩,利用对齐填充的特性,通过映射达到内存地址扩展的效果。指针压缩可以节省内存空间,提高程序的寻址效率。堆内存最好不要设置超过32GB,此时指针压缩将失效,造成空间浪费。另外,指针压缩不仅可以应用于对象头的类型指针,还可以应用于引用类型的字段指针和引用类型的数组指针。3.3数组长度如果对象是数组对象时,在对象头中有一个空间用来保存数组的长度,占用4字节(32bit)的空间。用以下代码测试:publicstaticvoidmain(String[]args){User[]user=newUser[2];//查看对象的内存布局System.out.println(ClassLayout.parseInstance(user).toPrintable());}运行代码,结果如下:内存结构从上到下依次为:8字节markword4字节klass指针4字节数组长度,值为2,表示数组中有两个元素,每一个启用指针压缩后的引用类型占用4个字节,数组中的两个元素一共占用8个字节。需要注意的是,在不启用指针压缩的情况下,数组长度后会有一段对齐填充字节:计算得出:8B(markword)+8B(klasspointer)+4B(arraylength)+16B(instancedata)=36B需要要对齐到8字节,这里选择在数组长度和实例数据之间加上对齐的4字节。4.实例数据实例数据(InstanceData)保存了对象实际存储的有效信息,保存了代码中定义的各种数据类型的字段内容,如果有继承关系,子类也会包含从父类继承在场上课。基本数据类型:引用数据类型:启用指针压缩时为8字节,启用指针压缩时为4字节。4.1字段重排序在User类中添加基本数据类型的属性字段:publicclassUser{intid,age,weight;bytesex;longphone;charlocal;}查看内存布局:可以看到在内存中,属性的顺序是一样的与类中定义的顺序不同,因为jvm会使用字段重排序技术对原有类型进行重排序,以达到内存对齐的目的。具体规则如下:根据数据类型的长度,长度相同的字段从大到小排列,分配在相邻的位置。如果一个字段的长度是L字节,那么这个字段的偏移量(OFFSET)需要对齐到nL(n为整数)上面的前两条规则比较容易理解,这里举例说明第三条规则:因为long类型占8个字节,它的偏移量一定是8n,加上上面的对象头占12个字节,所以long类型变量的最小偏移量是16。通过打印对象的内存布局,可以发现即当对象头不是8字节的整数倍时(只存在8n+4字节),长度为4、2、1字节的属性将按照从大到小的顺序进行替换。为了区别于alignmentpadding,可以称之为prefilling。如果填充后仍不满足8字节的整数倍,则进行对齐填充。在预填充的情况下,字段的排序打破了上面的第一条规则。所以,在上面的内存布局中,先用4字节的int进行预填充,然后按照第一条规则降序排列。如果我们把int类型的3个字段删掉,再看内存布局:前面提到char类型和byte类型的变量进行预填充,在long类型之前进行1字节对齐填充。4.2当类有父类时,类有父类时,遵循父类中定义的变量出现在子类中定义的变量之前的原则publicclassA{inti1,i2;longl1,l2;charc1,c2;}publicclassBextendsA{booleanb1;doubled1,d2;}查看内存结构:如果父类需要后补,子类中类型较短的变量可能会提前,但总体原则是先子类后父类班级。publicclassA{inti1,i2;longl1;}publicclassBextendsA{inti1,i2;longl1;}查看内存结构:可以看到子类中长度较短的变量被推进到父类中,然后填充。父类的padding会被子类继承publicclassA{longl;}publicclassBextendsA{longl2;inti1;}查看内存结构:当B类不继承A类时,正好满足8字节对齐,不需要对齐padding.B类继承A类时,会继承A类的prefillpadding,所以在B类的末尾也需要alignmentpadding。4.3参考数据类型上面的例子只讨论了基本数据类型的排序。如果有引用数据类型,排序情况如何?在User类中添加引用类型:publicclassUser{intid;StringfirstName;StringlastName;intage;}查看内存布局:可以看到默认情况下,基本数据类型的变量排在引用数据类型之前。这个顺序可以在jvm启动参数中修改:-XX:FieldsAllocationStyle=0重新运行,可以看到引用数据类型的排列顺序放在前面:FieldsAllocationStyle的不同值的简单说明:0:先放普通对象的引用指针,再放基本数据类型变量1:默认表示先放基本数据类型变量,再放普通对象的引用指针4.4静态变量在上面的基础上,给类添加静态变量:publicclassUser{intid;staticbytelocal;}查看内存布局:从结果可以看出,静态变量不在对象的内存布局中,它的大小不算在对象中,因为静态变量属于类而不是属于对象。5.对齐填充字节在Hotspot的自动内存管理系统中,对象的起始地址必须是8字节的整数倍,也就是说对象的大小必须满足8字节的整数倍。因此,如果实例数据没有对齐,则需要对齐以填补空白。完成的位仅用作占位符,没有特殊含义。在前面的例子中,我们对alignmentpadding有了充分的了解,下面做一些补充:启用指针压缩时,如果类中有long/double类型的变量,会存储在对象头中,instancedata之间形成了一个间隙(gap)。为了节省空间,长度较短的变量默认会放在前面。这个功能可以通过jvm参数开启或关闭:#open-XX:+CompactFields#close-XX:-CompactFieldstest关闭,可以看到长度较短的变量没有向前填充:在前面的指针压缩中,我们提到可以改变对齐宽度,也是通过修改如下jvm参数配置来实现:-XX:ObjectAlignmentInBytes默认对齐宽度为8。这个值可以修改为2~256范围内的2的整数次方。一般按8字节或16字节对齐。测试修改为16字节对齐:上例中当对齐调整为16字节时,最后一行的属性字段只占6字节,所以会增加10字节用于对齐填充。当然,一般情况下,不建议修改对齐长度参数。如果对齐宽度太长,可能会浪费内存空间。6.小结本文通过使用jol调试java对象的结构,学习了对象内存布局的基础知识。通过学习,可以帮助我们:掌握对象的内存布局,据此调优jvm参数了解对象头在synchronize锁升级过程中的作用熟悉jvm中对象的寻址过程通过计算大小的对象,可以根据评估业务量预估项目上线前会使用多少内存,防止服务器频繁gc