JAVA运行时数据区对象创建原理
Java与C++之间有一堵由内存动态分配和自动垃圾回收技术围城的墙,墙里面的人想出来,墙外面的人想出去。
关于虚拟机的内存区域和内存异常,分两个部分,第一个部分是运行时数据区及其对应的异常类型,第二个部分是在内存区域中创建对象的原理。本篇主要涉及第二个部分,第一个部分参考JAVA运行时数据区管理
Java是面向对象语言,在Java程序运行过程中无时无刻都有对象产生。在语言层面,创建对象通常就是一个new 关键字而已,但是在虚拟机层面,对象的创建过程是什么样的呢,创建对象后对象在内存中的分布存储是什么样的,如何根据对象引用找到对象实例。
对象创建
在代码层面,创建对象通常如下:Object obj = new Object();
但是这行代码虚拟机是如何执行的呢?虚拟机创建一个对象可分为5个步骤:
- 解析new指令
- 堆中分配内存
- 对分配的内存空间初始化为零值
- 虚拟机对对象进行设置
- 执行<init>方法进行程序层面的初始化
解析new指令
当虚拟机遇到一条new指令的时候,根据指令参数,去检查指令参数在常量池中能否找到一个类的符号引用。如果没有则说明编译阶段出错,是不会进入运行阶段的。同时检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有则执行相应的类加载过程操作(类加载的过程,请参考虚拟机类加载机制)。
堆中分配内存
当类加载检查通过后,虚拟机就会为新对象在java堆中分配内存。分配内存就是根据对象的大小,划分相同大小的堆内存空间,然后将对象引用指向(并不一定是直接指向,也有可能通过句柄)该块内存。但是这里就会有几个问题:
-
如果java堆内存是规整的
也就是在java堆内存中有一个指针作为分界点,指针一侧是已经分配的内存,另一侧是未分配的内存。此时将指针想未分配的一侧进行申请对象大小的内存地址的偏移即可,通过地址偏移标记该内存地址已经被分配。这种方式叫做“指针碰撞”(Bump the Pointer) -
如果java堆内存是不规整的
在物理地址上,就会分配与未分配是交叉出现的,并且每块的地址大小也不相同。此时就需要一个列表,来维护哪块地址是被分配的,哪块地址是分配的。在未分配的空间中找到足够大的内存空间进行分配,同时更新列表。如果所有的空闲内存都不够分配,这里就设计内存碎片的产生和整理,在此不展开讨论。这种方式叫做“空闲列表”(Free List)。
到底采用哪种分配方式,是有java堆内存是否规整决定的,而java堆内存是否规整有时有垃圾收集器是否带有压缩整理功能决定的。因此在使用Serial、ParNew等带有Compact过程的垃圾收集器时采用的是指针碰撞分配方式,而在使用CMS这种基于Mark-Sweep算法的垃圾收集器时采用的是空闲列表分配方式。 -
并发申请内存的保证
如果在多线程编程场景中,会存在多个线程并发的来申请内存,那么如何保证多线程申请内存地址不会相互冲突呢,因为分配内存的动作并不是原子性的,仅仅是修改指针地址也是线程不安全的。
目前有两种方案来解决:一种是对分配内存空间的动作进行同步操作,其实虚拟机使用的就是CAS+失败重试的机制保证地址分配更新的原子性;另一种方式就是第一部分(JAVA运行时数据区管理)提到的TLAB方式,虽然堆是所有线程贡献的,但是也会分配线程私有的本地线程分配缓冲,每个线程都有自己的私有缓冲,每个线程申请空间就在自己私有的空间中申请,只有当私有空间不足时才使用同步锁。是否使用TLAB,在虚拟机启动的时候加参数-XX:+/-UseTLAB即可。
对分配的内存空间初始化为零值
内存分配完成后,需要对所分配的内存空间都初始化为零值,这里不包括对象头(对象头是什么在下文的对象布局中会有详细的说明)。如果使用了TLAB,则这一步也有可能在分配TLAB是就完成。
虚拟机对对象进行设置
初始化零值完成后,虚拟机还需要对对象进行必要的设置,比如该对象是哪个类的实例,如何才能找到对象的类的元数据,对象的哈西码,GC分代年龄信息等。这些都是放在对象头中的。
执行<init>方法进行程序层面的初始化
执行到上一步,对虚拟机来说对象刚已经创建完成了,但是从java程序来看,对象还没有执行<init>方法,所有字段都为零。此时执行<init>方法,之后java程序就可以使用该对象了。
对象的创建总结来说就是上面的过程,那么创建完成后,对象在内存中的布局如何,如上文提到的对象头又是什么?
对象布局
这里以HotSpot虚拟机为例,对象在内存中的布局分为3块:
- 对象头
- 对象实例数据
- 对齐填充
对象头
正如上文所说,我们是如何找到该对象实例,对象实例的类的元数据是什么,这些都是在对象头中设置的。对象头分为两部分信息
- 对象自身运行时数据信息
这部分数据主要包括对象的类的元数据,对象的哈西码,GC分代年龄信息、锁状态标志、线程持有的锁、偏向线程ID,偏向时间戳等。这部分数据32为和64为的操作系统中长度分别是32bit和64bit,官方成为“Mark Word”。但是对象需要存储的自身运行时数据是非常多的,已经超出了32bit和64bit所能存储的最大限度。而我们也明白,这些数据是与对象自身定义的数据也就是我们真正想要存储的值(比如int value=4这个4)是没有关系的,所以考虑到成本、空间效率,“Mark Word”被设计成一个非固定的数据结构,以便在极小的空间内存储更多的信息,它会根据对象的不同状态复用自己的内存空间。如下表:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁定记录的指针 | 00 | 轻量级锁定 |
指向中量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标志 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
- 类型指针
指向对象类的元数据的指针。虚拟机通过该指针来确定对象是哪个类的实例。并不是所有的虚拟机的实现都需要在对象信息熵保留类型指针,也就是查找对象实例的类的原信息并不都一定通过类型指针,也有可能是句柄。如果对象是数组,还需要在对象头中存储数组的长度。
实例数据
实例数据才是我们程序或者程序员需要的真正存储的有效信息。不管是从父类继承下来的,还是在子类中定义的,都需要记录下来。而这些数据存储顺序与虚拟机分配策略参数和在java代码中的定义声明顺序有关。HotSpot虚拟机的默认分配策略参数是long/double,ints、shorts/chas、bytes/booleans、oops(Ordinay Object Pointers),可以看出是相同宽度的数据类型放在一起,在满足该条件下,从父类继承的变量是会出现在子类之前。如果CompactField参数为true(默认就是true),那么子类中宽度较窄的变量也会插在父类变量的空隙中。
对齐填充
对齐填充不是必然存在的,没有太多的意义,只是作为占位符存在。HotSpot虚拟机规定对象的起始地址必须是8字节的整数倍,换句话说对象的大小必须是8字节的整数倍。因此当实例数据部分没有对齐时,就需要对齐填充。
看有的地方说对象头部分正好是8字节的1倍或者2倍,但是对象都是32bit后者64bit,其实应该是4字节的1倍或者2倍才对。
对象定位
对象被创建和分配到内存中后,我们就需要使用对象。java的程序需要通过在栈上的引用类型,找到该对象的真实地址,进而使用该实例的数据。那么这个过程在虚拟机中是如何的呢?
java虚拟机规范中之规定了这个reference类型指向对象的应用,并没有规定这个引用(其实就认为是一个地址)到底如何去定位、访问堆中对象的具体位置。所以不同的虚拟机实现也不相同。目前主流的有如下两种方式:
-
使用句柄访问
需要在java堆中划分一块地址,作为句柄池,栈中的引用对象存储的就是句柄池中句柄的地址,而句柄包含了对象实例数据和元数据(类型数据)的地址信息
image.png -
使用直接指针访问
在栈中的引用对象存储的就是对象在堆中的对象的地址。而此时,对象在堆中除了存放实例数据外,还需要考虑如何存放类型数据的相关信息,存放的也是类型数据的地址。
image.png
两种方式各有优势。使用句柄来访问最大的好处就是reference中存储的信息是稳定的句柄地址,在对象被移动改变的时候只会修改句柄中的数据,而reference数据不用修改。垃圾收集回收时,对象被移动是非常普遍的行为;使用直接内存访问的最大优势是速度更快。java程序在运行中创建对象是非常普遍并且高频发生的操作,那么对象的访问定位也是非常频繁的。直接内存相比于句柄方式,减少了一次访问内存的次数,在频繁的访问对象的过程中,积少成多这种减少内存访问次数带来的效率也是非常可观的。