【读书笔记】《深入理解Java虚拟机》虚拟机类加载机制

474 阅读10分钟

类加载机制概述

虚拟机的类加载机制:Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型

类加载的流程包含加载、校验、准备、解析、初始化、使用和卸载七个阶段。其中加载、校验、准备、初始化和卸载这五个阶段的顺序是确定的,必须按这个顺序按部就班地开始。虽然这五个阶段需要按部就班的开始,但并不要求必须按部就班地完成,可能在前一个阶段还未结束时,下一阶段就会启动。

类加载的时机

虚拟机什么时间进行类加载的第一阶段没有明确要求,交给虚拟机的具体实现来把握,但是严格规定了有且只有六种情况必须立即对类型(类或者接口)进行初始化(类的加载、校验和准备自然要在这之前完成):

  1. 遇到new、getstatic、putstatic和invokestatic这四条字节码指令时,如果类型没有初始化,必须先触发初始化阶段。能生成这四条指令的典型Java代码场景包括:
    • 使用new关键字实例化对象
    • 读取或者设置一个类型的静态字段(被final修饰的静态字段除外,已经在编译阶段把结果放入了常量池)
    • 调用一个类型的静态方法
  2. 使用java.lang.reflect包对方法的类型进行发射调用时,如果类型未初始化,则需要先进行初始化。
  3. 当初始化类的时候,如果其父类还没有进行过初始化,需要先触发其父类的初始化。但是接口在初始时,并不要求其父接口全部完成了初始化,只有在真正使用父接口时(如引用接口中定义的常量)才会初始化。
  4. 但虚拟机启动时,需要指定一个要执行的主类,虚拟机会先初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且方法句柄对应的类没有进行初始化时,需要先触发该类的初始化。
  6. 当一个接口实现了JDK 8的默认方法时,需要改接口的实现类发生初始化,该接口要在其之前被初始化。

上述六种会触发类型初始化的场景称为对一个类型的主动引用,其余场景称为被动引用。

class Dfather {
    static int count = 1;
    static{
        System.out.println("Initialize class Dfather");
    }
}
 
class Dson extends Dfather{
    static{
        System.out.println("Initialize class Dson");
    }
}

public class NotInitliazion {
    public static void main(String[] args) {
        // 被动引用的场景一:不会触发子类Dson的初始化
        System.out.println(Dson.count);
        // 被动引用的场景二:不会触发Dfather的初始化
        Dfather[] nums = new Dfather[10];
    }
}

类加载的过程

加载

在加载阶段,虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,如:网络、动态生成、数据库等);
  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中(对于HotSpot虚拟机而言就是方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口;

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在夹在阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

校验

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会尾号虚拟机自身的安全。验证阶段大致会完成资格阶段的检验动作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范(如:是否以魔数0xCAFEBABE开头,主次版本号是否在当前虚拟机的处理范围之内、常量池中是否有不被支持的类型)
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求(如:这个类是否有父类,除了java.lang.Object之外)
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的;
  • 符号引用验证:确保解析动作能正确执行,发生在符号引用转换为直接引用时。 验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响。如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类变量(static成员变量)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配(JDK 7及以后,类变量会随着Class对象分配在Java堆中)。这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。

准备阶段所说的初始值通常情况下是数据类型的零值,假设一个类变量定义为:

public static int value = 123;

 那么,变量value在准备阶段过后的值为0而不是123。因为这时候尚未开始执行任何java方法,而把value复制为123的putstatic指令时程序被变异后,存放于类构造器方法()之中,所以把value赋值为123的动作将在初始化阶段才会执行。至于“特殊情况”是指:当类字段的字段属性是ConstantValue时,会在准备阶段初始化为指定的值,所以标注为final之后,value的值在准备阶段初始化为123而非0。

public static final int value = 123;

解析

Java虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

初始化

在准备阶段,变量已经赋过一次系统要求的初始值(零值);而在初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,更直接地说:初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法时由编译器自动收集类中的所有类变量的赋值动作静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

<clinit>()方法与类的构造函数不同,不需要显式调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前执行完父类的<clinit>()方法。

与类不同,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。

<clinit>()方法对于类或者接口不是必须的,如果类中没有静态语句块,也没有对类变量的赋值操作,编译器可以不为这个类生成<clinit>()方法。

这是类的初始化,不是类实例的初始化,因此类的构造函数是不会在这里调用的。

附:类实例(类对象)创建的过程,这些不可不知的JVM知识,我都用思维导图整理好了

类加载器

完成类加载阶段中“通过一个类的全限定名来获取描述该类的二进制流”这个动作的代码称为“类加载器”。

Java程序中比较两个类是否相等(Class对象的equals()isAassinableFrom()isInstance()方法和instanceof关键字),只有在两个类是由同一个类加载器加载的前提下才有意义;否则即使两个类来源同一个Class文件,被同一个Java虚拟机加载,但是加载它们的类加载器不同,那这两个必定不相等。

类加载器分类

  • Bootstrap classLoader:c++编写,加载java核心库 java.*,构造ExtClassLoaderAppClassLoader。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
  • ExtClassLoaderjava编写,加载扩展库,如classpath中的jrejavax.*或者java.ext.dir 指定位置中的类,开发者可以直接使用标准扩展类加载器。
  • AppClassLoader:java编写,加载程序所在的目录,如user.dir所在的位置的class。
  • CustomClassLoader:Java编写,用户自定义的类加载器,可加载指定路径的class文件。

双亲委派机制

工作机制:当一个类加载器收到一个类加载请求时,该类加载器首先会把请求委派给父类加载器。每个类加载器都是如此,只有在父类加载器在自己的搜索范围内找不到指定类时,子类加载器才会尝试自己去加载。

使用双亲委派模型的作用:

避免重复加载:Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。

避免核心类篡改:Java核心api中定义类型不会被随意替换,都是由启动类加载进行加载,保证核心API类是同一个类。假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。