对象的创建过程

对象创建的本质

Java的对象几乎全部存储在Java堆里面,一个对象的创建最通常的方法就是通过new关键词来创建。当jvm遇到一条字节码new指令时,首先要去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用的类是否已经被加载、解析和初始化过。如果没有就会先加载这个类。

一个类在类加载之后,它的实例对象的所需要的内存大小就可以完全确定了,因此jvm创建对象其实就是在堆中找一块固定大小的内存去存储这个对象并进行后续的对象初始化操作。

注意: 这里所说的对象是一个普通的Java对象,并不包括数组和Class对象。

分配对象内存的方式

指针碰撞

如果在Java堆中,被使用的内存被放到一边,空闲的内存被放到另外一边,中间放一个指针作为分界点的指示器,这种堆内存分布叫作“绝对规整”。如果堆内存是绝对规整的,那么分配固定内存大小的对象时,只需要移动中间这个指针即可,这种分配方式就叫做指针碰撞。

空闲列表

堆内存是否绝对规整取决于是否采用的垃圾收集器是否带有空间压缩整理的能力,例如Serial、ParNew等垃圾收集器是有这个功能的,所以这时候就会采用指针碰撞的方式分配对象内存。当采用CMS这类没有自动压缩整理能力的垃圾收集器的话,就只能采用这里说的空闲列表的方来分配对象内存了。这个空闲列表其实就是jvm维护的一个列表,这个列表记录了那些内存块是可用的,在分配对象的时候会从列表中找到一块足够大的空间划分给对象实例,并更新空闲列表上的信息。如果采用空闲列表的分配方式,那维护成本其实还是蛮高的。

分配对象内存的安全问题

除了上面的对象内存分配方式,还有一个重要的问题,就是这两种分配方式是否安全。由于对象的创建是频繁的,那么给对象分配内存就也是频繁的,可能会出现正在给A分配内存的时候,指针还未移动或空闲列表尚未更新,而对象B又同时使用了原来的指针或空闲列表来分配内存。也就是说,在并发情况下,创建对象是线程不安全的。处理这种线程不安全的问题一般有两种方法:同步处理和本地线程分配缓冲。

同步处理

jvm采用CAS加上失败重试的方式保证更新操作的原子性。

本地线程分配缓冲

本地线程分配缓冲简称TLAB,就是在线程创建的时候在堆中为这个线程分配一块独立的空间,这个线程在创建对象分配内存的时候就在这块独立的内存区域给对象分配内存。而如果这个TLAB用完了之后,就会分配新的TLAB,这是才需要同步锁定。

初始化对象空间

在为对象分配好内存空间之后,就需要初始化这块内存了。首先jvm会把这块内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可提前到TLAB分配时顺便进行。正因为初始化为零值这一步操作,才保证了对象的实例字段在Java代码之中可以不赋予初始值就直接使用其默认值。初始化为默认值后,jvm就会对对象进行一些必要的设置,例如:确定这个对象是那个类的实例、找到类的元数据信息、确定对象的哈希码……

在完成初始化默认值和进行对象的必要设置后,jvm就需要执行类的构造函数,也就是Class文件中的()方法。执行构造函数之后,对象的状态信息(比如:字段的值)才是真正按照程序员的意义构造好的。一般来说,new指令之后就会接着执行()方法,按照程序员的意愿对对象进行初始化。至此,一个真正可用的对象才算被完全创建出来。

对象的内存布局

在HotSpot虚拟机中,对象在堆中的存储布局可以分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头

对象头里面的信息与对象自身定义的数据无关,只是描述对象的信息,因此为了考虑虚拟机的空间效率,对象头被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。

对象自身运行时数据

对象自身运行时数据包括:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

类型指针

类型指针指向对象的类型元数据,这个指针用来确定该对象是哪个类的实例。但是并不是所有的虚拟机实现都在对象数据上保留类型指针的,不过HotSpot是有保留的。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

实例数据

实例数据存储的是对象真正存储的有效信息,即定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

对齐填充

对齐填充并不是每个对象空间必然存在的,它没有特别的含义,仅仅起到一个占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是任何对象的大小必须是8的整数倍。对象头已经被精心设计成8字节的整数倍,因此如果实例数据没有对齐的话,就需要填充对齐。

对象的访问定位

对象创建完成之后,就可以进行访问了。Java程序会通过栈上的reference数据来操作堆上的具体对象。reference只是一个指向对象的引用,但具体如何引用主要使用句柄和直接指针来实现。

句柄

如果使用句柄访问对象,Java堆里面一般会划分一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄里面包含了指向对象实例数据与类型数据的地址的指针。通过两个指针就可以分别访问到对象实例数据和对象类型数据。如图所示:
mmexport0960ec989c5e62fa1643d13e87ba8722_1636612162196.png
使用句柄访问的好处是句柄中储存的是稳定的对象地址,当对象被移动时候,只需要更新句柄中的对象实例部分的值即可,句柄本身不用被移动修改。

直接指针

如果采用直接指针的方式,那么reference中存储的直接就是对象的内存地址了。如图所示:
mmexport511f1836bfbab8210cfc2b5efa349716_1636612361666.png
使用直接指针的好处相对于句柄来讲,少了一次指针定位时间的开销,缺点是,当对象被移动时(如进行GC后的内存重新排列),对象的引用(reference)也需要同步更新。

HotSpot使用的是直接指针的方式来访问对象。