在Java运行时数据区中,我们知道了虚拟机内存的概况,本文介绍虚拟机内存中的数据的其它细节,如对象如何创建、如何布局以及如何访问。
基于实用的原则,这里以HotSpot虚拟机和常用的内存区域Java堆为例,来探讨HotSpot虚拟机在Java堆中对象的分配、布局和访问的全过程。
一、对象的创建
1.分配内存
虚拟机遇到new指令时,首先检查该指令的参数是否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。可参考类加载机制。
类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
创建对象的过程需要考虑两个很重要的问题:如何划分内存空间,和如何保证创建对象时的线程安全
(1)如何划分内存空间
内存分配方式有两种:
①指针碰撞(Bump the Pointer)分配方式
假设Java堆中内存是绝对规整的,所有用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点,那分配内存就仅仅是把该指针向空闲的那边挪动一段与对象大小相等的距离,这种分给方式称为指针碰撞。
②空闲列表(Free List)分配方式
如果Java堆中的内存并不是规整的,已使用内存与空闲内存相互交错,就不能简单进行指针碰撞了。虚拟机必须维护一个列表,记录那些内存是可用的,在分配时从列表中找到一块足够大的空间分配给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整是由采用的垃圾收集器是否带有压缩整理功能决定的。因此,使用Serial、ParNew等带有Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
(2)创建对象时如何保证线程安全
有两种方案
方案①:对分配内存空间的动作进行同步处理
虚拟机采用CAS加失败重试的方式保证更新操作的原子性
方案②:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一个小块内存,称为本地线程分配缓冲(TLAB,thread LocalAllocation Buffer)。哪个线程要分配内存就在哪个线程的TLAB上分配,只有TLAB用完并重新分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序访问到这些字段的数据类型所对应的零值。
2.虚拟机对对象进行设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象时哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头中。
根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
3.init初始化
上面工作都完成后,从虚拟机视角看,对象已经创建成功了,但从Java程序的视角看,对象还没进行初始化,所有的字段都还为零值,接下来执行<init>进行初始化。
所以一般来说(由字节码中是否跟随invokespecial指令决定),执行new指令后会接着执行<init>方法,按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建完成。
二、对象的内存布局
在 HotSpot 虚拟机中,对象在内存中存储布局分为 3 块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头
HotSpot
虚拟机对象的对象头一般包含两部分信息,第一部分用于存储对象自身的运行时数据,例如HashCode
、GC
分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等信息。官方称这部分数据为Mark Word
。在32
位和64
位的JVM
中,这部分数据分别为32bit
和64bit。
另一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM
通过这个指针确定对象是哪个类的实例。在32
位JVM
中,指针的长度为32bit
,在未开启压缩指针的64
位JVM
中,该指针的长度为64bit
,如果开启压缩指针,那么为32bit
。
需要注意的是:如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,并且这部分数据也随着JVM
位数的不同而不同:32
位的JVM
上,该区域的长度为32bit
,在64
位未开启压缩指针的JVM
中,这部分数据的长度为64bit
,否则为32bit
。
实例数据
实例数据部分是对象真正存储有效信息的区域,存储了代码中定义的各种字段的内容,包括从父类继承下来的字段和子类中定义的字段。
对齐填充
对齐填充这部分不是必须存在的,这部分仅仅是起着占位符的作用。由于HotSpot
虚拟机的自动内存管理系统要求对象的起始地址必须是8
字节的整数倍,因此当对象实例部分数据没有对齐时,就需要对剩余的部分进行填充。
三、对象的访问定位
Java堆上的对象通过需要通过栈上的reference(引用)数据来操作。由于JVM规范中只规定了一个指向对象的引用,并没定义这个引用应该通过哪种方式去定位、访问堆中的对象的具体位置,所以对象访问方式取决于虚拟机的实现方式。目前主流的访问方式有使用句柄和直接指针两种。
1.使用句柄访问。
Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
2.使用直接指针访问(HotSpot虚拟机使用该方式)
reference中存储的直接就是对象地址。Java堆对象的布局必须考虑如何放置访问类型数据的相关信息。
两种访问方式各有优点。
使用句柄访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动(如垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
使用直接指针访问方式的最大好处就是速度更快,因为节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,这种靠小积少成多也是非常可观的执行成本。
总结
1.HotSpot虚拟机在Java堆上创建对象的过程?
①分配内存
②虚拟机设置对象头
③对象初始化
2.分配内存空间的两种方式?
指针碰撞和空闲列表。
3.如何保证线程安全?
4.通过引用访问对象的过程?有哪两种方式?各有什么优点?Hotspot使用哪种方式?