面试官:详细完整的说说对象实例化过程!
你知道的越多,不知道的就越多,业余的像一棵小草!
你来,我们一起精进!你不来,我和你的竞争对手一起精进!
编辑:业余草
juejin.cn/post/6919694056071118861
推荐:https://www.xttblog.com/?p=5319
对象的实例化过程需要做哪些工作呢?首先 Java 是一门面向对象的语言,类是对所属于一类的所有对象的抽象,对象的所有结构化信息都定义在了类中,因此对象的创建需要根据类中定义的类型信息,也就是类所对应的 class 二进制字节流,所以这就涉及到了类的加载与初始化。其次,对象大多存储在堆内存中,这就涉及到内存的分配。除此之外,还有变量的初始化零值,对象头的设置,在栈中创建对象的引用等等,本文我们来一起详细的分析一下对象的完整实例化过程。
整体流程
从整天上来看对象的整个实例化过程如下图所示:
为了故事的顺利发展,这里我们定义一个 Demo,并据此详细讨论一下 dc 对象是如何创建并实例化出来的。
public class Demo
{
public static void main(String[] args)
{
DemoClass dc=new DemoClass();
}
}
class DemoClass
{
private static final int a=1;
private static int b=2;
private static int c;
private int d=4;
private int e;
static
{
c=3;
}
public DemoClass()
{
e=5;
}
}
类初始化检查
这里我们使用 new 关键字创建对象,Java 中创建对象的方式还有好多种,比如反射,克隆,序列化与反序列化等等。这些方式不一而同,但是经过编译器编译之后,对应到 Java 虚拟机中其实就是一条 new(这里的 new 指令与前面提到的 new 关键字不同,这是虚拟机级别的指令)指令。当 Java 虚拟机碰到一条 new 指令时,会首先根据这条指令所对应的参数去常量池中查找是否有该类所对应的符号引用,并判断该类是否已经被加载、解析、初始化过,也就是到方法区中检查是否有该类的类型信息,如果没有,首先要进行类加载与初始化。如果类已经加载和初始化,那么继续后续的操作。
这里假设 DemoClass 类还没有被加载与初始化,也就是方法区中还没有 DemoClass 的类型信息,这时需要进行 DemoClass 类的加载与初始化。
类加载过程
类加载过程总的可分为7个步骤:加载、验证、准备、解析、初始化、使用、卸载。这里我们看一下前六个阶段。
加载
加载阶段主要干了三件事:
根据类的全限定名获取类的二进制字节流。
将二进制字节流所代表的静态存储结构转化为方法区中运行时数据结构。
在内存中创建一个代表该类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
具体到这里就是首先根据 package.DemoClass 全限定名定位 DemoClass.class 二进制文件,然后将该 .class 文件加载到内存进行解析,将解析之后的结果存储在方法区中,最后在堆内存中创建一个 Java.lang.Class 的对象,用来访问方法区中加载的这些类信息。
验证
验证阶段完成的任务主要是确保 class 文件中字节流中包含的信息符合 Java 虚拟机的规范,虽然说得很简单,但是 Java 虚拟机进行了很多复杂的验证工作,总的来说可分为四个方面:
文件格式验证
元数据验证
字节码验证
符号引用验证
具体到这里就是对于加载进内存的 DemoClass.class 中存储的信息进行虚拟机级别的校验,以确保 DemoClass.class 中存储的信息不会危害到 Java 虚拟机的运行。
准备
准备阶段完成的工作就是为类变量(也就是静态变量)分配内存并赋予初始值,通常情况下是变量所对应的数据类型的零值。但是在这个阶段,被 final 修饰的变量也就是常量会在这个阶段准确的被赋值。
具体到这里,在这个阶段 DemoClass 中的 a 会被赋值为 1,b 与 c 均被赋值为 0。
解析
这个阶段主要的任务是将常量池中的符号引用替换为直接引用。
初始化
在之前的阶段中,除了加载阶段通过自定义的类加载器可以干预虚拟机的加载过程外,其他的阶段都是虚拟机完全主导,而在初始化阶段才开始根据程序员的意愿执行类的初始化,这个阶段主要完成的工作是执行类构造器方法(),同时虚拟机会保证执行该类的类构造器方法时,其父类的类构造器方法已经被正确的执行,同时,由于类的初始化只进行一次,当多个线程并发的进行初始化时,虚拟机可以确保多个线程只有一个可以完成类的初始化工作, 保证线程安全工作。
具体到 DemoClass类,在这个阶段会将 b 赋值为 2,c 赋值为 3。
分配内存
当类加载过程完成后,或者类本身之前已经被加载过,下一步就是虚拟机要为新生对象分配内存。对象所需要的内存空间在类加载过程完成后就可以完全确定下来,为对象分配内存空间就相当于从堆内存中划分出一块合适的内存来,分配内存的主要方式有两种:指针碰撞和空闲列表。
指针碰撞:这种方式将堆内存分为空闲空间与已分配空间,使用一个指针来作为二者之间的分界线,当要为新生对象分配内存空间的时候,相当于将指针向着空闲空间的方向移动一段与对象大小相等的距离,可见这种分配方式 Java 堆内存必须是规整的,所有空闲空间在一边,已分配空间在另外一边。
空闲列表:在虚拟机中维护一个列表,用来记录堆中哪一块内存是空闲可用的,在为新生对象分配内存时,从列表中寻找一块合适大小的可用内存块,分配完成后更新空闲列表,这种方式下堆内存的空闲空间与分配空间可以交错存在。
从上面来看,选择采用指针碰撞还是空闲列表法分配内存,主要由 Java 堆内存是否规整决定的,而 Java 堆内存是否规整又取决于所采用的垃圾收集算法,这就涉及到垃圾回收机制(可见知识都是相通的,程序员就是活到老学到死啊!),GC 之后是否具有压缩或者整理的动作等等。
同时,由于创建对象的动作是十分频繁的,多线程可能存在多个线程同时申请为对象分配内存空间,这个时候如果不采取一定的同步机制,就有可能导致一个线程还未来得及修改指针,另一个线程就使用了原来的指针分配内存空间,因此衍生出来了两种解决方案:CAS 配上失败重试、TLAB 方式。
第一种方式很好理解,多个线程使用 CAS 的方式更新指针,多线程下只有一个线程可以更新完成,其他线程通过不断重试完成内存指针的重新移动。
第二种方式是每个线程提前分配一块内存空间,这个内存空间就是线程本地缓冲 TLAB,这样线程每次要分配内存时,先去 TLAB 中获取,当 TLAB 中内存空间不足的时候才采用同步机制继续申请一块 TLAB 空间,这样就降低了同步锁的申请次数。
具体到这个阶段,是在堆内存中为 DemoClass 对象,也就是 dc 对象实例开辟了一块内存空间。
初始化零值
在为对象分配内存完成之后,虚拟机会将分配到的这块内存初始化为零值,这样也就使得 Java 中的对象的实例变量可以在不赋初值的情况下使用,因为代码所访问当的就是虚拟机为这块内存分配的零值。
具体到这里,就是 Java 虚拟机将上面分配的内存空间初始化为零值,这一步使得现在 DemoClass 中的 d 与 e 均被赋值为 0。
设置对象头
对象头就像我们人的身份证一样,存放了一些标识对象的数据,也就是对象的一些元数据,我们首先看一下对象的构成。
在初始化了零值之后,怎么知道对象是哪个类的实例,就需要设置指向方法区中类型信息的指针,对象 Mark Word 中相关信息的设置,就在这个阶段完成。
实例对象初始化
这一步虚拟机将调用实例构造器方法(),根据我们程序员的意愿初始化对象,在这一步会调用构造函数,完成实例对象的初始化。
具体到这里就是 DemoClass 的 d 被赋值为 4,e 被赋值为 5。
创建引用,入栈
执行到这一步,堆内存中已经存在被完成创建完成的对象,但是我们知道,在 Java 中使用对象是通过虚拟机栈中的引用来获取对象属性,调用对象的方法,因此这一步将创建对象的引用,并压如虚拟机栈中,最终返回引用供我们使用。
在这里就是讲对象的引入入栈,并返回赋值给 dc,至此,一个对象被创建完成。
对象实例化的完整流程
根据上面的讨论,我们再来回顾一下对象实例化的整个流程: