类的加载过程

1 类的加载过程简介

ClassLoader的主要职责就是负责加载各种class文件到JVM中,ClassLoader是一个抽象的class,给定一个class的二进制文件名,ClassLoader会尝试加载并且在JVM中生成构成这个类的各个数据结构,然后使其分布在JVM对应的内存区域中

类的加载过程一般分为三个比较大的阶段,分别是加载阶段、连接阶段和初始化阶段

在这里插入图片描述

  • 加载阶段:主要负责查找并且加载类的二进制数据文件,其实就是class文件。
  • 连接阶段:连接阶段所做的工作比较多,细分的话还可以分为如下三个阶段。
  • 验证:主要是确保类文件的正确性,比如class的版本,class文件的魔术因子是否正确。
  • 准备:为类的静态变量分配内存,并且为其初始化默认值
  • 解析:把类中的符号引用转换为直接引用。
  • 初始化阶段:为类的静态变量赋予正确的初始值(代码编写阶段给定的值)

当一个JVM在我们通过执行Java命令启动之后,其中可能包含的类非常多,是不是每一个类都会被初始化呢?答案是否定的,JVM对类的初始化是一个延迟的机制,即使用的是lazy的方式,当一个类在首次使用的时候才会被初始化,在同一个运行时包下,一个Class只会被初始化一次

2 类的主动使用和被动使用

JVM虚拟机规范规定了,每个类或者接口被Java程序首次主动使用时才会对其进行初始化,当然随着JIT技术越来越成熟,JVM运行期间的编译也越来越智能,不排除JVM在运行期间提前预判并且初始化某个类。

2.1 类的主动使用

JVM同时规范了以下6种主动使用类的场景:

  1. ·通过new关键字会导致类的初始化:这种是大家经常采用的初始化一个类的方式,它肯定会导致类的加载并且最终初始化。

  2. 访问类的静态变量,包括读取和更新会导致类的初始化:下面的代码中a是一个简单的静态变量,其他类即使不对Demo进行new的创建,直接访问变量a也会导致类的初始化

    public class Demo {
    
      static {
          System.out.println("I will init");
      }
    
      public static int a = 1;
    
    
    }
    public class Test {
    	  public static void main(String[] args) {
      		// 直接访问变量a 也会输出:I will init
          int b = Demo.a;
      }
    }
    
  3. ·同样的,访问类的静态方法,会导致类的初始化

  4. ·对某个类进行反射操作,会导致类的初始化:

    public class Demo {
    
     static {
         System.out.println("I will init");
     }
    
     public static int a = 1;
    
    
    }
    public class Test{
       public static void main(String[] args) throws ClassNotFoundException {
        // 同样会看到静态代码块中的输出语句执行
         Class.forName("study.wyy.thread.package03.Demo");
      }
    }
    
  5. 初始化子类会导致父类的初始化,这种情况的示例代码如下:

    class Parent {
    
       static {
           System.out.println("parent init");
       }
    }
    
    class Son extends Parent{
       static {
           System.out.println("son init");
       }
    }
    public class Test{
        public static void main(String[] args) {
       	// // 不仅仅会初始化son,也会初始化父类
           new Son();
       }
    }
    

    当然这里需要注意的一点是,通过子类使用父类的静态变量只会导致父类的初始化,子类则不会被初始化:

    class Parent {
        public static int a = 10;
    
        static {
            System.out.println("parent init");
        }
    }
    
    class Son extends Parent{
        static {
            System.out.println("son init");
        }
    }
    
    public class Test{
        public static void main(String[] args) {
            int a = Son.a;
        }
    }
    
  6. 启动类:也就是执行main函数所在的类会导致该类的初始化

2.2 类的被动使用

除了上述6种情况,其余的都称为被动使用,不会导致类的加载和初始化。

2.3 类的主动和被动使用两个易混淆的例子

  1. 构造某个类的数组时并不会导致该类的初始化
public class Test{
    public static void main(String[] args) {
       Demo[] demos = new Demo[10];
    }
}

上面的的代码中new方法新建了一个Demo类型的数组,但是它并不能导致Simple类的初始化,因此它是被动使用;事实上该操作只不过是在堆内存中开辟了一段连续的地址空间

  1. 引用类的静态常量不会导致类的初始化
public class GlobalConstants
{
    static
    {
        System.out.println("The GlobalConstants will be initialized.");
    }

	// 在其他类中使用MAX不会导致GlobalConstants的初始化,静态代码块不会输出
    public final static int MAX = 100;
    // 虽然RANDOM是静态常量,但是由于计算复杂,只有初始化之后才能得到结果,因此在其他类中使用RANDOM会导致GlobalConstants的初始化
    public final static int RANDOM = new Random().nextInt();
}

MAX是一个被final修饰的静态变量,也就是一个静态常量,在其他类中直接访问MAX不会导致GlobalConstants的初始化,虽然它也是被static修饰的,但是如果在其他类中访问RANDOM则会导致类的初始化,因为RANDOM是需要进行随机函数计算的,在类的加载、连接阶段是无法对其进行计算的,需要进行初始化后才能对其赋予准确的值。

3 类的加载过程详解

3.1 思考

public class Singleton
{

    private static Singleton instance = new Singleton();

    private static int x = 0;

    private static int y;

    private Singleton()
    {
        x++;
        y++;
    }

    public static Singleton getInstance()
    {
        return instance;
    }

    public static void main(String[] args)
    {
        Singleton singleton = Singleton.getInstance();
        System.out.println(singleton);
        System.out.println(singleton.x);
        System.out.println(singleton.y);
    }
}

输出:

0
1

稍稍调整一下顺序:


private static int x = 0;

private static int y;

private static Singleton instance = new Singleton();

输出:

1
1

接下来就来解释一下原因。

3.2 类的加载阶段

类的加载就是将class文件中的二进制数据读取到内存之中,然后将该字节流所代表的静态存储结构转换为方法区中运行时的数据结构,并且在堆内存中生成一个该类的java.lang.Class对象,作为访问方法区数据结构的入口:
在这里插入图片描述
类加载的最终产物就是堆内存中的class对象,对同一个ClassLoader来讲,不管某个类被加载了多少次,对应到堆内存中的class对象始终是同一个。虚拟机规范中指出了类的加载是通过一个全限定名(包名+类名)来获取二进制数据流,但是并没有限定必须通过某种方式去获得,比如我们常见的是class二进制文件的形式,但是除此之外还会有如下的几种形式:

  1. 运行时动态生成,比如通过开源的ASM包可以生成一些class,或者通过动态代理java.lang.Proxy也可以生成代理类的二进制字节流。
  2. 通过网络获取,比如很早之前的Applet小程序,以及RMI动态发布等。
  3. 通过读取zip文件获得类的二进制字节流,比如jar、war(其实,jar和war使用的是和zip同样的压缩算法)。
  4. 将类的二进制数据存储在数据库的BLOB字段类型中。
  5. 运行时生成class文件,并且动态加载,比如使用Thrift、AVRO等都是可以在运行时将某个Schema文件生成对应的若干个class文件,然后再进行加载。

注意我们在这里所说的加载是类加载过程中的第一个阶段,并不代表整个类已经加载完成了,在某个类完成加载阶段之后,虚拟机会将这些二进制字节流按照虚拟机所需的格式存储在方法区中,然后形成特定的数据结构,随之又在堆内存中实例化一个java.lang.Class类对象,在类加载的整个生命周期中,加载过程还没有结束,连接阶段是可以交叉工作的,比如连接阶段验证字节流信息的合法性,但是总体来讲加载阶段肯定是出现在连接阶段之前的。

3.3 类的连接阶段

类的连接阶段可以细分为三个小的过程,分别是验证、准备和解析

3.3.1 验证过程

验证在连接阶段中的主要目的是确保class文件的字节流所包含的内容符合当前JVM的规范要求,并且不会出现危害JVM自身安全的代码,当字节流的信息不符合要求时,则会抛出VerifyError这样的异常或者是其子异常。

主要验证一下信息:

  1. 验证文件格式
    • 在很多二进制文件中,文件头部都存在着魔术因子,该因子决定了这个文件到底是什么类型,class文件的魔术因子是0xCAFEBABE
    • 主次版本号,Java的版本是在不断升级的,JVM规范同样也在不断升级,比如你用高版本的JDK编译的class就不能够被低版本的JVM所兼容,在验证的过程中,还需要查看当前的class文件版本是否符合当前JDK所处理的范围。
    • 构成class文件的字节流是否存在残缺或者其他附加信息,主要是看class的MD5指纹(每一个类在编译阶段经过MD5摘要算法计算之后,都会将结果一并附加给class字节流作为字节流的一部分)。
    • 常量池中的常量是否存在不被支持的变量类型,比如int64。
    • ·指向常量中的引用是否指到了不存在的常量或者该常量的类型不被支持。
    • 其他信息。

JVM对class字节流的验证远不止于此,由于获得class字节流的来源各种各样,甚至可以直接根据JVM规范编写出一个二进制字节流,对文件格式的验证可以在class被初始化之前将一些不符合规范的、恶意的字节流拒之门外,文件格式的验证充当了先锋关卡。

  1. 元数据验证
    元数据的验证其实是对class的字节流进行语义分析的过程,整个语义分析就是为了确保class字节流符合JVM规范的要求。

    • 检查这个类是否存在父类,是否继承了某个接口,这些父类和接口是否合法,或者是否真实存在。
    • 检查该类是否继承了被final修饰的类,被final修饰的类是不允许被继承并且其中的方法是不允许被override的。
    • 检查该类是否为抽象类,如果不是抽象类,那么它是否实现了父类的抽象方法或者接口中的所有方法。
    • 检查方法重载的合法性,比如相同的方法名称、相同的参数但是返回类型不相同,这都是不被允许的。
    • 其他语义验证。
  2. 字节码验证
    当经过了文件格式和元数据的语义分析过程之后,还要对字节码进行进一步的验证,该部分的验证是比较复杂的,主要是验证程序的控制流程,比如循环、分支等。

    • 保证当前线程在程序计数器中的指令不会跳转到不合法的字节码指令中去。
    • 保证类型的转换是合法的,比如用A声明的引用,不能用B进行强制类型转换。
    • 保证任意时刻,虚拟机栈中的操作栈类型与指令代码都能正确地被执行,比如在压栈的时候传入的是一个A类型的引用,在使用的时候却将B类型载入了本地变量表
    • 其他验证。
  3. 符号引用验证
    在类的加载过程中,有些阶段是交叉进行的,比如在加载阶段尚未结束之前,连接阶段可能已经开始工作了,这样做的好处是能够提高类加载的整体效率,同样符号引用的验证,其主要作用就是验证符号引用转换为直接引用时的合法性。

    • 通过符号引用描述的字符串全限定名称是否能够顺利地找到相关的类。
    • 符号引用中的类、字段、方法,是否对当前类可见,比如不能访问引用类的私有方法。
    • 其他。

符号引用的验证目的是为了保证解析动作的顺利执行,比如,如果某个类的字段不存在,则会抛出NoSuchFieldError,若方法不存在时则抛出NoSuchMethodError等,我们在使用反射的时候会遇到这样的异常信息。

3.3.2 准备过程

当一个class的字节流通过了所有的验证过程之后,就开始为该对象的类变量,也就是静态变量,分配内存并且设置初始值了,类变量的内存会被分配到方法区中,不同于实例变量会被分配到堆内存之中。

所谓设置初始值,其实就是为相应的类变量给定一个相关类型在没有被设置值时的默认值:

byte: 0;
char: '\u0000'
short: 0
int: 0
float: 0.0F
double: 0,.0d
long: 0L
boolean: false
引用类型: null
public class LinkedPrepare
{
	// static int a=10在准备阶段不是10,而是初始值0
    private static int a = 10;
    // final static int b则还会是10,因为final修饰的静态变量(可直接计算得出结果)不会导致类的初始化,是一种被动引用,因此就不存在连接阶段了。
    private final static int b = 10;
}

3.2.3 解析过程

在连接阶段中经历了验证、准备之后,就可以顺利进入到解析过程了,当然在解析的过程中照样会交叉一些验证的过程,比如符号引用的验证,所谓解析就是在常量池中寻找类、接口、字段和方法的符号引用,并且将这些符号引用替换成直接引用的过程

解析过程主要是针对类接口、字段、类方法和接口方法这四类进行的,分别对应到常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、Constant_Methodref_info和Constant_InterfaceMethodred_info这四种类型常量。

  1. 类接口解析

    • 前文代码中的Demo,不是一个数组类型,则在加载的过程中,需要先完成对Demo类的加载,同样需要经历所有的类加载阶段。
    • 如果Demo是一个数组类型,则虚拟机不需要完成对Demo的加载,只需要在虚拟机中生成一个能够代表该类型的数组对象,并且在堆内存中开辟一片连续的地址空间即可。
    • 在类接口的解析完成之后,还需要进行符号引用的验证。
  2. 字段的解析

    • 如果类本身就包含某个字段,则直接返回这个字段的引用,当然也要对该字段所属的类提前进行类加载。
    • ·如果类中不存在该字段,则会根据继承关系自下而上,查找父类或者接口的字段,找到即可返回,同样需要提前对找到的字段进行类的加载过程。
    • 如果类中没有字段,一直找到了最上层的java.lang.Object还是没有,则表示查找失败,也就不再进行任何解析,直接抛NoSuchFieldError异常

      这样也就解释了子类为什么重写了父类的字段之后能够生效的原因,因为找到子类的字段就直接初始化并返回了。

  3. 类方法的解析
    类方法和接口方法有所不同,类方法可以直接使用该类进行调用,而接口方法必须要有相应的实现类继承才能够进行调用。

    • 若在类方法表中发现class_index中索引的某个类是一个接口而不是一个类,则直接返回错误。
    • 在类中查找是否有方法描述和目标方法完全一致的方法,如果有,则直接返回这个方法的引用,否则直接继续向上查找。
    • 如果父类中仍然没有找到,则意味着查找失败,程序会抛出NoSuchMethodError异常。
    • ·如果在当前类或者父类中找到了和目标方法一致的方法,但是它是一个抽象类,则会抛出AbstractMethodError这个异常。
  4. 接口方法的解析
    接口不仅可以定义方法,还可以继承其他接口

    • 在接口方法表中发现class_index中索引的某个接口是一个类而不是一个接口,则会直接返回错误,因为方法接口表和类接口表所容纳的类型应该是不一样的,这也是为什么在常量池中必须要有Constant_Methodref_info和Constant_InterfaceMethodred_info两个不同的类型。
    • 接下来的查找就和类方法的解析比较类似了,自下而上的查找,直到找到为止,或者没找到抛出NoSuchMethodError异常。

3.4 类的初始化阶段

3.4.1 类初始化方法

类的初始化阶段是整个类加载过程中的最后一个阶段,在初始化阶段做的最主要的一件事情就是执行clinit方法的过程(clinit是class initialize前面几个字母的简写)在clinit方法中所有的类变量都会被赋予正确的值,也就是在程序编写的时候指定的值。

clinit方法是在编译阶段生成的,也就是说它已经包含在了class文件中了,clinit中包含了所有类变量的赋值动作和静态语句块的执行代码,编译器收集的顺序是由执行语句在源文件中的出现顺序所决定的

另外需要注意的一点是,静态语句块只能对后面的静态变量进行赋值,但是不能对其进行访问

在这里插入图片描述
另外clinit方法与类的构造函数有所不同,它不需要显示的调用父类的构造器,虚拟机会保证父类的clinit方法最先执行,因此父类的静态变量总是能够得到优先赋值:

public class Parent{

    static int a = 10;

    static {
        a = 20;
    }

}
public class Son extends Parent{

    /**
     * 这里使用父类的属性给b赋值
     */
    static int b = a;
}
public class Test{
    public static void main(String[] args) {
        // 输出是20,而不是10,因为父类的静态代码块优先得到了执行
        System.out.println(Son.b);
    }
}

程序的输出是20,而不是10,因为父类的cinit方法优先得到了执行。

Java编译器会帮助class生成clinit方法,但是该方法并不意味着总是会生成,比如某个类中既没有静态代码块,也没有静态变量,那么它就没有生成clinit方法的必要了,接口中同样也是如此,由于接口天生不能定义静态代码块,因此只有当接口中有变量的初始化操作时才会生成clinit方法。

3.4.2 类初始化方法的线程安全问题

clinit方法虽然是真实存在的,但是它只能被虚拟机执行,在主动使用触发了类的初始化之后就会调用这个方法,如果有多个线程同时访问这个方法,那么会不会引起线程安全问题呢?

public class ClassInit
{
    static
    {
        try
        {
            System.out.println("The ClassInit static code block will be invoke.");
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }

    public static void main(String[] args)
    {
    	// 构造五个线程,分别new对象
        IntStream.range(0, 5)
                .forEach(i -> new Thread(ClassInit::new));
    }
}

运行上面的代码,你会发现在同一时间,只能有一个线程执行到静态代码块中的内容,并且静态代码块仅仅只会被执行一次,JVM保证了clinit方法在多线程的执行环境下的同步语义,因此在单例设计模式下,采用Holder的方式是一种最佳的设计方案

3.5 总结

随着JVM的不断升级,其中的一些细节可能会不断变化,但是总体来讲,类的加载过程还是会围绕着二进制文件的加载、二进制数据的连接以及类的初始化这样的过程去进行

解答一下3.1的代码


 private static Singleton instance = new Singleton();

 private static int x = 0;

 private static int y;
 
 private Singleton()
    {
        x++;
        y++;
    }

在连接阶段的准备过程中,每一个类变量都被赋予了相应的初始值:
instance=null, x = 0, y = 0

在类的初始化阶段,需要为每一个类赋予程序编写时期所期待的正确的初始值,首先会进入instance的构造函数中,instance的构造函数之后,各个静态变量的值如下:
instance=Singleton@3f99bd52, x = 1, y = 1

然后,为x初始化,由于x有显式地进行赋值,因此0才是所期望的正确赋值,而y由于没有给定初始值,在构造函数中计算所得的值就是所谓的正确赋值,因此结果又会变成:
instance=Singleton@3f99bd52, x = 0, y = 1

所以最终输出就是:

0
1

调整顺序:

private static int x = 0;

private static int y;

private static Singleton instance = new Singleton();

在连接阶段的准备过程中,每一个类变量都被赋予了相应的初始值:
x = 0, y = 0, instance=null

下面跳过解析过程,来看类的初始化阶段,初始化阶段会为每一个类变量赋予正确的值,也就是执行clinit方法的过程:
x = 0, y=0, instance = new Singleton()

在new Singleton的时候会执行类的构造函数,而在构造函数中分别对x和y进行了自增,因此结果为:
x = 1, y = 1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值