第七章 虚拟机类加载机制

133 阅读32分钟

1.概述

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

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

2.类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期会经历:加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中:验证、准备、解析三个阶段统称为连接。 image.png 其中:加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这个顺序按部就班的开始。解析则不用一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的运行时绑定特性。

JVM 规范没有强制约束什么情况下开始类加载过程。但是对于初始化阶段,JVM 规范规定了有且只有六种情况必须立即对类继续初始:

  1. 遇到 new 、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类型没有进行过初始化,需要先触发其初始化阶段,触发这四条指令的典型场景:
    • 使用 new 关键字实例化对象的时候。
    • 读取或设置一共类型的静态字段(被 final 修饰、在编译器把结果放入常量池的静态字段除外)的时候。
    • 调用一个类型的静态方法的时候。
  2. 使用反射包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需先触发它的初始化。
  3. 当类初始化时,如果发现它的父类还没有进行过初始化,则需要先触发它的父类初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 mian() 方法的类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、 REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,且这个方法句柄对应的类没有初始化,则需要先触发它的初始化。
  6. 当一个接口种定义了 JDK 8 新加入的默认方法时,如果这个接口的实现类发生了初始化,那么该接口需要在它之前被初始化。

上面的六种场景中的行为称为对一个类型进行主动引用。除此之外所有引用类型的方式都不会触发初始化,称为被动引用。

下面是三个被动引用的例子:

/** 
* 被动使用类字段演示一: 
* 通过子类引用父类的静态字段,不会导致子类初始化 
**/ 
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!”。


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

上面的代码不会触发 SuperClass 类的初始化,但是会触发 [Lorg.fenixsoft.classloading.SuperClass类的初始化。

/** * 被动使用类字段演示三: 
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的 类的初始化 
**/ 
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!”,是因为对常量的引用,实际上转换为了对类自身的常量池的引用了。

接口与类相似,也有类加载过程,但是加载的过程稍有不同,接口中不能使用静态代码块,但是编译器仍然会为接口生成 <clinit>()类构造器,用于初始化接口中所定义的成员变量。接口与类有区别的是上面六种情况中的第三种情况,当一个类在初始化时,要求它的父类全部都已经初始化过了,但是接口在初始化时,并不要求它的父接口全部都完成了初始化,只有在真正使用到父接口时(如引用接口中定义的常量)才会初始化。

3.类记载过程

3.1 加载

加载阶段是类加载过程中的一个阶段,在加载阶段 JVM 虚拟机需要完成以下三件事:

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

JVM 虚拟机规范对这三点的要求并不具体,所以留给虚拟机的实现与 Java 应用的灵活度都相当大。

二进制流的来源:

  • 从ZIP压缩包读取,最终成为日后 JAR、EAR、WAR格式的基础。
  • 从网络中读取,最典型的应用就是 web applet。
  • 运行时计算生成,使用最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass()来为特定接口生 成形式为 *$Proxy 的代理类的二进制字节流。
  • 从其他文件生成,典型场景就是jsp应用,由jsp文件生成对应的 Class 文件。

对于非数组类型的加载阶段,是开发人员可控性最强的阶段。加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以自定义类加载器去完成,自定义时通过重写一个类加载器的findClass()或 loadClass()方法,实现根据自己的想法来赋予应用程序获取运行代码的动态性。

对于数组,数组类本身不通过类加载器创建,它是由 JVM 直接在内存中动态构造出来的。但是数组类的元素类型需要靠类加载器来完成加载。

数组的创建涉及两个概念:

  • 组件类型:定义数组时,定义的类型,比如 String[] arr = new String[5];, 数组的组件类型是 String 。
  • 元素类型:实际存储到数组的元素的类型。
  • 在 Java 中组件类型与元素类型是一致的。

数组类创建过程遵循以下规则:

  • 如果数组的组件类型是引用类型,那就递归去加载这个组件类型,并且将被创建的数组标识在加载这个组件类型的类加载器的名称空间上,保证被创建的数组类和它的组件类在同一个类加载器的名称空间上。
  • 如果数组的组件类型不是引用类型,则会把被创建的数组类标记为与引导类加载器关联。
  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组的可访问性默认为 public,可以被所有的类和接口访问到。

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

加载阶段与连接阶段的部分动作是交叉进行的,加载阶段尚未结束,连接阶段可能已经开始,但是这些夹杂在加载阶段的动作仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的现后顺序。

3.2 验证

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

Java 语言本身是相对安全的,但是 Class 文件是可以直接被编辑的,所以验证字节码是 JVM 的自我保护措施。验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

验证大致分为四个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。

  1. 文件格式验证:验证字节流是否符号 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段大致包括:
    • 是否以魔数 0xCAFEBABE 开头。
    • 主、次版本号是否在当前虚拟机的接受范围之内。
    • 常量池中是否有不被支持的常量类型(检查常量的 tag 标志)。
    • 指向常量的各种索引值中是否有指向不存在的常量或者不符合类型的常量。
    • CONSTANT_Utf8_info 型常量中是否有不符合 UTF-8 编码的数据。
    • Class 文件中的各个部分及文件本身是否有被删除的或附加的其他信息。
  2. 元数据验证:对字节码描述信息进行语义分析,以保证其描述信息符合 JVM 规范的要求。大致包含以下验证点:
    • 这个类是否有父类。
    • 这个类是否继承了不允许被继承的类。
    • 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法。
    • 类中的字段、方法是否与父类产生矛盾,如覆盖了父类的 final 字段等。
  3. 字节码验证:这个阶段是通过对数据流分析和控制流分析来确定程序的语义是合法的、符合逻辑的。这个阶段是对 Class 文件中的 Code 属性中的内容进行分析,以保证方法在运行时不会做出危害虚拟机安全的行为。大致包含以下验证点:
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现在操作数栈放置了一个 int 类型数据,使用时却按 long 类型来加载入本地变量表中。
    • 保证任何跳转指令都不会跳转到方法体意以外的字节码指令上。
    • 保证方法体中的类型转换总是有效的,例如可以把子类对象赋值给父类数据,但是把父类对象赋值给子类数据类型,甚至是毫不相干的数据类型则不合法。
  4. 符号引用验证:这个验证行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将会在连接的第三阶段解析阶段中发生。符号引用验证是验证该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。主要验证的内容:
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
    • 符号引用中的类、字段、方法的可访问性(private、protected、public,default)是否可被当前类访问。

符号引用验证阶段不通过会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等。验证阶段是一个非常重要的,但是却不是必须的阶段,如果程序的代码是经过反复使用和验证过的,可以通过 -Xverify:none 参数来关闭大部分的类验证措施,从而缩短虚拟机类加载的时间。

3.3 准备

准备阶段是正式为类中定义的变量(类变量,static修饰)分配内存并设置初始值的阶段。从概念上讲,这些类变量所使用的内存应当在方法区中进行分配,方法区本身就是一个逻辑上的区域,JDK 7 及以前,HotSpot 使用永久代来实现方法区时,是完全符合这种逻辑概念的,而 JDK 8 及之后,类变量会随着 Class 对象一起存放在 Java 堆中,这时候"类变量在方法区"就完全是一种对逻辑概念的表述了。

准备阶段仅仅对类变量进行内存分配,且初始值是零值,真正赋值要在初始化阶段执行<clinit>() 时才会正式赋值。而实例变量则会随着对象实例化时一起分配在堆中。

下面是基本数据类型的零值表。 image.png

但是如果是类常量(static、final修饰),则会在准备阶段就赋值为真实值。

3.4 解析

解析阶段是 JVM 将常量池中的符号引用替换为直接引用的过程。

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

JVM 规范没有规定解析阶段的具体时间,只要求在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、 invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、 new、putfield 和 putstatic 这 17 个操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机的实现可以根据需要来自行判断是在类被加载时就对常量池中的符号引用进行解析,还是等到一个符号引用要被使用时才进行解析。

对方法或者字段的访问,也会在解析阶段中对它们的可访问性进行检查。

对于非 invokedynamic 指令,虚拟机可以对第一次解析的结果进行缓存,并标识为以解析状态,从而避免重复解析。虚拟机会需要保证在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求应当一直成功,如果第一次解析失败,后续的解析也应该收到相同的异常。

对于 invokedynamic 指令,这个指令的本来目的就是用于动态语言的支持,需要程序实际运行到这条指令时,解析动作才能进行。相对于 invokedynamic 指令,其他指令都是静态的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前解析。

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

1.类或接口的解析

假设当前代码在类 A, 现在需要解析 A 的一从未被解析过的符号引用 B,这个符号引用指向的是类或者接口 C,那么虚拟机需要 3 个步骤来完成解析:

  1. 如果 C 不是数组类型,虚拟机将会把 B 代表的全限定名传递给 A 的类加载器去加载 C。过程中由于元数据验证、字节码验证等,可能会触发其他相关类的加载,这个过程中一旦出现异常,解析过程就失败了。
  2. 如果 C 是数组类型,并且数组的元素类型为对象,则会按照第一点的规则加载数组元素类型,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
  3. 如果上面两步没有异常,则 C 在虚拟机中已经是一个有效的类或者接口了,但是解析完成前还要进行符号引用验证,确认 A 是否具备对 C 的访问权限。如果不具备会抛出 java.lang.IllegalAccessError 异常。

针对第三点:在 JDK 9 引入模块化后,除了 public 外,还需要至少满足下面三点中的一点,A 才具备对 C 的访问权限。

  1. 被访问的 C 是 public 的,并且与访问类 A 处于同一模块。
  2. 被访问的 C 是 public 的,不与访问类 A 处于同一模块,但是被访问类 C 的模块允许被访问类 A 的模块进行访问。
  3. 被访问的 C 不是 public 的,但是它与访问类 A 处于同一个包中。

2.字段解析

首先将字段表内的 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的引用。解析过程中出现任何异常,都会解析失败。如果解析成功,按照下面的步骤进行字段的搜索:

  • 如果当前类本身就包含了简单名称和字段描述符都相符的字段,则返回这个字段的直接引用,查找结束。
  • 否则,如果当前类实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果搜索到简单名称和字段描述符与目标匹配的字段,则返回这个字段的直接引用,查找结束。
  • 如果当前类不是 java.lang.Object 类的话,会按照继承关系从下往上递归搜索它的父类,如果搜索到简单名称和字段描述符与目标匹配的字段,则返回这个字段的直接引用,查找结束。
  • 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
  • 如果查找成功返回了引用,还会对字段进行权限验证,如果不具备字段的访问权限,将会抛出 java.lang.IllegalAccessError 异常。

3.方法解析

首先将方法表中的 class_index 项中的方法所属的类或者接口的符号引用进行解析。如果解析成功,则按照下面的步骤进行方法搜索:

  • 由于 Class 文件中,类方法和接口方法的符号引用的常量类型定义是分开的,所以如果在类的方法表中发现 class_index 中索引的类是一个接口的话,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
  • 如果通过了第一步,则在当前类中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回方法的直接引用,查找结束。
  • 否则,递归查找当前类的父类中是否有简单名称与描述符都与目标相匹配的方法,如果有则返回方法的直接引用,查找结束。
  • 否则,在当前类实现的接口列表,以及它们的父接口递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明当前类是一个抽象类,查找结束,抛出 java.lang.AbstractMethodError 异常。
  • 否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError。
  • 最后,如果在查找过程中查找成功,返回了直接引用,还将会对这个方法进行权限验证,如果发现不具备对次方法的访问权限,将抛出 java.lang.IllegalAccessError 异常。

4.接口方法解析

接口方法解析也需要先解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,按照下面的步骤搜索方法:

  • 如果在接口方法表中,发现 class_index 中索引的是个类,而不是一个接口,那么就直接抛出 java.lang.IncompatibleClassChangeError 异常。
  • 否则,在当前接口中查找是否有简单名称和描述符都与目标匹配的方法,如果有则返回方法的直接引用,查找结束。
  • 否则,递归查找当前接口的父接口,直到 java.lang.Object 类为止(包括 Object 类),查找是否有简单名称和描述符都与目标匹配的方法,如果有则返回方法的直接引用,查找结束。
  • 由于 Java 接口允许多重继承,所以如果当前接口的父接口中存在多个简单名称和描述符与目标相匹配的方法,将会返回一个,并结束查找。具体返回哪一个,根据虚拟机的实现不同,有所不同。
  • 否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError 异常。
  • 在 JDK 9 之前,接口中所有的方法默认都是 public,也没有模块化访问约束。但是在 JDK 9 中增加了接口的静态私有方法,也有了模块化访问约束,接口方法的访问也完全有可能因访问权限控制而出 现 java.lang.IllegalAccessError 异常。

3.5 初始化

初始化是类加载的过程的最后一个步骤,之前的阶段都是 JVM 来主导控制的。直到初始化阶段,JVM 才真正开始执行类中编写的 Java 代码,主导权才交给应用程序。

准备阶段时,类变量已经赋值过初始零值。初始化阶段则会去初始类变量和其他资源。也就是说开始执行类构造器 <clinit>() 方法。

  • <clinit>() 是 Javac 编译器自动生成的,它是搜集了所有的类变量和静态代码块语句合并产出的,<clinit>() 中,它收集的静态变量和静态代码块的顺序是由它们在源文件中出现的顺序决定的。
  • 静态语句块只能访问到定义在它之前的变量,定义在它之后的变量能赋值,但是不能访问。
public class Test { 
    static { 
        // 给变量复制可以正常编译通过 
        i = 0; 
        // 这句编译器会提示“非法向前引用” 
        System.out.print(i); 
    } 
    static int i = 1; 
}
  • <clinit>() 方法不需要显示的调用父类的 <clinit>() 方法。JVM 会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完成。因此 JVM 中第一个被执行的 <clinit>() 方法类型肯定是 java.lang.Object 类。
  • 由于父类的 <clinit>() 先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
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) { 
    // B 的值为 2,而不是 1。
    System.out.println(Sub.B); 
}
  • <clinit>() 方法对类或接口来说不是必须的,如果类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。
  • 接口中不能使用静态语句块,但是仍然有变量初始化的赋值操作,因此接口与类一样会生成 <clinit>() 方法。但是执行接口的 <clinit>() 方法不需要先执行父类接口的 <clinit>() 方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也不一定会执行接口的 <clinit>() 方法。
  • JVM 必须保证一个类的 <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
*/

4. 类加载器

通过一个类的全限定名来获取描述该类的二进制字节流,这个动作被放到了 JVM 外部去实现,以便应用程序自己决定如何去获取所需的类,这个动作的实现代码被称为类加载器。

类加载器在类层次划分、OSGi、程序热部署、代码加密领域大放异彩,是 Java 技术体现中一块重要的基石。

1. 类与类加载器

JVM 中类的唯一性是由类和类加载器共同决定的,对于类加载器,每个类加载器都有一个独立的类名称空间,对于类,如果不是同一个类加载器加载的,那它们就是不同的,这里的不同包含:Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法、instanceof 关键字对对象所属关系的判断,都会返回 false。

2. 双亲委派模型

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

站在 Java 开发者角度,Java 有三层类加载器,并且有双亲委派的类加载机制。

  • 启动类加载器:这个类加载器负责加载存放在 <java_home>\lib 目录下,或者 -Xbootclasspath 参数指定的路径下存放的,而且是 JVM 能识别的(按照文件名识别,如:rt.jar、tools.jar,名字不符合的类库放在 lib 目录下也不会被加载)类库加载到虚拟机内存中。启动类加载器无法直接被 Java 程序引用,用户在编写自己的类加载器时,如果想把类委派给启动类加载器处理,直接使用 null 值代替即可。
  • 扩展类加载器:这个类加载器是以 Java 代码实现的。它负责加载 <java_home>\lib\ext 目录中,或者 java.ext.dirs 系统变量中所指定的路径中所有的类库。JDK 9 之后这种扩展机制被模块化带来的天然扩展能力所取代。由于扩展类加载器是 Java 代码实现的,开发者可以直接在程序中使用这个类加载器来加载 Class 文件。
  • 应用程序类加载器:这个类加载器是 ClassLoader 类中,getSystem-ClassLoader() 方法的返回值,所以有时候它被称作系统类加载器。它负责加载用户类路径下的所有类库,开发者可以直接在代码中使用这个类加载器。

image.png

在 JDK 9 之前,Java 应用都是由这三种类加载器互相配合来完成加载的,用户还可以加入自定义类加载器来进行扩展。典型的例如:增加了磁盘以外的 Class 文件来源,或者通过类加载器实现类的隔离、重载等。

上图展示的是类加载器的双亲委派模型,双亲委派模型要求除了顶层的启动类加载器之外,其他的类加载器都应该有自己的父类加载器,不过这个父子关系不是通过继承来实现的,而是通过组合关系来复用父加载器的代码。双亲委派机制是 JDK 推荐的最佳事件,而不是一种强制约束。

双亲委派的工作过程:

先检查请求加载的类是否已经被当前类加载器加载过,如果没有被加载,则调用父类的 loadClass() 方法,如果父类加载器为 null,则默认使用启动类加载器作为父类加载器。如果父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载。

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { 
    // 首先,检查请求的类是否已经被加载过了 
    Class c = findLoadedClass(name); 
    if (c == null) { 
        try {
            if (parent != null) { 
                c = parent.loadClass(name, false); 
            } else { 
                c = findBootstrapClassOrNull(name); 
            } 
        } catch (ClassNotFoundException e) { 
            // 如果父类加载器抛出ClassNotFoundException 
            // 说明父类加载器无法完成加载请求 
        } 
        if (c == null) { 
            // 在父类加载器无法加载时 
            // 再调用本身的findClass 方法来进行类加载 
            c = findClass(name); 
        } 
    }
    if (resolve) { 
        resolveClass(c); 
    }
    return c; 
}

双亲委派机制显而易见的好处是:Java 中的类随着它的类加载器而具备了优先级的层次关系。例如:java.lang.Object 类,它存放在 rt.jar 之中,不管哪个类加载器去加载它,最终都会委派给启动类加载器,因此 Object 类在程序的各个类加载器环境都能保证是同一个类,如果没有双亲委派机制,用户自定义一个 Java.lang.Obejct 类,并放在 ClassPath 下进行加载,会导致系统中出现多个不同的 Object 类。从而导致 Java 体系中最基础的行为也无法保证。

3. 破坏双亲委派模型

双亲委派机制,到 Java 模块化出现为止,出现了三次较大规模的破坏。

第一次是加入双亲委派机制时,在 JDK 1.2 之前就已经抽象出了 ClassLoader 类,因此为了向前兼容,没有办法阻止 loadClass() 被覆写。所以加入了 findClass() 方法,并鼓励用户覆写这个方法来实现类加载逻辑。双亲委派逻辑是在 loadClass() 中实现的,当父类加载失败后,会自动调用 findClass() 方法来完成加载。

第二次破坏是由于双亲委派机制本身的缺陷,双亲委派很好的解决了各个类加载器协作时基础类型一致性问题,越基础的类由越高级的类加载加载。但是如果基础类要调用用户代码,就没有办法实现了。比如:JDBC,JDBC 接口是由 Java 提供,由启动类加载器加载。但是具体的实现类,比如 Mysql、Oracle 是由三方厂商实现。需要由应用类加载器加载。但是每个类都会使用自己的类加载去加载自己依赖的类。对于 JDBC 来说,当加载 JDBC 接口时,我们需要加载到它对应的实现类。由于 JDBC 是由启动类加载,但是启动类加载器加载不到 JDBC 的实现类,所以这时候就引入了线程上下文类加载器。在默认情况下,线程上线文类加载器是应用类加载器,所以当启动类加载了 JDBC 的接口后,将将加载 JDBC 实现类的任务委托给线程上下文类加载器去加载,从而打破了双亲委派机制。

4. Java 模块化系统

模块化的关键目标是:可配置的封装隔离机制。

Java 的模块定义除了充当代码的容器,还包括:

  • 依赖其他模块的列表。
  • 导出的包列表,即其他模块可以使用的列表。
  • 开放的包列表,即其他模块可以反射访问模块的列表。
  • 使用的服务列表。
  • 提供服务的实现列表。

可配置的封装隔离机制首先要解决 JDK 9 之前,基于类路径来查找依赖的可靠性问题。之前如果类路径中缺失了运行时依赖类型,那就只有等到程序运行到发生该类型的加载、链接时才会报错。在 JDK 9 以后,如果启用了模块化封装,模块就可以声明对其他模块的显示依赖,从而让虚拟机在启动时就验证程序的依赖关系是否完备,从而避免很大一部分由于类型依赖而引发的运行时异常。

可配置的封装隔离机制还解决了原来类路径上跨 jar 文件 public 类型的可访问性问题。JDK 9 中的 public 不再意味着程序的所有地方的代码都可以随意访问到它们。模块提供了更精细的可访问性控制。必须明确申明其中哪些 public 的类型可以被其他哪一些模块访问,这种访问控制也是再类加载过程中完成的。

1. 模块的兼容性

为了使可配置的封装隔离机制能兼容传统类路径查找机制,JDK 9 提出了与类路径相对应的模块路径的概念。简单来说就是某个类库被当作传统的 jar 包,还是被当作模块,只取决于它放在哪个路径上。在类路径上都会被当作传统 jar 包,在模块路径上都会被当作模块。

  • jar 文件在类路径的访问规则:所有类路径下的 jar 文件及其他资源文件,都会被视为自动打包在一个匿名模块里。这个匿名模块几乎没有任何隔离,它可以看到和使用类路径上所有的包、JDK 系统模块中所有的导出包、模块路径上所有模块中导出的包。
  • 模块在模块路径的访问规则:模块路径下的具名模块只能访问到它依赖定义中列明依赖的模块和包,匿名模块里的所有内容对具名模块都不可见,即看不见传统 jar 包。
  • jar 文件在模块路径的访问规则:如果包一个传统的、不含模块定义的 jar 文件放置到模块路径中,它会变成一个自动模块,自动模块默认依赖与整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。

Java 模块化系统目前不支持在模块定义中加入版本号来管理约束依赖,本身也不支持多版本号哥得概念和版本选择功能。但是在 Java 命令和 Java 类库的 API 或者是 JVM 规范中,都能找模块版本在编译、加载、运行期期间是可以使用的。

2.模块化下的类加载器

JDK 9 的类加载器做了一些变动。

  • 首先,扩展类加载器被平台类加载器取代,因为 JDK 基于模块化进行构建,Java 类库天然已经满足了可扩展的需求,因此无需保留通过 <java_home>\lib\ext 目录来扩展 JDK 功能的机制了。类似的也取消了 <java_home>\jre 目录,因为可以通过组合构建出程序所需的 jre来。
  • 其次,平台类加载器和应用程序类加载器不再派生自 java.net.URLClassLoader,如果程序之前继承了这个类,则在 JDK 9 及以上会崩溃。现在启动类加载器、平台类加载器、应用程序类加载器都继承自 jdk.internal.loader.BuiltinClassLoader 。 image.png
  • 最后,JDK 9 中仍然维持着三层类加载器和双亲委派架构,但是类加载的委派关系也发生了变动,当平台及应用程序类加载器收到加载请求,在委派给父类加载器前,要先判断该类是否能够归属到某一系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。