深入类加载机制,浅出对象创建过程

对象创建过程

一、代码运行的三个阶段

本文章基于 官方jdk8语言规范 梳理,其实类加载创建实例化过程没有什么变化。

在研究对象创建过程,代码执行顺序之前,前置知识必须知道代码运行的三个阶段:

  1. source源代码阶段
  2. Class类对象阶段
  3. runtime运行时阶段

image-20220921112928581

核心需要记住的是:

  1. 类加载阶段
  2. 对象创建阶段

二、类加载阶段

提供一个Test.java测试类,从虚拟机启动开始描述整个对象的加载实例化过程。

核心步骤:1.虚拟机启动—>2.加载测试类二进制文件—>3.连接(link)该测试类—>4.初始化Initialize(实例化)—>5.调用main方法—>6.Unloading卸载类—>7.退出程序

1. 虚拟机启动

Java虚拟机通过调用某个指定类或接口的main方法开始执行,向它传递一个字符串数组参数。

public class Test{
    public static void main(String[] args){
        System.out.println("helloWoniu");
    }
}

在使用命令行的主机环境中,通常将类或接口的完全限定名指定为命令行参数,并将随后的命令行参数用作字符串,作为参数提供给方法main。

在IDE中启动该方法后,将会继续执行下面第一步。

2. 加载类Test

在第一步尝试执行类Test的main方法时,虚拟机发现没有加载类Test——也就是说,Java虚拟机当前不包含这个类的二进制表示。

加载 是查找具有特定名称的类或接口类型的二进制表示的过程 ,并创建二进制表示的类或接口的动作。

然后,Java虚拟机使用类加载器来尝试找到这样的二进制表示。如果这个过程失败,就会抛出一个错误。

该类加载过程,在后续进行解释。

本操作对应图中的内容:

image-20220921151129164

其中,类加载过程做了三件事::

1.通过一个类的全限定名来获取定义此类的二进制字节流

- 从zip(jar)包获取

2.将这个字节流所代表的静态存储结构转化为方法区(解释见2.1)的运行时数据结构(解释见2.2)

3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

想要明白上文提到的类加载,需要了解额外知识方法区运行时常量池,符号引用(栈帧中的动态连接需要)。下方内容较难,可以跳过。


2.1 方法区

Java虚拟机有一个方法区域它由所有Java虚拟机线程共享。方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中程序存储区的“文本”段(text segment)。

程序存储区:除了text segment,额外还有data segmentbss segment(Block Started by Symbol)。

方法区存储每个类的结构,如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法(<init>和JDK1.7的<clinit>)用于类和实例初始化以及接口初始化。

方法区域是在虚拟机启动时创建的。

尽管方法区域在逻辑上是堆的一部分,但简单的实现可能选择不进行垃圾收集或压缩。JDK规范中并不强制要求方法区域的位置或用于管理编译代码的策略。方法区域可以是固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,可以进行收缩。

方法区域的内存不需要连续。

所以,根据策略,在JDK1.8开始,使用的Metaspace元空间作为方法区的实现。从堆空间移动到了直接内存(JVM虚拟机以外的计算机剩余物理内存),但是还是可以手动设置元空间的大小,通过这个命令进行:-XX:MaxMetaspaceSize。

额外扩展:

​ 通常元空间内存大小虽然是直接内存,但是核心还是由CompressedClassSpaceSize(配置类空间容量)这个默认的1G大小来控制的,所以我们唯一会触碰到的是 Class Space 空间的上限。

​ 一个类,放入元空间中,占用部分分为 Class SpaceNon-Class Space

​ 元空间可以设置整个计算机内存(除去其他必备内存之外),可它同时又受到类空间容量限制,按每个类约 5-7k 的 Non-Class Space 和 600-900 bytes 的 Class Space,我们可以估算出大约 1-150万个类(假设没有碎片、没有浪费)以后会触碰到 Class Space 的 OOM。

Java虚拟机实现(JDK1.8的元空间就是实现)可以为程序员或用户提供对方法区域初始大小的控制,以及在可变大小的方法区域的情况下,对最大和最小方法区域大小的控制。

以下异常情况与方法区域相关联:

  • 如果方法区域中的内存无法满足分配请求,Java虚拟机将抛出一个OutOfMemoryError
2.2 运行时常量池

Java虚拟机维护每种类型的常量池(见下表),一种运行时数据结构,它服务于传统编程语言实现的符号表。

每个运行时常量池都是在Java虚拟机的方法区域中。类或接口的运行时常量池是在创建类或接口时通过Java虚拟机构造的。

Constant TypeValue
CONSTANT_Class7
CONSTANT_Fieldref9
CONSTANT_Methodref10
CONSTANT_InterfaceMethodref11
CONSTANT_String8
CONSTANT_Integer3
CONSTANT_Float4
CONSTANT_Long5
CONSTANT_Double6
CONSTANT_NameAndType12
CONSTANT_Utf81
CONSTANT_MethodHandle15
CONSTANT_MethodType16
CONSTANT_InvokeDynamic18

3.连接Test类: 验证 ,准备, (可选) 解析

image-20220921151110034

类或接口在连接之前,必须是完全加载的,接下来的连接过程,在初始化开始之前的操作。

连接是获取类或接口的二进制形式,并将其组合到Java虚拟机的运行时状态中,以便可以执行的过程。

验证检查加载的Test是格式良好的,带有适当的符号表。验证还检查实现Test遵循Java编程语言和Java虚拟机的语义要求。如果在验证过程中检测到问题,就会抛出一个错误。

准备包括静态存储和Java虚拟机实现内部使用的任何数据结构的分配,比如方法表、常量池。

解析是检查符号引用的过程Test加载提到的其他类和接口,并检查引用是否正确。

4.初始化Test类: 执行初始值设定

初始化对于类或接口来说,就是执行它的类或接口初始化<init>方法。

类或接口C只能在以下情况下初始化:

  • 任何一个Java虚拟机指令的执行new, getstatic, putstatic,或者invokestatic引用了类。

    在执行new指令时,如果被引用的类尚未初始化,则初始化该类。

    在执行getstatic, putstatic,或者invokestatic指令中,声明已解析的字段或方法的类或接口(如果尚未初始化)将被初始化。

  • 第一次调用java.lang.invoke.MethodHandle实例

  • 类库中某些反射方法的调用。

  • 如果C是一个类,它的一个子类的初始化。

  • 如果C是一个接口,它声明一个非abstract,非static方法,直接或间接实现了C接口。

  • 如果C是一个类,它被指定为Java虚拟机启动时的初始类。

在初始化之前,必须链接一个类或接口,即验证、准备和有选择地解析。

因为Java虚拟机是多线程的,所以类或接口C的初始化需要小心的同步(synchronization),因为一些其他线程可能同时试图初始化相同的类或接口。还存在这样的可能性,继承后的递归实例化请求。

Java虚拟机的实现通过使用以下过程负责同步和递归初始化。它假设Class对象已经过验证和准备,并且Class对象包含指示以下四种情况之一的状态:

  • Class对象已验证并准备好,但未初始化。
  • Class对象正被某个特定的线程初始化。
  • Class对象已完全初始化,可以使用了。
  • Class对象处于错误状态,可能是因为初始化尝试失败。

重点来了:LC锁。

对于每个类或接口C,有一个唯一的初始化锁LC。从C到LC的映射关系由Java虚拟机实现来决定。举个例子,LC可能是C的类对象或与之相关联的监视器锁。

初始化的过程C是这样的:

  1. 在C类或接口实例化时,获取初始化锁LC用于同步。这同步过程会出现等待,直到当前线程可以获取LC.

  2. 如果其他线程正在对Class的对象进行初始化,然后释放LC之前,当前线程将会阻塞,直到其他线程初始化已经完成后,当前线程再重复该初始化过程。

    线程中断状态不受初始化过程执行的影响。

  3. 如果C的Class对象显示正在对其进行递归的初始化请求,同时释放LC并正常完成初始化。

  4. 如果C的Class对象显示C已经初始化完成,则不需要进一步的操作,直接释放LC并正常完成初始化。

  5. 如果Class的对象C处于错误状态,则初始化是不可能完成的。直接释放LC抛出一个错误NoClassDefFoundError.

  6. 除此(前面几条)之外,记录下当前线程正在初始化C的类对象,并释放LC.

    然后,按照字段在类文件结构中出现的顺序,初始化final static属性为默认的常量值(0或者null);

  7. 接下来,如果C是一个类而不是一个接口,并且它的任意一个父类还没有初始化成功。

    如果其中一个父类的初始化由于抛出异常而突然结束,那么获取LC,将C的类对象标记为错误,通知所有等待的线程。然后释放LC,抛出与初始化父类相同的异常。

  8. 接下来,通过查询C类定义的类加载器,来确定C是否启用了断言。

  9. 接下来,执行的类或接口初始化方法C.

  10. 如果类或接口初始化方法的执行正常完成,则获取LC,标记Class的对象C完全初始化后,通知所有等待的线程后,释放LC,并正常完成此初始化过程。

  11. 否则,类或接口初始化方法一定是通过抛出某个异常E而突然完成。如果的异常E不是Error或者它的一个子类,然后创建该类的一个新实例ExceptionInInitializerError随着E作为参数,并使用此对象代替E在接下来的步骤中。如果的新实例ExceptionInInitializerError无法创建,因为OutOfMemoryError发生时,请使用OutOfMemoryError对象来代替E在接下来的步骤中。

  12. 获得LC,标记Class的对象C作为错误,通知所有等待线程,释放LC,抛出上一步确认的异常。

当Java虚拟机实现可以确定类的初始化已经完成时,它可以省略步骤1中的锁获取(以及步骤4/5中的释放同步锁操作)来优化该过程。这种优化策略必须先确认:类以及完成初始化,并且在此情况,从java内存模型的角度看,获取同步锁时,具备的那些happens-before顺序规则(),在执行优化有仍能得到遵守。

5. 使用

无……

6. 卸载

当某个线程调用Runtime类或System类的exit方法,或Runtimehalt方法,并且java安全管理器也允许本次exit或者halt操作。

此外,使用JNI(Java Native interface)规范调用其API来加载或卸载Java虚拟机时,Java虚拟机的终止。

三、对象创建过程

1. 原理图

当new一个对象,进行实例化,复杂版的对象创建过程是:

image-20220921113258054

简略版对象创建过程:

image-20220923154303033

2. 代码执行流程

2.1 代码块和构造方法

根据简略版原理图,首先确认代码块构造方法的执行时机:

package com.j89class;

public class SonClazz  {
    String name;
    int age;
    {
        System.out.println("SonClazz.instance initializer:"+name+"---"+age);
    }
    public SonClazz() {
        this.age=6;
        this.name="蜗牛学苑";
        System.out.println("SonClazz.SonClazz:"+name+"---"+age);
    }
    public static void main(String[] args) {
        new SonClazz();
    }
}
//打印结果:
//SonClazz.instance initializer:null---0
//SonClazz.SonClazz:蜗牛学苑---6
2.2 静态代码块

执行顺序:静态代码块 > 代码块 > 构造方法

静态代码块在类加载过程执行,只能对静态属性进行赋值操作。未赋值则为默认值,在准备阶段进行初始化默认值。

package com.j89class;


public class SonClazz{
    String name;
    int age;
    static int aa;
    static {
        System.out.println("静态代码块执行赋值,只能对静态属性赋值:"+aa);
        aa=123;
        System.out.println("静态代码块执行赋值,只能对静态属性赋值:"+aa);
    }
    {
        System.out.println("普通代码块");
    }
    public SonClazz() {
        this.age=6;
        this.name="蜗牛学苑";
        System.out.println("SonClazz.SonClazz:"+name+"---"+age);
    }
    public static void main(String[] args) {
        new SonClazz();
    }
}

执行结果:

静态代码块执行赋值,只能对静态属性赋值:0
静态代码块执行赋值,只能对静态属性赋值:123
普通代码块
SonClazz.SonClazz:蜗牛学苑---6

如果静态代码块中,new对象,会发生什么呢?

修改上述代码一部分:

    static {
        new SonClazz();
        System.out.println("静态代码块执行赋值,只能对静态属性赋值:"+aa);
        aa=123;
        System.out.println("静态代码块执行赋值,只能对静态属性赋值:"+aa);
    }

打印结果:

普通代码块
SonClazz.SonClazz:蜗牛学苑---6
静态代码块执行赋值,只能对静态属性赋值:0
静态代码块执行赋值,只能对静态属性赋值:123
普通代码块
SonClazz.SonClazz:蜗牛学苑---6

说明类加载过程和实例化对象过程,并非是同步操作,而是异步的。并不会因为加载初始化赋值问题,而无法实例化。不过,普通代码块中,拿到的数据依然都是默认值(包括静态属性)。

2.3 静态属性对象
package com.j89class;


public class SonClazz{
    String name;
    int age;
    static int aa;
    static SonClazz sonClazz = new SonClazz("属性",123);
    static {
        new SonClazz("静态代码块实例化对象",123);
        System.out.println("静态代码块执行赋值,只能对静态属性赋值:"+aa);
        aa=123;
        System.out.println("静态代码块执行赋值,只能对静态属性赋值:"+aa);
    }
    {
        System.out.println("普通代码块");
    }
    public SonClazz() {
        this.age=6;
        this.name="蜗牛学苑";
        System.out.println("SonClazz.SonClazz:"+name+"---"+age);
    }

    public SonClazz(String name, int age) {
        this.name = name;
        this.age = age;
        System.out.println("有参构造,查看静态属性顺序:"+this.name+"--"+this.age);
    }

    public static void main(String[] args) {
        new SonClazz();
    }
}

提供一个有参构造,使用静态属性实例化对象,和静态代码块实例化对象,得出结论:

静态代码块和静态属性,谁在前,先执行谁。

普通代码块
有参构造,查看静态属性顺序:属性--123
普通代码块
有参构造,查看静态属性顺序:静态代码块实例化对象--123
静态代码块执行赋值,只能对静态属性赋值:0
静态代码块执行赋值,只能对静态属性赋值:123
普通代码块
SonClazz.SonClazz:蜗牛学苑---6

交换静态代码块和静态属性位置:再次测试,验证结论;

静态代码块和静态属性是安装顺序从上往下执行

普通代码块
有参构造,查看静态属性顺序:静态代码块实例化对象--123
静态代码块执行赋值,只能对静态属性赋值:0
静态代码块执行赋值,只能对静态属性赋值:123
普通代码块
有参构造,查看静态属性顺序:属性--123
普通代码块
SonClazz.SonClazz:蜗牛学苑---6
2.4 有父类情况

有父类的情况:new一个子类,代码如下:

package com.j89class;

class FatherClazz{
    static {
        System.out.println("父类静态代码块");
    }
    {
        System.out.println("父类代码块!");
    }

    public FatherClazz() {
        System.out.println("父类构造方法");
    }
}

public class SonClazz extends  FatherClazz{
    String name;
    int age;
    static int aa;
    static {
        System.out.println("子类静态代码块");
    }
    {
        System.out.println("子类代码块");
    }
    public SonClazz() {
        this.age=6;
        this.name="蜗牛学苑";
        System.out.println("子类构造方法");
    }
    public static void main(String[] args) {
        new SonClazz();
    }
}

顺序如下:

父类静态代码块
子类静态代码块
父类代码块!
父类构造方法
子类代码块
子类构造方法

静态代码块在类加载的时候执行,优先执行父类!

实例化对象过程,优先实例化父类。

也就是现有父,才能有子。

2.5 父类+子类静态属性对象

子类添加静态属性static SonClazz sonClazz = new SonClazz(); 且静态属性实例化 放在 静态代码块之

这种情况比较特殊:子类静态代码块将不会再父类构造方法之前执行。

package com.j89class;

class FatherClazz{
    static {
        System.out.println("父类静态代码块");
    }
    {
        System.out.println("父类代码块!");
    }

    public FatherClazz() {
        System.out.println("父类构造方法");
    }
}

public class SonClazz extends  FatherClazz{
    String name;
    int age;
    static SonClazz sonClazz = new SonClazz();
    static {
        System.out.println("子类静态代码块");
    }
    {
        System.out.println("子类代码块");
    }
    public SonClazz() {
        this.age=6;
        this.name="蜗牛学苑";
        System.out.println("子类构造方法");
    }

    public static void main(String[] args) {
        new SonClazz();
    }
}

结果如下:

父类静态代码块
父类代码块!
父类构造方法
子类代码块
子类构造方法

子类静态代码块
父类代码块!
父类构造方法
子类代码块
子类构造方法

如果子类添加静态属性static SonClazz sonClazz = new SonClazz(); 且静态属性实例化 放在 静态代码块之;同时静态代码块只会执行一次。

结果如下:

父类静态代码块
子类静态代码块

父类代码块!
父类构造方法
子类代码块
子类构造方法
父类代码块!
父类构造方法
子类代码块
子类构造方法

再次说明,并非是实例化对象就会调用静态代码块,其实还分情况,如果静态属性在静态代码块之前加载,则会先实例化对象,跳过子类静态代码块的执行。

这个答案并非一定是正确的,且听且理解:

类加载和实例化对象并非是同步操作,可能会在加载的时候,判断需要实例化对象,于是,优先进行实例化操作,为连接过程中的解析步骤,提供一个实例化对象的地址,方便把符号引用转换为直接引用,随后进行下一步静态代码块的执行,以及后续的初始化操作。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值