zoukankan      html  css  js  c++  java
  • 08-类加载机制

    1. 概述

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

    与那些在编译时需要进行连接的语言不同,在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为 Java 应用提供了极高的扩展性和灵活性,Java 天生可以动态扩展的语言特性就是依赖运行期动态加载动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过 Java 预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。

    为了避免语言表达中可能产生的偏差,在正式开始本章以前,笔者先设立两个语言上的约定:

    1. 在实际情况中,每个 Class 文件都有代表着 Java 语言中的一个类或接口的可能,后文中直接对“类型”的描述都同时蕴含着类和接口的可能性,而需要对类和接口分开描述的场景,笔者会特别指明;
    2. 与前面介绍 Class 文件格式时的约定一致,本章所提到的“Class 文件”也并非特指某个存在于具体磁盘中的文件,而应当是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、网络、数据库、内存或者动态产生等。

    2. 类加载的时机

    2.1 生命周期

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

    加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而〈解析〉阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

    请注意,这里笔者写的是按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

    2.2 初始化

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

    (1)遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这 4 条指令的典型 Java 代码场景有:

    • 使用 new 关键字实例化对象的时候;
    • 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;
    • 调用一个类型的静态方法的时候。

    (2)使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。

    (3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    (4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。

    (5)当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

    (6)当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

    对于这 6 种会触发类型进行初始化的场景,《Java 虚拟机规范》中使用了一个非常强烈的限定语 ——“有且只有”,这 6 种场景中的行为称为对一个类型进行“主动引用”。除此之外,所有引用类型的方式都不会触发初始化,称为“被动引用”。

    2.3 “被动引用”举例

    2.3.1 子类引用父类的静态字段

    package org.fenixsoft.classloading;
    /**
     * 被动使用类字段演示一:
     * 通过子类引用父类的静态字段,不会导致子类初始化
     **/
    public class SuperClass {
        static {
            System.out.println("SuperClass init!");
        }
        public static int value = 123;
    }
    
    public class SubClass extends SuperClass {
        static {
            System.out.println("SubClass init!");
        }
    }
    
    /**
     * 非主动使用类字段演示
     **/
    public class NotInitialization {
        public static void main(String[] args) {
            System.out.println(SubClass.value);
        }
    }
    

    上述代码运行之后,只会输出“SuperClass init!”,而不会输出“SubClass init!”。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证阶段,在《Java 虚拟机规范》中并未明确规定,所以这点取决于虚拟机的具体实现。对于 HotSpot 虚拟机来说,可通过 -XX:+TraceClassLoading 参数观察到此操作是会导致子类加载的。

    2.3.2 通过数组定义来引用类

    package org.fenixsoft.classloading;
    /**
     * 被动使用类字段演示二:
     * 通过数组定义来引用类,不会触发此类的初始化
     **/
    public class NotInitialization {
        public static void main(String[] args) {
            SuperClass[] sca = new SuperClass[10];
        }
    }
    

    这段代码复用了例一中的 SuperClass,运行之后发现没有输出“SuperClass init!”,说明并没有触发类 org.fenixsoft.classloading.SuperClass 的初始化阶段。但是这段代码里面触发了另一个名为 [Lorg.fenixsoft.classloading.SuperClass 的类的初始化阶段,对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令 newarray 触发。

    这个类代表了一个元素类型为 org.fenixsoft.classloading.SuperClass 的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为 public 的 length 属性和 clone() 方法)都实现在这个类里。

    Java 语言中对数组的访问要比 C/C++ 相对安全,很大程度上就是因为这个类包装了数组元素的访问,而 C/C++ 中则是直接翻译为对数组指针的移动。在 Java 语言里,当检查到发生数组越界时会抛出 java.lang.ArrayIndexOutOfBoundsException 异常,避免了直接造成非法内存访问。

    2.3.3 引用某类的常量

    package org.fenixsoft.classloading;
    /**
     * 被动使用类字段演示三:
     * 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定
     * 义常量的类,因此不会触发定义常量的类的初始化。
     **/
    public class ConstClass {
        static {
            System.out.println("ConstClass init!");
        }
        public static final String HELLOWORLD = "hello world";
    }
    /**
     * 非主动使用类字段演示
     **/
    public class NotInitialization {
        public static void main(String[] args) {
            System.out.println(ConstClass.HELLOWORLD);
        }
    }
    

    上述代码运行之后,也没有输出“ConstClass init!”,这是因为虽然在 Java 源码中确实引用了 ConstClass 类的常量 HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”直接存储在 NotInitialization 类的常量池中,以后 NotInitialization 对常量 ConstClass.HELLOWORLD 的引用,实际都被转化为 NotInitialization 类对自身常量池的引用了。也就是说,实际上 NotInitialization 的 Class 文件之中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 文件后就已不存在任何联系了。

    接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,上面的代码都是用静态语句块 static{} 来输出初始化信息的,而接口中不能使用 static{} 语句块,但编译器仍然会为接口生成 <clinit>() 类构造器,用于初始化接口中所定义的成员变量。

    接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第 3 种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

    3. 类加载的过程

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

    3.1 加载

    3.1.1 三件事

    “加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,希望你没混淆这两个看起来很相似的名词。在加载阶段,Java 虚拟机需要完成以下 3 件事情:

    1. 通过一个类的全限定名来获取定义此类的二进制字节流。
    2. 将这个字节流所代表的静态存储结构(前面学的类文件结构) → 方法区的运行时数据结构(C++ 的 instanceKlass)
    3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

    《Java 虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与 Java 应用的灵活度都是相当大的。例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条规则,它并没有指明二进制字节流必须得从某个 Class 文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。

    仅仅这一点空隙,Java 虚拟机的使用者们就可以在加载阶段搭构建出一个相当开放广阔的舞台,Java 发展历程中,充满创造力的开发人员则在这个舞台上玩出了各种花样,许多举足轻重的 Java 技术都建立在这一基础之上,例如:

    • 从 ZIP 压缩包中读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础。
    • 从网络中获取,这种场景最典型的应用就是 Web Applet。
    • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass() 来为特定接口生成形式为 *$Proxy 的代理类的二进制字节流。
    • 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件。
    • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
    • 可以从加密文件中获取,这是典型的防 Class 文件被反编译的保护措施,通过加载时解密 Class 文件来保障程序运行逻辑不被窥探。
    • ……

    3.1.2 非数组类型的加载阶段

    相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用 Java 虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的 findClass()loadClass() 方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

    加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java 虚拟机规范》未规定此区域的具体数据结构。例如 Hotspot 内部采用 C++ 的 instanceKlass 描述 Java 类,它的重要 field 有:

    • _java_mirror 指向堆中 Class 对象
    • _super 即父类
    • _fields 即成员变量
    • _methods 即方法
    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法表

    【注意】① 如果这个类还有父类没有加载,先加载父类;② 加载和链接可能是交替运行的;③ instanceKlass 这样的元数据是存储在方法区(1.8 后的元空间内),但 _java_mirror 是存储在堆中(可以通过前面介绍的 HSDB 工具查看)。

    类型数据妥善安置在方法区之后,会在 Java 堆内存中实例化一个 java.lang.Class 类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

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

    3.1.3 数组类型的加载阶段

    对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的「元素类型(Element Type,指的是数组去掉所有维度的类型)」最终还是要靠类加载器来完成加载,一个数组类(下面简称为 C)创建过程遵循以下规则:

    • 如果数组的「组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的“元素类型”区分开来)」是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上(这点很重要,之后会介绍,一个类型必须与类加载器一起确定唯一性)。
    • 如果数组的组件类型不是引用类型(例如 int[] 数组的组件类型为 int),Java 虚拟机将会把数组 C 标记为与引导类加载器关联。
    • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到。

    3.2 验证

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

    从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

    3.2.1 文件格式验证

    第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

    • 是否以魔数 0xCAFEBABE 开头;
    • 主、次版本号是否在当前 Java 虚拟机接受范围之内;
    • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志);
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
    • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据;
    • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息;
    • ……

    实际上第一阶段的验证点还远不止这些,上面所列的只是从 HotSpot 虚拟机源码(srchotspotshareclassfileclassFileParser.cpp)中摘抄的一小部分内容,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入 Java 虚拟机内存的方法区中进行存储,所以后面的 3 个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

    3.2.2 元数据验证

    第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求,这个阶段可能包括的验证点如下:

    • 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类);
    • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类);
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;
    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
    • ……

    第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java 语言规范》定义相悖的元数据信息。

    3.2.3 字节码验证

    第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中”这样的情况;
    • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上;
    • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的;
    • ……

    如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。即使字节码验证阶段中进行了再大量、再严密的检查,也依然不能保证这一点。这里涉及了离散数学中一个很著名的问题——“停机问题”(Halting Problem),即不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。在我们讨论字节码校验的上下文语境里,通俗一点的解释是通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来准确判定一段程序是否存在 Bug。

    由于数据流分析和控制流分析的高度复杂性,Java 虚拟机的设计团队为了避免过多的执行时间消耗在字节码验证阶段中,在 JDK 6 之后的 Javac 编译器和 Java 虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到 Javac 编译器里进行。具体做法是给方法体 Code 属性的属性表中新增加了一项名为“StackMapTable”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java 虚拟机就不需要根据程序推导这些状态的合法性,只需要检查 StackMapTable 属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间。理论上 StackMapTable 属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了 Code 属性的同时,也生成相应的 StackMapTable 属性来骗过虚拟机的类型校验,则是虚拟机设计者们需要仔细思考的问题。

    JDK 6 的 HotSpot 虚拟机中提供了 -XX: -UseSplitVerifier 选项来关闭掉这项优化,或者使用参数 -XX: +FailOverToOldVerifier 要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而到了 JDK 7 之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号大于 50(对应 JDK 6)的 Class 文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到原来的类型推导的校验方式。

    3.2.4 符号引用验证

    最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第 3 阶段 —— 解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类;
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段;
    • 符号引用中的类、字段、方法的可访问性(private、protected、public、默认)是否可被当前类访问;
    • ……

    符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等。

    验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify: none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

    3.3 准备

    准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段。

    从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在 JDK 7 及之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种逻辑概念的(存储于 instanceKlass 末尾);而在 JDK 8 及之后,类变量则会随着 Class 对象一起存放在 Java 堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

    关于准备阶段,还有两个容易产生混淆的概念笔者需要着重强调:

    • 首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
    • 其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:public static int value = 123;,那变量 value 在〈准备阶段〉过后的初始值为 0 而不是 123,因为这时还未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器 <clinit>() 方法之中,所以把 value 赋值为 123 的动作要到类的〈初始化阶段〉才会被执行。下表列出了 Java 中所有基本数据类型的零值。=> static 变量分配空间和赋值是两个步骤,分配空间在〈准备阶段〉完成,赋值在〈初始化阶段〉完成

    上面提到在“通常情况”下初始值是零值,那言外之意是相对的会有某些“特殊情况”:如果类字段的字段属性表中存在 ConstantValue 属性,那在〈准备阶段〉变量值就会被初始化为 ConstantValue 属性所指定的初始值,假设上面类变量 value 的定义修改为:public static final int value = 123;,编译时 javac 将会为 value 生成 ConstantValue 属性,在〈准备阶段〉虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。

    3.4 解析

    3.4.1 符号/直接引用

    〈解析阶段〉是 Java 虚拟机将常量池内的「符号引用」替换为「直接引用」的过程,「符号引用」在讲解 Class 文件格式的时候(详见《类文件结构》#5.2)已经出现过多次,在 Class 文件中它以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量出现,那解析阶段中所说的「直接引用」与「符号引用」又有什么关联呢?

    • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java 虚拟机规范》的 Class 文件格式中。
    • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

    《Java 虚拟机规范》之中并未规定〈解析阶段〉发生的具体时间,只要求了在执行 ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield、putstatic 这 17 个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的「符号引用」进行解析,还是等到一个符号引用将要被使用前才去解析它。

    类似地,对方法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、private、默认)进行检查,至于其中的约束规则已经是 Java 语言的基本常识,笔者就不再赘述了。

    3.4.2 invokedynamic

    对同一个「符号引用」进行多次解析请求是很常见的事情,除 invokedynamic 指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。无论是否真正执行了多次解析动作,Java 虚拟机都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进 Java 虚拟机内存之中。

    不过对于 invokedynamic 指令,上面的规则就不成立了。当碰到某个前面已经由 invokedynamic 指令触发过解析的符号引用时,并不意味着这个解析结果对于其他 invokedynamic 指令也同样生效。因为 invokedynamic 指令的目的本来就是用于动态语言支持,它对应的引用为“动态调用点限定符(Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。

    解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行,分别对应于常量池的 CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info 和 CONSTANT_InvokeDynamic_info 这 8 种常量类型

    下面笔者将讲解前 4 种引用的解析过程,对于后 4 种,它们都和动态语言支持密切相关,由于 Java 语言本身是一门静态类型语言,在没有讲解清楚 invokedynamic 指令的语意之前,我们很难将它们直观地和现在的 Java 语言语法对应上,因此笔者将延后到后面介绍动态语言调用时一起分析讲解。

    (1)类或接口的解析
    (2)字段解析
    (3)方法解析
    (4)接口方法解析

    3.5 初始化

    类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在〈加载阶段〉用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到〈初始化阶段〉,Java虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。

    进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器 <clinit>() 方法的过程<clinit>() 并不是程序员在 Java 代码中直接编写的方法,它是 javac 编译器的自动生成物,但我们非常有必要了解这个方法具体是如何产生的,以及 <clinit>() 方法执行过程中各种可能会影响程序运行行为的细节,这部分比起其他类加载过程更贴近于普通的程序开发人员的实际工作。

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

    public class Test {
        static {
            i = 0;                // 给变量复制可以正常编译通过
            System.out.print(i);  // 这句编译器会提示“非法向前引用”
        }
        static int i = 1;
    }
    

    <clinit>() 方法与类的构造函数(即在虚拟机视角中的实例构造器 <init>() 方法)不同,它不需要显式地调用父类构造器,Java 虚拟机会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。因此在 Java 虚拟机中第一个被执行的 <clinit>() 方法的类型肯定是 java.lang.Object。

    由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下所示,字段 B 的值将会是 2 而不是 1。

    static class Parent {
        public static int A = 1;
        static {
            A = 2;
        }
    }
    static class Sub extends Parent {
        public static int B = A;
    }
    
    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
    

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

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

    Java 虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行完毕 <clinit>() 方法。如果在一个类的 <clinit>() 方法中有耗时很长的操作,那就可能造成多个进程阻塞(需要注意,其他线程虽然会被阻塞,但如果执行 <clinit>() 方法的那条线程退出 <clinit>() 方法后,其他线程唤醒后则不会再次进入<clinit>() 方法。同一个类加载器下,一个类型只会被初始化一次),在实际应用中这种阻塞往往是很隐蔽的。下述代码演示了这种场景。

    static class DeadLoopClass {
        static {
            // 如果不加上这个if语句,编译器将提示“Initializer does not complete normally”并拒绝编译
            if (true) {
                System.out.println(Thread.currentThread() + "init DeadLoopClass");
                while (true) {}
            }
        }
    }
    public static void main(String[] args) {
        Runnable script = new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };
        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
    

    运行结果如下,一条线程在死循环以模拟长时间操作,另外一条线程在阻塞等待:

    Thread[Thread-0,5,main]start
    Thread[Thread-1,5,main]start
    Thread[Thread-0,5,main]init DeadLoopClass
    

    【小结】类初始化发生的时机

    • 概括得说,类初始化是【懒惰的】
      • main 方法所在的类,总会被首先初始化
      • 首次访问这个类的静态变量或静态方法时
      • 子类初始化,如果父类还没初始化,会引发
      • 子类访问父类的静态变量,只会触发父类的初始化
      • Class.forName
      • new 会导致初始化
    • 不会导致类初始化的情况
      • 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
      • 类对象.class 不会触发初始化
      • 创建该类的数组不会触发初始化
      • 类加载器的 loadClass 方法
      • Class.forName 的参数 2 为 false 时

    测试代码:

    class A {
        static int a = 0;
    
        static {
            System.out.println("a init");
        }
    }
    class B extends A {
        final static double b = 5.0;
    
        static boolean c = false;
    
        static {
            System.out.println("b init");
        }
    }
    

    验证(实验时请先全部注释,每次只执行其中一个)

    public class Load3 {
        static {
            System.out.println("main init");
        }
    
        public static void main(String[] args) throws ClassNotFoundException {
            // 1. 静态常量(基本类型和字符串)不会触发初始化
            System.out.println(B.b);
            // 2. 类对象.class 不会触发初始化
            System.out.println(B.class);
            // 3. 创建该类的数组不会触发初始化
            System.out.println(new B[0]);
            // 4. 不会初始化类 B,但会加载 B、A
            ClassLoader cl = Thread.currentThread().getContextClassLoader();
            cl.loadClass("cn.itcast.jvm.t3.B");
            // 5. 不会初始化类 B,但会加载 B、A
            ClassLoader c2 = Thread.currentThread().getContextClassLoader();
            Class.forName("cn.itcast.jvm.t3.B", false, c2);
    
            // 1. 首次访问这个类的静态变量或静态方法时
            System.out.println(A.a);
            // 2. 子类初始化,如果父类还没初始化,会引发
            System.out.println(B.c);
            // 3. 子类访问父类静态变量,只触发父类初始化
            System.out.println(B.a);
            // 4. 会初始化类 B,并先初始化类 A
            Class.forName("cn.itcast.jvm.t3.B");
        }
    }
    

    3.6 练习

    (1)从字节码分析,使用 a,b,c 这三个常量是否会导致 E 初始化?

    public class LoadTest {
        public static void main(String[] args) {
            System.out.println(E.a); // × 准备阶段
            System.out.println(E.b); // × 准备阶段
            System.out.println(E.c); // √ 初始化阶段
        }
    }
    
    class E {
        public static final int a = 10;
        public static final String b = "hello";
        public static final Integer c = 20;
    
        static {
            System.out.println("init E");
        }
    }
    

    (2)完成懒惰初始化单例模式(懒惰实例化;初始化时的线程安全是有保障的~)

    public final class Singleton {
        private Singleton() {}
    
        // 静态内部类中保存单例
        private static class LazyHolder {
            static final Singleton INSTANCE = new Singleton();
        }
    
        // 第一次调用 getInstance 方法,才会导致内部类加载和初始化
        public static Singleton getInstance() {
            return LazyHolder.INSTANCE;
        }
    }
    

    4. 类加载器

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

    4.1 类与类加载器

    类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远超〈类加载阶段〉。

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

    这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括了使用 instanceof 关键字做对象所属关系判定等各种情况。

    如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,如下代码演示了不同的类加载器对 instanceof 关键字运算的结果的影响。

    public class ClassLoaderTest {
      public static void main(String[] args) throws Exception {
        ClassLoader myLoader = new ClassLoader() {
          @Override
          public Class<?> loadClass(String name) throws ClassNotFoundException {
            try {
              String fileName = name.substring(name.lastIndexOf(".") + 1)+".class";
              InputStream is = getClass().getResourceAsStream(fileName);
              if (is == null) {
                return super.loadClass(name);
              }
              byte[] b = new byte[is.available()];
              is.read(b);
              return defineClass(name, b, 0, b.length);
            } catch (IOException e) {
              throw new ClassNotFoundException(name);
            }
          }
        };
        Object obj = myLoader.loadClass("cn.edu.nuist.ClassLoaderTest").newInstance();
        System.out.println(obj.getClass()); // class cn.edu.nuist.ClassLoaderTest
        System.out.println(obj instanceof cn.edu.nuist.ClassLoaderTest); // false
      }
    }
    

    它可以加载与自己在同一路径下的 Class 文件,我们使用这个类加载器去加载了一个名为“cn.edu.nuist.ClassLoaderTest”的类,并实例化了这个类的对象。

    两行输出结果中,从第一行可以看到这个对象确实是类 cn.edu.nuist.ClassLoaderTest 实例化出来的,但在第二行的输出中却发现这个对象与类 cn.edu.nuist.ClassLoaderTest 做所属类型检查的时候返回了 false。

    这是因为 Java 虚拟机中同时存在了两个 ClassLoaderTest 类,一个是由〈虚拟机的应用程序类加载器〉所加载的,另外一个是由我们〈自定义的类加载器〉加载的,虽然它们都来自同一个 Class 文件,但在 Java 虚拟机中仍然是两个互相独立的类,做对象所属类型检查时的结果自然为 false。

    4.2 双亲委派模型

    站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

    站在 Java 开发人员的角度来看,类加载器就应当划分得更细致一些。本节内容将针对 JDK 8 及之前版本的 Java 来介绍什么是“三层类加载器”以及什么是“双亲委派模型”。对于这个时期的 Java 应用,绝大多数 Java 程序都会使用到以下 3 个系统提供的类加载器来进行加载。

    4.2.1 启动类加载器

    启动类加载器(Bootstrap Class Loader)

    前面已经介绍过,这个类加载器负责加载存放在 <JAVA_HOME>lib 目录,或者被 -Xbootclasspath 参数(没有 :)所指定的路径中存放的,而且是 Java 虚拟机能够识别的(按照文件名识别,如 rt.jar、tools.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机的内存中。「启动类加载器」无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给「引导类加载器」去处理,那直接使用 null 代替即可,如下代码展示的就是 java.lang.ClassLoader.getClassLoader() 方法的代码片段,其中的注释和代码实现都明确地说明了以 null 值来代表「引导类加载器」的约定规则。

    /**
     * Returns the class loader for the class.  Some implementations may use null to
     * represent the bootstrap class loader. This method will return null in such
     * implementations if this class was loaded by the bootstrap class loader.
     */
    public ClassLoader getClassLoader() {
        ClassLoader cl = getClassLoader0();
        if (cl == null) return null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            ClassLoader ccl = ClassLoader.getCallerClassLoader();
            if (ccl != null && ccl != cl && !cl.isAncestor(ccl)) {
                sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);
            }
        }
        return cl;
    }
    

    Xbootclasspath 表示设置 bootclasspath,其中 /a:. 表示将当前目录追加至 bootclasspath 之后(可以用这个办法替换核心类)

    java -Xbootclasspath:<new bootclasspath>    //  替换
    java -Xbootclasspath/a:. <追加路径>         // 后追加
    java -Xbootclasspath/p:. <追加路径>         // 前追加
    

    4.2.2 扩展类加载器

    扩展类加载器(Extension Class Loader)

    这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载 <JAVA_HOME>libext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种 Java 系统类库的扩展机制,JDK 的开发团队允许用户将具有通用性的类库放置在 ext 目录里以扩展 Java SE 的功能,在 JDK 9 之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由 Java 代码实现的,开发者可以直接在程序中使用扩展类加载器来加载 Class 文件。

    4.2.3 应用程序类加载器

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

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

    4.2.4 类加载器双亲委派模型

    所谓的“双亲委派”,就是指调用类加载器的 loadClass 方法时,查找类的规则。

    JDK 9 之前的 Java 应用都是由这 3 种类加载器互相配合来完成加载的,如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的 Class 文件来源,或者通过类加载器实现类的隔离、重载等功能。这些类加载器之间的协作关系“通常”会如图所示。

    上图中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。// 所以说称为“上级”应该更稳妥~

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

    使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都能够保证是同一个类。

    反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中就会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。如果读者有兴趣的话,可以尝试去写一个与 rt.jar 类库中已有类重名的 Java 类,将会发现它可以正常编译,但永远无法被加载运行(即使自定义了自己的类加载器,强行用 defineClass() 方法去加载一个以“java.lang”开头的类也不会成功。如果读者尝试这样做的话,将会收到一个由 Java 虚拟机内部抛出的“java.lang.SecurityException:Prohibited package name:java.lang”异常)。

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 1. 检查该类是否已经加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        // 2. 有上级的话,委派上级 loadClass
                        c = parent.loadClass(name, false);
                    } else {
                        // 3. 如果没有上级了(ExtClassLoader),则委派 BootstrapClassLoader
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父类加载器抛出 ClassNotFoundException,说明父类加载器无法完成加载请求
                }
                if (c == null) {
                    long t1 = System.nanoTime();
                    // 4. 在父类加载器无法加载时,再调用本身的 findClass() 来进行类加载
                    c = findClass(name);
                    // 5. 记录耗时
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
    

    这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载。

    4.3 破坏双亲委派模型

    上文提到过双亲委派模型并不是一个具有强制性约束的模型,而是 Java 设计者推荐给开发者们的类加载器实现方式。在 Java 的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到 Java 模块化出现为止,双亲委派模型主要出现过 3 次较大规模“被破坏”的情况。

    4.3.1 发生在模型出现之前

    双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前 —— 即 JDK 1.2 面世以前的“远古”时代。

    由于双亲委派模型在 JDK 1.2 之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader 则在 Java 的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java 设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免 loadClass() 被子类覆盖的可能性,只能在 JDK 1.2 之后的 java.lang.ClassLoader 中添加一个新的 protected 方法 findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass() 中编写代码。

    上节我们已经分析过 loadClass() 方法,双亲委派的具体逻辑就实现在这里面,按照 loadClass() 方法的逻辑,如果父类加载失败,会自动调用自己的 findClass() 方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

    4.3.2 由模型自身的缺陷导致*

    双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的 API 存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?

    这并非是不可能出现的事情,一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器来完成加载(在 JDK 1.3 时加入到 rt.jar 的),肯定属于 Java 中很基础的类型了。但 JNDI 存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的 ClassPath 下的 JNDI 服务提供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?

    为了解决这个困境,Java 的设计团队只好引入了一个不太优雅的设计:「线程上下文类加载器(Thread Context ClassLoader)」。

    这个类加载器可以通过 Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是「应用程序类加载器」。

    有了「线程上下文类加载器」,程序就可以做一些“舞弊”的事情了。JNDI 服务使用这个「线程上下文类加载器」去加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。

    Java 中涉及 SPI 的加载基本上都采用这种方式来完成,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。不过,当 SPI 的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在 JDK 6 时,JDK 提供了 java.util.ServiceLoader 类,以 META-INF/services 中的配置信息,辅以责任链模式,这才算是给 SPI 的加载提供了一种相对合理的解决方案。


    举个例子吧,比如我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,就算不写 Class.forName("com.mysql.jdbc.Driver") 也是可以让 com.mysql.jdbc.Driver 正确加载的,你知道是怎么做的吗?下面来追踪源码:

    public class DriverManager {
      // 注册驱动的集合
      private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
      // 初始化驱动
      static {
        loadInitialDrivers(); // <=== Step Into
        println("JDBC DriverManager initialized");
      }
      // ...
    }
    

    先不看别的,来看看 DriverManager 的类加载器是谁吧:

    System.out.println(DriverManager.class.getClassLoader()); // null
    

    打印 null,表示它的类加载器是 Bootstrap ClassLoader,会到 JAVA_HOME/jre/lib 下搜索类,但 JAVA_HOME/jre/lib 下显然没有 mysql-connector-java-5.1.47.jar 包,这样问题来了,在 DriverManager 的静态代码块中,怎么能正确加载 com.mysql.jdbc.Driver 呢?

    继续看 loadInitialDrivers() 方法:

    private static void loadInitialDrivers() {
        String drivers;
        try {
            drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
                public String run() {
                    return System.getProperty("jdbc.drivers");
                }
            });
        } catch (Exception ex) {
            drivers = null;
        }
    
        // (1) 使用 ServiceLoader 机制加载驱动,即 SPI (详解见下文)
        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {
                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                Iterator<Driver> driversIterator = loadedDrivers.iterator();
                try{
                    while(driversIterator.hasNext()) {
                        driversIterator.next();
                    }
                } catch(Throwable t) {
                    // Do nothing
                }
                return null;
            }
        });
    
        println("DriverManager.initialize: jdbc.drivers = " + drivers);
    
        // (2) 使用 jdbc.drivers 定义的驱动名加载驱动
        if (drivers == null || drivers.equals("")) {
            return;
        }
        String[] driversList = drivers.split(":");
        println("number of Drivers:" + driversList.length);
        for (String aDriver : driversList) {
            try {
                println("DriverManager.Initialize: loading " + aDriver);
                Class.forName(aDriver, true, // ↓ 应用程序类加载器,因此可以顺利完成类加载
                        ClassLoader.getSystemClassLoader());
            } catch (Exception ex) {
                println("DriverManager.Initialize: load failed: " + ex);
            }
        }
    }
    

    约定如下,在 jar 包的 META-INF/services 包下,以接口全限定名名为文件,文件内容是实现类名称:

    这样就可以使用 ↓ 来得到实现类,体现的是“面向接口编程+解耦”的思想,在下面一些框架中都运用了此思想:JDBC、Servlet 初始化器、Spring 容器、Dubbo(对 SPI 进行了扩展)。

    ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class);
    Iterator<接口类型> iter = allImpls.iterator();
    while(iter.hasNext()) {
        iter.next(); // 得到某个实现类的实例对象
    }
    

    接着看 ServiceLoader.load() 方法:

    public static <S> ServiceLoader<S> load(Class<S> service) {
        // 「线程上下文类加载器」是当前线程使用的类加载器,默认就是「应用程序类加载器」
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl); // <=== Step Into
    }
    
    public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
        return new ServiceLoader<>(service, loader); // <=== Step Into
    }
    
    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        service = Objects.requireNonNull(svc, "Service interface cannot be null");
        loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
        acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
        reload(); // <=== Step Into
    }
    
    public void reload() {
        providers.clear();
        lookupIterator = new LazyIterator(service, loader); // <=== Step Into
    }
    

    ServiceLoader 的内部类 LazyIterator:

    private class LazyIterator implements Iterator<S> {
    
        private LazyIterator(Class<S> service, ClassLoader loader) {
            this.service = service;
            this.loader = loader;
        }
    
        private S nextService() {
            if (!hasNextService()) throw new NoSuchElementException();
    
            String cn = nextName;
            nextName = null;
            Class<?> c = null;
            try {
                c = Class.forName(cn, false, loader);
            } catch (ClassNotFoundException x) {
                fail(service, "Provider " + cn + " not found");
            }
            if (!service.isAssignableFrom(c)) {
                fail(service, "Provider " + cn  + " not a subtype");
            }
            try {
                S p = service.cast(c.newInstance());
                providers.put(cn, p);
                return p;
            } catch (Throwable x) {
                fail(service, "Provider " + cn + " could not be instantiated", x);
            }
            throw new Error(); // This cannot happen
        }
    }
    

    4.3.3 对程序动态性的追求导致

    双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。说白了就是希望 Java 应用程序能像我们的电脑外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用关机也不用重启。对于个人电脑来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是大型系统或企业级软件开发者具有很大的吸引力。

    现在我们先来简单看一看 OSGi 是如何通过类加载器实现热部署的。

    OSGi 实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:

    1. 将以 java.* 开头的类,委派给父类加载器加载。
    2. 否则,将委派列表名单内的类,委派给父类加载器加载。
    3. 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。
    4. 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。
    5. 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载。
    6. 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。
    7. 否则,类查找失败。

    上面的查找顺序中只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的。

    本节中笔者虽然使用了“被破坏”这个词来形容上述不符合双亲委派模型原则的行为,但这里“被破坏”并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新。正如 OSGi 中的类加载器的设计不符合传统的双亲委派的类加载器架构,且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但对这方面有了解的技术人员基本还是能达成一个共识,认为 OSGi 中对类加载器的运用是值得学习的,完全弄懂了 OSGi 的实现,就算是掌握了类加载器的精粹。

    4.4 自定义类加载器

    1. 想加载 !classpath 随意路径中的类文件;
    2. 都是通过接口来使用实现,希望解耦时,常用在框架设计;
    3. 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器。

    步骤:

    1. 继承 ClassLoader 父类
    2. 要遵从双亲委派机制,重写 findClass 方法(注意不是重写 loadClass 方法,否则不会走双亲委派机制)
    3. 读取类文件的字节码
    4. 调用父类的 defineClass 方法来加载类
    5. 使用者调用该类加载器的 loadClass 方法
  • 相关阅读:
    python学习-(__new__方法和单例模式)
    jQuery弹性展开收缩菜单插件gooey.js
    轻量级Modal模态框插件cta.js
    基于jq流畅度非常好的图片左右切换焦点图
    基于jQuery实现的腾讯互动娱乐网站特效
    基于jQuery和CSS3炫酷图片3D旋转幻灯片特效
    可嵌入图片视频jQuery全屏滑块
    基于jQuery仿迅雷影音官网幻灯片特效
    基于jQuery自适应宽度跟高度可自定义焦点图
    基于jQuery实现汉字转换成拼音代码
  • 原文地址:https://www.cnblogs.com/liujiaqi1101/p/14787124.html
Copyright © 2011-2022 走看看