转载

聊聊 Java 虚拟机:类的加载过程

我们都知道 Java 源文件通过编译器 javac 命令能够编译生成相应的 class 文件,即二进制字节码文件。Java 虚拟机将描述类或接口的 class 文件(准确地说,应该是类的二进制字节流)加载到内存,对数据进行校验、转换解析和初始化,最终形成能够被虚拟机直接使用的 Java 类型,真正能够执行字节码的操作才刚刚开始。这个过程就是虚拟机的类加载机制。

类的加载过程概述

按照 Java 虚拟机规范,一个 Java 文件(类或接口)从被加载到内存到被卸载出内存的整个生命过程,总共经历 5 个大的阶段:加载、连接(验证+准备+解析)、初始化、使用、卸载。其中第二个阶段“连接”可细分为 3 个阶段:验证、准备和解析。因此很多书籍上也将 Java 类的生命周期划分为 7 个阶段。

聊聊 Java 虚拟机:类的加载过程

类的生命周期

Java 虚拟机动态地加载类和接口,整个加载过程包括加载、连接和初始化 3 个阶段。

  • 加载阶段:根据特定名称查找类或接口类型的二进制数据文件,就是 class 文件,并由此数据文件来创建类或接口的过程。
  • 连接阶段:为了让类和接口可以被 Java 虚拟机执行,而将类或接口并入虚拟机运行时状态的过程。这个阶段做的工作比较多,还可以细分为下面三个阶段:
    • 验证阶段:主要是校验类文件结构上的正确性,确保 class 中的数据信息符合当前虚拟机的约束要求。
    • 准备阶段:为类或接口的静态变量分配内存,并且以初始化这些变量的默认值,这些变量需要使用的内存都在方法区中进行分配。
    • 解析阶段:虚拟机将类在常量池内的符号引用转换为直接引用的过程。
  • 初始化阶段:初始化对于类或接口来说,就是执行它的初始化方法<clinit>(),真正开始执行类中定义的 Java 程序代码,为类的静态变量赋予正确的初始值。

加载、验证、准备和初始化这 4 个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或后期绑定)。另外需要注意的是这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

这里简要说明下 Java 中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,绑定分为静态绑定和动态绑定:

静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对 Java 来说,简单的可以理解为程序编译期的绑定。Java 当中的方法只有 final,static,private 和构造方法是前期绑定的。

动态绑定:即后期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在 Java 中,几乎所有的方法都是后期绑定的。

类的主动引用与被动引用

在整个类加载的过程中,第一个阶段“加载”在虚拟机规范中并没强行约束,这点可以交给虚拟机的具体实现自由设计,但是对于初始化阶段虚拟机规范是严格规定了如下几种情况,如果类未初始化会对类进行初始化(当然加载、验证和准备阶段自然需要在此之前开始)。

1.在执行下列需要引用类或接口的 Java 虚拟机指令时:new、getstatic、putstatic或invokestatic,如果类没有进行过初始化,则需要先触发其初始化。生成这四条指令的常见 Java 代码时机分别是:使用 new 关键字实例化对象的时候,读取或者设置一个类的静态变量(被 final 修饰的静态变量即常量、已经在编译器把结果放入常量池的静态变量除外)的时候,或是调用类的静态方法的时候。

读取或设置类的静态变量会触发类初始化,示例如下:

public class Initialization {

    static {
        System.out.println("init!");
    }

    public static int x = 0;
}

public class Test {

    public static void main(String[] args) {
        System.out.println(Initialization.x);
    }
}
复制代码

输出结果:

init!
0
复制代码

调用类的静态方法会触发类初始化,示例如下:

public class Initialization {

    static {
        System.out.println("init!");
    }

    public static void test() {

    }
}

public class Test {

    public static void main(String[] args) {
        Initialization.test();
    }
}
复制代码

输出结果:

init!
复制代码

2.调用类库 java.lang.reflect 包中某些方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。示例如下:

public class Initialization {

    static {
        System.out.println("init!");
    }
}

public class Test {

    public static void main(String[] args) throws ClassNotFoundException {
        Class.forName("jvm.init.demo.Initialization");
    }
}
复制代码

输出结果:

init!
复制代码

3.在对一个类的某个子类进行初始化的时候,如果发现该类没有进行过初始化,则需要先触发其初始化。

public class Parent {

    static {
        System.out.println("Parent init!");
    }
}

public class Child extends Parent {

    static {
        System.out.println("Child init!");
    }
}

public class Initialization {

    static {
        System.out.println("init!");
    }

    public static void main(String[] args) {
        Child child = new Child();
    }
}
复制代码

输出结果:

init!
Parent init!
Child init!
复制代码

4.在类被选定为 Java 虚拟机启动时的初始类时(包含 main() 的那个类),虚拟机会先初始化这个主类。如上示例所示,虚拟机会先初始化 Initialization 这个主类。

5.在使用 JDK1.7 的动态语言支持下,初次调用 java.lang.invoke.MethodHandle 实例后的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法所对应的类没有进行过初始化,则需要先触发其初始化。

根据虚拟机规范,类或接口首次主动引用时才会对其初始化,且只有以上几种对类主动引用的场景才会触发类的初始化,除此之外,其余的都称为被动引用,且都不会触发初始化。

关于类的主动引用和被动引用,下面举几个容易引起大家混淆的例子来说明什么是被动引用。

  • 定义某个类的数组时不会触发该类的初始化,比如下面的例子:
public class Person {

    static {
        System.out.println("Person init!");
    }

}

public class Test {

    public static void main(String[] args) {
        Person[] p = new Person[10];
        System.out.println(p.length);
    }

}
复制代码

输出结果:

10
复制代码

从结果中并没有看到输出:Person init! 因此,新建 Person 数组并没有触发 Person 类的初始化,是被动引用。

  • 子类引用父类的静态字段,不会导致子类初始化,如下例子:
public class Parent {

    static {
        System.out.println("Parent init!");
    }

    public static String s = "hello";

}

public class Child extends Parent {

    static {
        System.out.println("Child init!");
    }

}

public class Test {

    public static void main(String[] args) {
        System.out.println(Child.s);
    }

}
复制代码

输出结果:

Parent init!
hello
复制代码

从结果中没有看到输出:Child init! 因此,并没有触发子类的初始化,属于被动引用。

  • 引用类的静态常量不会导致类的初始化,看下面例子:
public class Constants {

    static {
        System.out.println("Constants init!");
    }

    public static final String HELLO = "hello";

}

public class Test {

    public static void main(String[] args) {
        System.out.println(Constants.HELLO);
    }

}
复制代码

输出结果:

hello
复制代码

从结果中没有看到输出:Constants init!常量在编译阶段会存入调用类的常量池中,其实并没有直接引用到定义常量的类,因此没有触发定义常量的类的初始化。

类的加载过程

在详细讲解 Java 虚拟机中类加载的各个阶段前,我们先看下面的这段单例程序,思考下程序的输出结果:

public class Singleton {

    // ①
    private static int a = 0;

    private static int b;

    private static Singleton instance = new Singleton(); // ②

    private Singleton() {
        a++;
        b++;
    }

    public static Singleton getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println("a = " + instance.a);
        System.out.println("b = " + instance.b);
    }
}
复制代码

运行上面的程序结果输出为:

a = 1
b = 1
复制代码

将 ② 这行代码上移到注释 ① 的位置,再次运行结果输出为:

a = 0
b = 1
复制代码

输出不一样的结果,这是为什么呢?通过下面的学习,我们在后面详细解释这个现象。

类的加载(Loading)阶段

“加载”是整个“类加载”过程的第一个阶段,简单来说,类的加载就是将 class 文件中的二进制数据读取到内存中,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,并且在内存中生成一个代表这个类的 java.lang.Class 对象,作为访问方法区数据结构的入口。

Java 虚拟机规范定义类的加载是通过全限定名(包名+类名)来获取二进制数据流,但并没有强行约束必须通过某种方式获取或者从某个地方获取,通过以下几种常见的形式来获取描述类的二进制数据流:

  • 通过读取 zip 文件获取,比如 jar、war。
  • 运行时动态生成,通过动态代理 java.lang.Proxy 生成代理类的二进制字节流,或者使用 ASM 包生成 class。
  • 通过网络获取,如 Applet 小程序、RMI 动态调用发布。
  • 将类的二进制数据存储在数据库的 BLOB 字段中,从数据库中获取。

在某个类经历加载阶段后,虚拟机首先会将二进制字节流按照虚拟机所需的格式存储在方法区中,虚拟机规范没有规定方法区中的具体数据结构,该区域中的数据存储格式由虚拟机实现自行定义。随后在内存中实例化一个 java.lang.Class 对象,以便后面程序可通过该对象去访问方法区中的这些类型数据。在类加载的整个生命周期中,加载阶段是可以与连接阶段的部门内容交叉进行的,比如连接阶段验证字节码文件格式,但是加载阶段肯定还是在连接阶段之前开始的。

类的连接(Linking)阶段

类的连接阶段可细分为 3 个小的过程:验证、准备和解析。

1.验证

验证是连接阶段的第一步,验证是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:文件格式验证、元数据验证、字节码验证和符号引用验证。

1.文件格式验证,验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。

如验证魔数是否 0xCAFEBABE;

主、次版本号是否正在当前虚拟机处理范围之内;

常量池的常量中是否有不被支持的常量类型等等。

该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。

2.元数据验证,是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。可能包括的验证如:

这个类是否有父类;

这个类的父类是否继承了不允许被继承的类;

如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法等等。

3.字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。

如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;

但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。

4.符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。

验证符号引用中通过字符串描述的权限定名是否能找到对应的类;

在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;

符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机加载类的时间。

2.准备

准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

//在准备阶段 value 初始值为 0,在初始化阶段才会赋值为 100
public static int value = 100;
复制代码

3.解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。

直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经存在于内存中。

类的初始化(Initialization)阶段

类初始化是类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。

初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。

程序结果分析

结合类加载过程的详细说明,最后分析一下之前给出的程序片段:

public class Singleton {

    // ①
    private static int a = 0;

    private static int b;

    private static Singleton instance = new Singleton(); // ②

    private Singleton() {
        a++;
        b++;
    }

    public static Singleton getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println("a = " + instance.a);
        System.out.println("b = " + instance.b);
    }
}
复制代码

分析说明如下:

1.Singleton instance = Singleton.getInstance(); Singleton 调用了类的静态方法,触发类的初始化;

2.类加载的时候在准备过程中为类的静态变量分配内存并初始化默认值 instance = null, a = 0, b = 0;

3.类开始初始化,为类的静态变量赋值和执行静态代码块,此时被赋值 a = 0,此时 b 没有赋值操作 b = 0, instance 被赋值为 new Singleton() 调用类的构造方法;

4.调用类的构造方法后 a = 1;b = 1。

若将 ② 这行代码上移到注释 ① 的位置:

1.Singleton instance = Singleton.getInstance(); Singleton 调用了类的静态方法,触发类的初始化;

2.类加载的时候在准备过程中为类的静态变量分配内存并初始化默认值 instance = null, a = 0, b=0;

3.类开始初始化,为类的静态变量赋值和执行静态代码块,instance 被赋值为 new Singleton() 调用类的构造方法;

4.调用类的构造方法后 a = 1; b = 1;

5.继续为 a 和 b 赋值,此时 b 没有赋值操作,所以 b 为 1,但是 a 执行赋值操作就被赋值为 0。

参考资料

《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)- 周志明》

《Java高并发编程详解:多线程与架构设计 - 汪文君》

原文  https://juejin.im/post/5c11dcd4f265da6133567235
正文到此结束
Loading...