Java 虚拟机类加载机制

本文部分摘自《深入理解 Java 虚拟机第三版》

概述

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

与那些在编译时需要进行连接的语言不同,在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种做法虽然让类加载时稍微增加了一些性能开销,但也为 Java 应用提供了极高的扩展性和灵活性,Java 可动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的

类加载的时机

一个类型从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Prepartion)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)

Java 虚拟机类加载机制

其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,必须按这个顺序开始,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java 语言的运行时动态绑定特性(也称动态绑定或晚期绑定)。注意这里写的是按顺序开始,而非进行,因为这些阶段通常是互相交互地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段

关于在什么情况下开始类加载过程的第一个阶段加载,Java 虚拟机规范并没有强制约束,可以交给虚拟机的具体实现来自由把握。但对于初始化阶段,Java 虚拟机则是严格规定了有且只有六种情况必须立即对类进行初始化(而加载、验证、准备自然要在此之前开始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有初始化,则先触发初始化阶段。能够生产这四条指令的典型 Java 代码场景有:
    • 使用 new 关键字实例化对象
    • 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)
    • 调用一个类型的静态方法
  2. 对类型进行反射调用时,如果类型没有初始化,则先触发初始化阶段
  3. 初始化类时,其父类尚未初始化,则需先触发其父类的初始化
  4. 虚拟机启动时,用户需要指定一个要执行的类(包含 main() 方法的类),会先初始化该主类
  5. 使用了 JDK7 新加入的动态语言支持
  6. 一个接口定义了 JDK8 新加入的默认方法,如果该接口的实现类发生了初始化,那接口要先被初始化

对于这六种会触发类型进行初始化的场景,称为对类型进行主动引用。其他所有引用类型的方式都不会触发初始化,称为被动引用,例如通过子类引用父类的静态字段不会导致子类初始化、通过数组定义来引用类不会触发类的初始化、引用常量等

接口的加载过程与类稍有不同,区别在于主动引用的第三种场景:当一个类在初始化时,要求其父类全部都已初始化,但一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化

类加载的过程

接下来我们会详细了解 Java 虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的具体动作

1. 加载

在加载截断,Java 虚拟机需要完成完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

Java 虚拟机规范对这三点要求并不是特别具体,因此实现十分灵活。例如第一点,它并没有指明二进制字节流必须从某个 Class 文件中获取,仅仅这一点空隙,充满创造力的开发人员就玩出了各种花样,许多日后举足轻重的 Java 技术都基于这一基础:

  • 从 ZIP 压缩包中读取,这是日后 JAR、EAR、WAR 格式的基础
  • 从网络中获取
  • 运行时计算生成,最常见的就是动态代理技术
  • 由其他文件生成,如 JSP 文件生成对应 Class 文件
  • 从数据库读取
  • 从加密文件读取

相对于类加载的其他阶段,非数组类型的加载阶段是开发人员可控性最强的阶段。加载阶段既可以使用 Java 虚拟机内置的引导类加载器完成,也可以由用户自定义的类加载完成。数组类本身不通过类加载器创建,它由 Java 虚拟机直接在内存中动态构造出来。但数组类中元素的类型最终还是要靠类加载器来完成加载

加载阶段结束后,Java 虚拟机外部的二进制字节流按照虚拟机设定的格式存储在方法区之中,存储格式由虚拟机自行定义。类型数据在方法区安置完后,会在堆中实例化一个 java.lang.Class 类的对象,作为程序访问方法区中类型数据的外部接口

2. 验证

验证是连接阶段的第一步,目的是确保 Class 文件的字节流中包含的信息符合 Java 虚拟机规范的要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

验证阶段大致上会完成下面四个阶段的检验动作:

  • 文件格式校验:验证字节流是否符合 Class 文件格式的规范,并能被当前版本的虚拟机所处理,例如是否以魔数开头、主次版本号是否在当前虚拟机的接受范围之内等等
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,例如该类是否有父类、是否实现其父类或接口所要求实现的所有方法等等
  • 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的,例如保证任何时刻操作数栈的数据与指令代码序列都能配合工作、保证任何跳转指令都不会跳到方法体以外的字节码指令上等等
  • 符号引用验证:该阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化的动作发生在解析阶段。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,该类是否缺少或被禁止访问它所依赖的某些外部类、方法、字段等资源

验证阶段并不是一个必须要执行的阶段,如果程序运行的所有代码都已经被反复使用和验证过,那么在生产环境的实验阶段可以考虑使用 -Xverify:none 参数来关闭大部分的类验证,缩短虚拟机加载的时间

3. 准备

准备阶段是整数为类中定义的变量(即静态变量)分配内存并设置类变量初始值的阶段。注意这里进行内存分配的仅包括类变量,而非实例变量,实例变量会在对象实例化时随着对象一起分配在 Java 堆中。其次这里说的初始值通常情况下是数据类型的零值

4. 解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,直接引用和符号引用之间的关系如下:

  • 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可
  • 直接引用:可以是直接指向目标指针的指针、相对偏移量或者是一个能间接定位到目标的句柄

5. 初始化

在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序代码制定的主观计划去初始化类变量和其他资源

初始化阶段就是执行类构造器 <clinit> 方法的过程,该方法是 Javac 编译器的自动生成物,由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生,语句顺序按源文件的顺序决定

<clinit> 方法与类的构造方法不同,它不需要显式地调用父类构造器,Java 虚拟机会保证子类的 <clinit> 方法执行前,父类的 <clinit> 方法已经执行完毕。由于父类 <clinit> 方法先执行,因此父类中定义的静态语句块要优于子类的变量赋值操作

接口中不能使用静态语句块,但仍有变量初始化的赋值操作,因此接口与类一样会生成 <clinit> 方法。不同的是,执行接口的 <clinit> 方法不需要先执行父接口的 <clinit> 方法,只有当父接口中定义的变量被使用,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit> 方法

Java 虚拟机必须保证一个类的 <clinit> 方法在多线程环境下被正确地加锁同步,如果有多个线程同时初始化一个类,那么只会有一个线程去执行 <clinit> 方法,其他线程阻塞等待,直至活动线程执行完 <clinit> 方法。但其他线程唤醒后不会再次进入 <clinit> 方法,同一个类加载器下,一个类型只会被初始化一次

类加载器

Java 虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让程序自己决定如何而去获取所需的类,实现这个动作的代码被称为类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话更通俗地表达是:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个两位就必定不相等

1. 双亲委派模型

绝大多数 Java 程序会使用到以下三个系统提供的类加载器来进行加载

  • 启动类加载器(Bootstrap Class Loader)

    启动类加载器使用 C++ 语言实现,负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机所能识别的类库加载到虚拟机的内存中。启动类加载器无法被 Java 程序直接引用

  • 扩展类加载器(Extension Class Loader)

    扩展类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式来实现,负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库,即用来扩展 Java SE 功能的类库。由于扩展类加载器是由 Java 代码实现,开发者可以直接在程序中使用扩展类加载器来加载 Class 文件

  • 应用程序类加载器(Application Class Loader)

    这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。由于它是 ClassLoader 类中的 getSystemClassLoader 方法的返回值,所以有些场合也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所有的类库,开发者也可以在代码中使用这个类加载器。如果应用程序没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器

图示的各种类加载器之间的层次关系被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器,不过这里的类加载器之间的父子关系一般不以继承关系实现,而使用组合关系来复用父加载器的代码

Java 虚拟机类加载机制

双亲委派模型的工作工程是:如果一个类加载器收到了类加载的请求,首先它不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该被传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载

使用双亲委派模型的好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系,例如 java.lang.Object 类,无论哪一个类加载器加载它,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都能保证是同一个类。如果没有双亲委派机制,那么用户也可以自己编写一个 java.lang.Object 类,并放在程序的 ClassPath 中,那系统就会出现多个不同的 Object 类,导致程序出错

版权声明:程序员胖胖胖虎阿 发表于 2022年8月29日 下午4:19。
转载请注明:Java 虚拟机类加载机制 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...