概述
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
类加载的时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期将会经历以下阶段:
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
“加载”阶段《Java虚拟机规范》中并没有进行强制约束,看不同虚拟机的实现自由把握。对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
- 使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
- 调用一个类型的静态方法的时候。
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
上述6种类型的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
/**
* 通过子类引用父类的静态字段,不会导致子类初始化
* 只有直接定义这个字段的类才会被初始化,因此通过子类引用父类定义的静态字段,只会触发
* 父类的初始化而不会触发子类的初始化
*/
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
/**
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
*/
class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String helloWorld = "hello world";
}
/**
* 非主动使用类字段演示
*/
class NotInitialization {
public static void main(String[] args) {
// System.out.println(SuperClass.value);
//通过数组定义来引用类,不会触发此类的初始化
SuperClass[] sca = new SuperClass[10];
System.out.println(ConstClass.helloWorld);
}
}
虽然源码中引用了ConstClass中的常量helloWorld,但其实在编译阶段通过常量传播优化,已经将常量的值直接存储在NotInitialization类的常量池中了。
接口的加载过程与类加载稍有不同:接口也有初始化过程,上面的代码都是有static代码块来输出初始化信息的,而接口不能使用static代码块,但编译器仍然会为接口生成
构造器,用来初始化接口中所定义的成员变量。
接口与类真正不同的是触发初始化场景的第三种:当一个类在初始化时,要求其父类全部都已经初始化过,但是一个接口在初始化时,并不要求父接口全部都完成初始化,只有在真正使用父类接口的时候(如引用接口中定义的变量)才会初始化
类加载的过程
加载
在加载阶段,Java虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
非数组类型的加载阶段是开发人员可控的,加载阶段的类加载器可以使用虚拟机内置的引导类加载器,也可以自定义类加载器完成(重写一个类加载器的findClass或loadClass方法)。
对于数组类本身不通过类加载器创建,是由虚拟机直接在内存中动态构造出来的。但是数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器完成加载,一个数组类创建过程遵循以下规则:
- 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程加载这个组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间
- 如果数组的组件类型不是引用类型(例如int[]数组的组件类型是int),虚拟机将会把数组标记为与引导类加载器关联
- 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public。
加载阶段结束后,虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。
验证
这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证
- 文件格式验证
第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点(一小部分):
是否以魔数0xCAFEBABE开头
主、次版本号是否在当前Java虚拟机接受范围之内
常量池的常量中是否有不被支持的常量类型(检查常量tag标志)
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据
-
Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
- 元数据验证
第二阶段是对字节码描述的信息进行语义分析,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)
- 字节码验证
主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会危害虚拟机安全,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的
- 符号引用验证
本阶段通常需要校验下列内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类
在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
-
符号引用中的类、字段、方法的可访问性(
private、protected、public、
)是否可被当前类访问符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
准备
准备阶段是正式为类中定义的变量(仅包括类变量,而不包括实例变量)(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。这里的初始值是各数据类型的零值。例如:public static int value=123,其实在准备阶段后初始值为0而不是123。
特殊情况:如果类字段的字段属性表中存在ConstantValue属性,会根据程序指定的初始值初始化。例如:public static final int value=123。
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,在Class文件中它以CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
- 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的。
除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。invokedynamic指令的目的本来就是用于动态语言支持,对应的引用称为“动态调用点限定符 (Dynamically-Computed Call Site Specifier),这里“动态”的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、 CONSTANT_Methodref_info、CONSTANT_InterfaceM ethodref_info、 CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info 8种常量类型。
- 类或接口的解析
假设当前所处的类为D,要把一个未解析的符号引用N解析为类或接口C的直接引用,整个解析的过程包括以下3个步骤:
- 如果C不是一个数组类型,那虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据和字节码验证的需要,又可能触发其他相关类的记载动作,例如加载这个类的父类或者实现的接口。
- 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符全是类似[Ljava/lang/Integer]的形式,将会按照第一点的规则加载数组元素类型,虚拟机生成一个代表该数组维度和元素的数组对象。
- C在虚拟机中实际已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确定D是否具备对C的访问权限。在JDK9引入模块化后,还必须检查模块间的访问权限
如果说一个D拥有C的访问权限,就意味着以下3条规则至少有一条成立:
- 被访问类C是public,并且与访问类D处于同一个模块
- 被访问类C是public的,不与访问类D同一个模块,但是被访问类C的模块允许访问类D的模块进行访问
- 被访问类C不是public的,但是它与访问类D处于同一个包中
-
字段解析
要解析一个未被解析过的字段符号引用,首先要找到字段所属的类或接口的符号引用,把这个字段所属的类或接口用C表示,对C进行后续字段搜索步骤:
- 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,接口搜索:如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,查找对应的简单名称和字段描述符的字段,则返回字段的直接引用,查找结束。
- 否则,类搜索:如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,查找对应的简单名称和字段描述符的字段,则返回字段的直接引用,查找结束。
- 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
找到的字段需进行权限验证,如果不具备权限,将抛出java.lang.IllegalAccessError异常。
-
方法解析
需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,用C表示这个类,搜索按以下步骤:
- 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError异常。
- 在类C中查找有简单名称和描述符都匹配的方法,如果有则直接返回这个方法的直接饮用,查找结束
- 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError异常
- 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
最后需要对方法进行权限验证,验证是否具备此方法的访问权限
-
接口方法解析
接口方法也是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,用C表示这个接口,搜索步骤如下:
- 与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出java.lang.IncompatibleClassChangeError异常。
- 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括 Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符 都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并 没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。
- 否则,宣告方法查找失败,抛出java.lang.NoSuchM ethodError异常。
在JDK9之前,没有模块化,所有的接口默认是public,所以没有权限的问题。在JDK9之后增加了接口的静态私有方法,也有模块化的访问结束。
初始化
在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。初始化阶段就是执行类构造器
方法的过程。
是Javac编译器的自动生成物。
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
public class Test {
static {
i = 0;//给变量赋值可以正常编译通过
System.out.println(i);//这句编译器会提示"非法向前引用Illegal forward reference"
}
static int i = 1;
}
方法与类的构造函数(即在虚拟机的实例构造器
方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的
方法执行前,父类的
方法已经执行完毕。因此在虚拟机中第一个被执行的
方法的类型肯定是java.lang.Object。
由于父类的
方法先执行,就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
/**
* ()方法执行顺序
* 输出结果是2
*/
public class ClinitTest {
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);
}
}
-
方法对于类或接口并不是必须的,如果一个类中没有静态语句块,没有对变量的赋值操作,这个类可以没有()
方法() - 接口中不能使用静态语句块,但仍有变量初始化赋值操作,因此接口也会生成
方法。但执行接口的()
方法不需要父接口的()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的()
方法。() - Java虚拟机必须保证一个类的
方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()
方法,其他线程都需要阻塞等待,直到活动线程执行完毕()
方法。如果在一个类的()
方法中有耗时很长的操作,那就可能造成多个进程阻塞(如果第一个线程执行结束,则其他线程不会再次进入()
)。()
/**
* 字段解析
* 一条线程在死循环模拟长时间操作,另外一条线程在阻塞等待
* 执行结果如下,且程序一直运行:
* Thread[Thread-0,5,main]start
* Thread[Thread-1,5,main]start
* Thread[Thread-0,5,main]init DeadLoopClass
*/
public class DeadLoopClassTest {
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 = () -> {
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();
}
}
类加载器
类与类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。判断两个类是否相等,必须在同一个类加载器,且同一虚拟机,这样比较才有意义。
两个类的相等包括代表类Class对象的equals方法、isAssignableFrom方法、isInstance方法的返回结果,也包括使用instaceof关键字做对象所属关系判定等各种情况。
import java.io.IOException;
import java.io.InputStream;
/**
* 不同的类加载器对instanceof关键字运算的结果的影响
* 运行结果:
* class part7.ClassLoaderTest
* false
*/
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
try {
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("part7.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
//虽然上面一行输出结果与part7.ClassLoaderTest一样,但其实已经属于不同的加载器中的两个不同的类
System.out.println(obj instanceof part7.ClassLoaderTest);
}
}
双亲委派模型
此处的双亲委派并非是继承关系,更确切的描述应该是关联关系,上下级关系。
站在虚拟机的角度来看,只存在两种类加载器:
- 启动类加载器(Bootstrap ClassLoader,C++实现),是虚拟机一部分
- 其他所有类加载器(Java实现),独立存在于虚拟机外部,继承自抽象类java.lang.ClassLoader
站在Java开发人员角度,从JDK1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。
- 启动类加载器(Bootstrap Class Loader):负责加载
目录或者-Xbootclasspath参数所指定的路径中存放的,而且是虚拟机能识别的(例如.jar)类库加载到虚拟机内存中。启动类无法被Java程序直接引用,如果需要把加载请求委派给引导类加载器去处理,直接使用null代替即可。\lib
/**
* Returns the class loader for the class. Some implementations may use null to represent the bootstrap class loa
* ClassLoader.getClassLoader()方法的代码片段
*/
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;
}
- 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。负责加载
\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。 -
应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。负责加载用户类路径(ClassPath)上所有的类库。一般情况下这是程序中默认的类加载器。
双亲(并非是继承关系)委派模型工作过程:如果一个类加载器收到类加载的请求,首先不会自身加载这个类,而是将请求委派给父类加载器去完成,每个层次的类加载器都是如此,因此所有加载请求最终都应该传送到最顶层的启动类加载器中,只有当父类加载器无法完成加载任务(即在搜索范围内没有找到所需类)时,子加载器才会尝试自身完成加载。
用双亲委派模型来组织类加载器之间的关系,好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。
//java.lang.ClassLoader的loadClass()方法之中双亲委派模型的实现
protected Class> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先,检查请求的类是否已经被加载过了
Class> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
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;
}
}
破坏双亲委派模型
经典场景:JNDI服务,JNDI现在已经是Java的标准服务,由启动类加载器完成加载。但JNDI存在的目的是对资源进行查找和集中管理,需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码。
实现逻辑:
线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方 法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器。在JDK 6时,JDK提供了 java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
Java模块化系统
在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS),为了实现可配置的封装隔离机制。JDK9的模块不仅像之前的JAR包充当代码的容器,Java的模块定义还包含以下内容:
- 依赖其他模块的列表
- 导出的包列表,即其他模块可以使用的列表
- 开放的包列表,即其他模块可反射访问模块的列表
- 使用的服务列表
- 提供服务的实现列表
问题:JDK9之前是基于类路径(ClassPath)来查找依赖的可靠性。如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接才能报出运行的异常。
解决:JDK9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败。
问题:原来类路径上跨JAR文件的public类型的可访问性问题。
解决:JDK9模块提供了更精细的可访问性控制,必须明确声明其中哪一些public类型可以被其他哪些模块访问,访问控制主要在类加载过程中完成的。
模块的兼容性
JDK9为了使可配置的封装隔离机制能够兼容以前的类路径查找机制,采取了以下方式:
类路径(ClassPath):兼容JDK9以前的类路径查找机制
模块路径(ModulePath):使用模块的方式处理
模块化规则:
- JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在 一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。
- 模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。
- JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块(Automatic M odule)。尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。
模块化下的类加载器
JDK9为了模块化系统顺利施行,模块化下的类加载器主要有以下几个方面的改动:
-
扩展类加载器被平台类加载器(Platform Class Loader)取代。Java类库天然满足了可扩展的需求,并且能随时组合构建出程序运行的JRE来,所以取消了
和\lib\ext
目录\jre #打包一个JRE jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre
-
平台类加载器和应用程序类加载器都不在派生自java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
JDK9虽然保持了三层类加载器和双亲委派的架构。但是当平台及应用程序加载器收到类加载请求,在委派给父加载器前,要先判断该类是否能够归属到某个系统模块中,如果可以找到,则优先委派给负责该模块的加载器加载。
各类加载器的归属关系如下:
-
启动类加载器负责加载的模块:
-
平台类加载器负责加载的模块:
-
应用程序类加载器负责加载的模块:
源自书籍:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)-周志明