类与对象
在编译时,通过Javac编译器为虚拟机规范的class文件格式。class文件格式是与操作系统和机器指令集无关的、平台中立的格式。其他语言编写的代码只需要实现指定语言的编译器编译位JVM规范标准的class文件就可以实现该语言运行在JVM之上,这就是JVM的语言无关性。
通过java命令运行class文件,首先会通过类加载器将class文件加载到内存中,加载class文件会为类生成一个klass实例。在klass包含了用于描述Java类的元数据,包括字段个数、大小、是否为数组、是否有父类、方法信息等。
对象类二分模型
HotSpot虚拟机是使用C++实现的,C++也是面向对象语言。可以采用java类一一映射到C++类,当创建Java对象就创建对应的C++类的对象。
但是由于如果C++的对象含有虚函数,则创建的对象会有虚方法表指针,指向虚方法表。如果采用这种直接一对一映射的方式,会导致含有虚方法的类创建的对象都包含虚方法指针。因此在HotSpot虚拟机中,通过对象类二分模型,将类描述信息和实例数据进行拆分。使用仅包含数据不包含方法的oop(OrdinaryObjectPointer)对象描述Java的对象,使用klass描述java的类。oop的职责在于表示对象实例数据,没必要维护虚函数指针。通过oop对象头部的一个指针指向对应的klass对象进行关联。
在HotSpot虚拟机中,普通对象的类通过instanceKlass表示,对象实例则通过instanceOopDesc表示。
在JVM中引用类型可以分为对象,基本类型数组和对象类型数组。可以分别映射到Java中的对应的对象和类型。
除了常用的3类引用对象外,还有一些其他JVM自己要用的java.lang.ClassLoader用InstanceClassLoaderKlass描述,java.lang.Class用InstanceMirrorKlass描述等。
对象
HotSpotVM使用oop描述对象,oop字面意思是“普通对象指针”。它是指向一片内存的指针,只是将这片内存‘视作’(强制类型转换)Java对象/数组。对象的本质就是用对象头和字段数据填充这片内存。
对象内存布局
JOL工具
在谈论具体对象布局时,推荐一个JOL工具,可以打印对象的内存布局。通过maven引入。
通过ClassLayout.parseInstance(newObject()).toPrintable()即可打印对象的内存布局。
对象头
普通对象的对象头包含2部分,第一部分被称为MarkWord,第二部分为类型指针。如果对象为数组,除了普通对象的两部分外对象头还包含数组长度。下图是64位虚拟机对象头。
32位虚拟机头部的MarkWord长度为4个字节。
MarkWord
MarkWord保存了对象运行时必要的信息,包括哈希码(HashCode)、GC分代年龄、偏向状态、锁状态标志、偏向线程ID、偏向时间戳等信息。通过类型指针,可以找到对象对应的类型信息。32位虚拟机和64位虚拟机的MarkWord长度分别为4字节和8字节。
不论是32位还是64位虚拟机的对象头部都使用了4比特记录分代年龄,每次GC时对象幸存年龄都会加1,因此对象在survivor区最多幸存15次,超过15次时,仍然有可达根的对象就会从survivor区被转移到老年代。可以通过-XX:MaxTenuringThreshold=15参数修改最大幸存年龄。
CMS垃圾回收器默认为6次。
类型句柄
相比32位对象头大小,64位对象头更大一些,64位虚拟机对象头的MarkWord和类型指针地址都是8字节。而通常情况,我们的程序不需要占用那么大的内存。因此虚拟机通过压缩指针功能,将对象头的类型指针进行压缩。而MarkWord由于运行时需要保存的头部信息会大于4字节,仍然使用8字节。若配置开启了-XX:+UseCompressedOops,虚拟机会将类型指针地址压缩为32位。若配置开启了-XX:+UseCompressedClassPointers,则会压缩klass对象的地址为32位。
需要注意的是,当地址经过压缩后,寻址范围不可避免的会降低。对于64位CPU,由于目前内存一般到不了2^64,因此大多数64位CPU的地址总线实际会小于64位,比如48位。开启-XX:+UseCompressedOops,默认也会开启-XX:+UseCompressedClassPointers。关闭-XX:+UseCompressedOops,默认也会关闭-XX:+UseCompressedClassPointers。如果开启-XX:+UseCompressedOops,但是关闭-XX:+UseCompressedClassPointers,启动虚拟机的时候会提示“JavaHotSpot(TM)64-BitServerVMwarning:UseCompressedClassPointersrequiresUseCompressedOops”。
普通对象内存布局(64位虚拟机指针压缩时)
需要注意,由于内存按小端模式分布,因此显示的内容是反着的。上面实际对象头内容为f8e5
数组对象内存布局(64位虚拟机指针压缩时)
对象头与锁膨胀
对象头中存储了锁的必要信息,不同的锁的对象头存储内容稍有不同。32位对象头存储格式如下
JVM底层对加锁进行了性能优化,默认虚拟机启动后大约4秒会开启偏向锁功能。当虚拟机未启用偏向锁时,锁的演化过程为无锁-轻量锁(自旋锁)-重量锁。当虚拟机启用了偏向锁时,锁的演化过程为无锁-偏向锁-轻量锁(自旋锁)-重量锁。
本文不讨论JVM对加锁的具体优化逻辑,内容比较多,感兴趣的可以看同学可以参考《浅谈偏向锁、轻量级锁、重量级锁》。
无锁
当对象未加锁时,锁状态为01,32位虚拟机的对象头部如图所示
需要注意的是其中对象头保存的hashCode被称为identityHashCode,当我们调用对象的hashCode方法,返回的就是该值。若我们重写了hashCode的值,对象头的hashCode值仍然是内部的identityHashCode,而不是我们重写的hashCode值。可以通过System.identityHashCode打印identityHashCode,或者也可以通过toString直接打印对象输出16进制的identityHashCode。
偏向锁
偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
偏向锁的锁状态和未锁状态一样都是01,当对象处于偏向状态时,偏向标记为1;当对象处于未偏向时,偏向标记为0。
32位虚拟机的偏向锁对象头部如图所示
偏向时间戳,它实际表示偏向的有效期。
无锁状态升级为偏向锁的条件:
对象可偏向,对象未加锁时,执行CAS更新对象头部线程偏向线程ID为当前线程成功。对象可偏向,对象已加锁,但偏向线程ID为空,执行CAS更新对象头部线程偏向线程ID为当前线程成功。对象可偏向,对象已加锁,且偏向线程ID等于当前线程ID。对象可偏向,对象已加锁,且偏向线程ID不为空且不等于当前线程ID,执行CAS更新对象头部线程偏向线程ID为当前线程成功。虚拟机启动时,会根据-XX:BiasedLockingStartupDelay配置延迟启动偏向,在JDK1.8中,默认为4秒。有需要时可以通过-XX:BiasedLockingStartupDelay=0关闭延时偏向。
轻量级锁
轻量级锁的锁状态为00,32位虚拟机的轻量级锁头部格式如下
升级为轻量级锁条件:
对象不可偏向,跳过偏向锁直接使用轻量级锁。对象可偏向,但偏向加锁失败(存在线程竞争)。对象获取调用hashCode后加锁。对象已升级为重量级锁后,锁降级只能降级为轻量级锁,无法降级为偏向锁。轻量级锁会在线程的栈帧中开辟一个锁记录区域,将当前对象的头部保存在锁记录区域中,将锁记录区域的地址保存到当前对象头部。
对象不可偏向直接升级到轻量锁
偏向锁竞争升级为轻量锁
偏向后调用hashCode方法升级为轻量级锁
重量级锁
轻量级锁的锁状态为10,32位重量级锁头部如图所示
轻量级锁自循环一定次数后一致获取不到锁,则升级为重量级锁条件。自旋次数默认为10次,可以通过-XX:PreBlockSpin配置修改次数。
重量级锁降级
当重量级锁解锁后就会进行锁降级,锁降级只能降级为轻量锁,无法再使用偏向锁。
实例数据
对象实例数据默认按照long、double、int、short、char、byte、boolean、reference顺序布局,相同字段宽度总是分配在一起。若有父对象,则父对象的实例字段在子对象前面。另外如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
填充
JVM中,对象大小默认为8的整数倍,若对象大小不能被8整除,则会填充空字节来填充对象保证。
对象生命周期
在了解完对象头部后,我们看下对象的创建的时候发生了什么事情。当我们调用newObject()创建一个对象时,生成的字节码如下
0new#2java/lang/Object3dup4invokespecial#1java/lang/Object.init
首先通过new指令分配对象,并将对象地址入栈,通过dup指令复制一份栈顶元素。通过invokespecial指令调用对象的init进行初始化会消耗栈顶2个槽。由于init方法需要传入一个参数,该参数即为引用对象本身。在init初始化时会将this指针进行赋值。这样我们在代码中就可以通过this指向当前对象。
对象创建流程如下图所示。
栈上分配通常对象都是在堆上创建的,若对象仅在当前作用域下使用,那么使用完很快就会被GC回收。JVM通过逃逸分析对对象作用域进行分析,如果对象仅在当前作用域下使用,则将对象的实例数据分配在栈上,从而提升对象创建速度的同时减少GC回收的对象数量。线程局部缓冲区(TLAB)如果无法在栈上分配,则对象会在堆上分配。对于JDK1.8来说,Java堆通常使用分代模型,(关于GC,垃圾回收算法等这里不做具体讨论)。经过统计,90%的对象在使用完成后都会被回收,因此默认新生代会分配10%的空间给幸存者区。对象先在eden区进行分配,但是我们知道,堆是所有线程共享的区域,会存在多线程并发问题。因此在堆上分配就需要进行线程同步。为了提高分配效率,JVM会为每个线程从eden区初始化一块堆内存,该内存是线程私有的。这样每次分配对象时就无需进行同步操作,从而提高对象分配效率。线程的这块局部内存区域被称为线程局部缓冲区(TLAB)。通常这块内存会小于eden区的1%。当这块内存用完时,就会重新通过CAS的方式为线程重新分配一块TLAB。通常对象分配有两种方式,一种是线性分配,当内存是规整时(大部分垃圾回收器新生代都是用标记清理算法,可以保证内存规整),通过一个指针向后移动对象大小,直接分配一块内存给对象,指针左边是已使用的内存,指针右边是未使用的内存,这种方式被称为指针碰撞。TLAB配合指针碰撞技术能够在线程安全的情况下移动一次指针直接就可以完成对象的内存分配。当内存不规整时(比如CMS垃圾回收器通常情况并不会每次GC后都压缩内存,会存在内存碎片),则需要一块额外的内存记录哪些内存是空闲的,这个缓存被称为空闲列表。eden区分配如果TLAB无法分配对象,那么对象只能在Eden区直接分配,前面说过,在堆上分配,必须采用同步策略避免有产生线程安全问题。如果分配内存时,对象的klass没有解析过,则需要先进行类加载过程,然后才能分配对象。这个过程被称为慢速分配,而如果klass已解析过则直接可以分配对象,这个过程被称为快速分配。老年代分配当eden区放不下对象时(当然还有其他的判断策略,这里暂时不去关心),对象直接分配到老年代。对象实例初始化当对象完成内存分配时,就会初始化对象,将内存清零。需要注意,对象的静态变量在类初始化的初始化阶段已经完成设置。初始化对象头部当对象实例初始化完,就会设置对象头部,默认的对象头部存放在klass,如果启用了偏向,则设置的就是可偏向的对象头。对象访问方式
现在我们了解了对象的内存布局和对象的创建逻辑,那么对象在运行时,如何通过栈的局部变量找到实际的对象呢?常用的对象访问方式有2种,直接指针访问和句柄访问。
直接指针访问
对象创建时,局部变量表只保存对象的地址,地址指向的是堆中的实际对象的markword地址,JVM中采用的就是这种方式访问对象。
句柄访问
通过句柄访问时局部变量保存的时句柄池的对象句柄,句柄池中,则会存储对象实例指针和对象类型指针。再通过这两个指针分别指向对象实例池中的对象和元数据的klass。
相比直接指针访问,这种访问方式由于需要2次访问,而直接指针只需要一次访问,因此句柄访问对象的速度相对较慢。但是对于垃圾回收器来说是比较友好的,因为对象移动无需更新栈中的局部变量表的内容,只需要更新句柄池中的对象实例指针的值。
HSDB
前面我们通过JOL工具可以很方便的输出对象的布局。JDK也提供了一些工具可以查看更详细的运行时数据。HSDB(HotspotDebugger)是JDK1.8自带的工具,使用该工具可以连接到运行时的java进程,查看到JVM运行时的状态。
以该偏向锁代码为例
为了能看到运行时状态,我们可以使用idea工具单笔调试,也可以使用jdb工具进行调试。jdb是Java的调试器,位于%JAVA_HOME%/bin下面。通过jdb-classpathXXXclass名执行main方法。执行后,我们可以将打断点,然后进行调试。
通过stopinclassid.method[(argument_type,...)]在方法中打断点,或者可以通过stopatclassid:line在指定行打断点。通过stopin