上篇文章讲解了 JVM 字节码,本节将会介绍 JVM 类加载机制。
类加载机制是 JVM 的运行时动态加载机制。会增加一定的运行时开销,但提供了极高的拓展性和灵活性。比如,IDEA、Eclipse 等 IDE 的插件系统,都是基于此实现的。
类加载的时机
一个类型从被加载到 JVM 内从中,到卸载出内存为止,一共会经历几个阶段:
- 加载(Loading)
- 链接(Linking)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
其中,加载、验证、准备、初始化、卸载,这五个阶段的开始顺序是决定了的。但解析阶段则不一定,也可以在初始化之后再开始,这是为了运行时绑定的特性。这里是说开始,基于性能考虑和实际情况,这些阶段通常都是交叉进行的。
关于什么时候要开始类加载中的「加载」阶段,《JVM 规范》并未强制要求。但对于「初始化」阶段,《JVM 规范》要求了几种情况前必须完成:
- 遇到
new
getstatic
putstatic
invokestatic
指令,应该先初始化。常见场景有new
关键字创建对象时- 读取或设置一个类型的静态字段时(
public final static String
这种已经在常量池的静态字段除外) - 调用一个类型的静态方法时
- 使用反射(具体而言为使用了
java.lang.reflect
)时 - 若类有父类,应该先初始化其的父类
- 包含
main()
方法的类,应当首先初始化 - JDK 7 后,
java.lang.invoke.MethodHandle
实例最后解析结果若为REF_getStatic
REF_putStatic
REF_invokeStatic
REF_newInvokeSpecial
四种类型的句柄,且对应类没有初始化,应当先初始化 - 当接口使用了 JDK 8 新加入的
default
时,应当先初始化对应的接口
《JVM 规范》对以上六种情况的限定词是「有且仅有」。这六种场景称为主动引用。除此之外的所有引用类型方式,都不会触发初始化,称为被动引用。
类加载过程
类加载过程,即指加载、验证、准备、解析、初始化这五步。不包含使用和卸载。
加载
「加载」是「类加载」中的一个阶段,注意不要混淆了。
JVM 在加载阶段需要完成三件事:
- 通过全限定类名(Qualified Class Name)获取二进制字节流
- 将字节流转换成方法区的运行时数据结构
- 生成对应的
java.lang.Class
,作为方法区数据的访问入口
《JVM 规范》的要求比较抽象,并不涉及具体如何加载,零活度相当大。例如第一条,并没有限定从何处获取二进制字节流,因此使用者可以从多种方式加载类文件:
- 从 ZIP 压缩包中获取,比如 JAR、EAR、WAR 格式
- 从网络中获取,比如 Web Applet
- 运行时计算生成,比如动态代理。
java.lang.reflect.Proxy
就是典型例子,可以为特定接口生成形式为*$Proxy
的代理类- ASM、Javassit、CGLIB 各种字节码生成框架更是数不胜数
- 由其他文件生成,比如 JSP
- 从数据库中读取
- 从加密文件中获取
- …
加载阶段是开发者可控性最强的阶段。既可以使用 JVM 内置的引导类加载器,也可以由用户自定义的类加载器完成。可以根据自己的想法来赋予应用程序获取运行代码的动态性。
但需要注意一个特例,数组类。数组类不通过类加载器创建,而是 JVM 直接在内存中动态构造出来的。一个数组类的加载,遵循以下规则:
- 如果数组的组件类型(Component Type,去掉一个维度的类型,比如
[Ljava.lang.String
的组件类型就是Ljava.lang.String
)是引用类型,那就递归调用加载过程去加载该组件类型。数组 C 将被表示在该类加载器的类命名空间上。(类型和类加载器一起才能确定唯一性) - 若不是引用类型(如
[I
/int[]
的组件类型为int
),JVM 将会把数组 C 与引导类加载器关联 - 数组类的可访问性和组件类型的可访问性一致。若组件类型不是引用类型,可访问性将默认为
public
加载结束后,字节流就会按照具体 JVM
实现存储在方法区(该过程完全自定义,不受约束)。之后,则需要实例化一个对应的
java.lang.Class
对象,将其作为访问方法区的外部接口。
当然,加载和链接阶段是交叉进行的。加载阶段尚未完成时,链接阶段可能就开始了。
验证
Java 是相对安全的编程语言。Java 无法做到访问数组边界,跳到不存在的代码行之类的事情。但 Class 文件并不一定是由 Java 源码编译而来。让一只猴子随机敲打 0 或 1,总有一天也能敲出正常运行的 Hello World。所以 JVM 不能对类文件完全信任,可能存在错误或有恶意企图的字节码流,从而导致整个系统受到攻击或崩溃。
验证阶段很重要,决定了 JVM 是否能承受恶意代码攻击。早期
JVM,运行时验证的工作量很大,耗费了很多尊荣。但规范却相当模糊和笼统。仅仅列举了一些静态和结构化的约束,比如若不符合类文件格式,应当抛出一个
java.lang.VerifyError
等。
直到 2011 年《JVM 规范》SE 7 版本出世,验证阶段才被详细描述(从不到 10 页增加到 130 页)。
验证阶段大致分为四个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证
这一阶段主要验证字节流是否符合类文件规范,并且是接受的版本。包括一下验证点:
- 以
0xCAFEBABE
开头 - 主、次版本号在 JVM 接受范围内
- 常量类型受支持(检查常量 tag)
- 常量索引值应当存在且符合类型
CONSTANT_Utf8_info
型的常量应当符合 UTF-8 编码- …
以上内容只是沧海一粟,实际的验证点比列举的多得多。该阶段主要保证字节流能正确加载到方法区。只有该阶段会读取并操作字节流。
元数据验证
根据字节码描述分析语义,保证类文件符合《JVM 规范》,可以进行的验证包括:
- 除
java.lang.Object
应都有父类 - 继承应该合法,不可继承
final
类 - 非抽象类应该实现父类或接口中要求的所有方法
- 类中的字段不可与父类产生矛盾
- …
字节码验证
这是最复杂的一个阶段。主要是验证方法体(Code
属性),保证代码不会危害虚拟机安全:
- 保证数据类型规范,不能出现需要
long
类型,却放了一个int
的情况 - 保证跳转指令只能在方法体内跳转
- 保证类型转换有效,不可把一个对象赋给子类数据类型,甚至是毫不相关的数据类型
- …
就算类文件通过了这一步骤,也不能保证它是安全的。和停机问题(Halting Problem)类似,通过程序检查程序,是不能做到绝对准确无误的。
由于这一阶段的高度复杂性,在 JDK 6 以后,Javac 和 JVM
联合优化了这一过程。将尽可能多的验证所需措施,挪到编译时。新增了
StackMapTable
属性,理论上只需要检查该属性的合法性即可。节省了大量的验证时间。
符号引用验证
该阶段检查类对外部符号引用的可访问性:
- 全限定类名应当有对应的类
- 应当存在对应的字段名称、类型等
- 可访问性修饰符(
private
protected
public
<package>
)是否允许当前类访问
若该验证失败,JVM 会抛出
java.lang.IncompatibleClassChangeError
异常。典型的有:java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
准备
准备阶段即为类中定义的静态变量分配内存,并设置初始值的阶段。从概念上讲,这些内存应当都分配到方法区中。
首先,此时分配的仅包括类变量,不包括实例变量。另外,这里说的初始值是指默认的零值。和定义局部变量默认的零值一致。而在代码中定义的值,会在
<clinit>()
方法中调用 putstatic
完成。
解析
解析阶段,JVM 会将符号引用转为直接引用,也就是将
CONSTANT_Class_info
CONSTANT_Fieldref_info
CONSTANT_Methodref_info
等类型常量解析的过程。
- 符号引用(Symbolic References):以一组符号来描述引用的目标,可以是任何形式的字面量,只要能无歧义的定位到目标即可。
- 直接引用(Direct References):可以直接指向目标的指针、相对偏移量或句柄。同一个符号引用,常常在不同的虚拟机实现中,对应不同的直接引用。
《JVM 规范》只要求了在哪些指令之前需要解析,所以虚拟机实现可以根据需要,是 lazyload 或在加载时就解析完。
类似的,对方法和字段的访问,也会在解析阶段中检查可访问性。其中的约束规则已是常识,就不再赘述了。
对同一个符号引用请求多次是很常见的,除了 invokedynamic
以外,JVM
都会对第一次解析结果缓存。避免重复创建。invokedynamic
本来就是用来运行时动态创建的,所以自然不需要缓存。
解析动作主要针对类/接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符
7 类符号引用,分别位于常量池 CONSTANT_Class_info
CONSTANT_Fieldref_info
CONSTANT_Methodref_info
CONSTANT_InterfaceMethodref_info
CONSTANT_MethodType_info
CONSTANT_MethodHandle_info
CONSTANT_Dynamic_info
和
CONSTANT_InvokeDynamic_info
8 种类型。下面将介绍前 4
种。而后面 4 种是为了动态语言,所以会在讲 invokedynamic
再涉及。
类或接口解析
若当前代码处于类 \(D\),需要把一个未解析的符号引用 \(N\) 解析为一个类或接口 \(C\) 的直接引用。那么 JVM 需要经历以下步骤:
- 若 \(C\) 不是数组类型,那么 JVM 会将 \(N\) 的全限定类名传递给 \(D\) 的类加载器去加载 \(C\)。在加载过程中,需要递归加载其他类,一旦加载过程中出现了任何异常,解析都会宣告失败。
- 若 \(C\)
是数组类型,并且数组的元素类型是对象,也就是 \(N\) 的描述符类似
[Ljava.lang.Integer
,将会按照第一点的规则加载数组元素类型。需要加载的就是java.lang.Integer
接着由虚拟机生成一个代表该数组维度和元素的数组对象。 - 若两步都无一场,那么 \(C\)
已经称为了一个有效的类或接口了,但在解析完成前,还需要验证 \(D\) 是否具备 \(C\)
的访问权限。如果发现不具备访问权限,将抛出
java.lang.IllegalAccessError
。
- 额外的,若在 JDK 9 之后,还需要检查模块化访问权限。若 \(D\) 有 \(C\) 的访问权限,需要满足以下规则之一:
- \(C\)
public
,和 \(D\) 处于一个模块。 - \(C\)
public
,不与 \(D\) 处于一个模块,但是 \(C\) 所处模块允许访问 \(D\) 的模块访问 - \(C\) 不是
public
,但和 \(D\) 处于一个包中
- \(C\)
字段解析
遇到一个未解析过的字段符号引用,首先会解析字段表
class_index
索引项对应的 CONSTANT_Class_info
类型常量。如果成功解析,将该字段所属的类/接口用 \(C\) 表示,后续操作为:
- 若 \(C\) 包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
- 否则,如果 \(C\) 中实现了接口,则会按照继承关系从上到下搜索接口,对各个接口执行第一步。
- 否则,如果 \(C\) 继承了父类,则会按照继承关系从上到下搜索父类,对各个父类执行第一步。
- 否则,查找失败,抛出
java.lang.NoSuchFieldError
方法解析
和字段解析类似,需要先查找 class_index
索引项对应的方法所属类或接口的符号引用,若解析成功,我们同样用 \(C\) 表示,后续操作为:
- 由于类文件和接口的方法符号引用是分开的,所以如果在类中发现
class_index
的索引是接口的话,会直接抛出java.lang.IncompatibleClassChangeError
异常。 - 之后,会在 \(C\) 中查找是否有简单名称和描述符都与目标匹配的方法,如有则返回对应的直接应用,查找结束。
- 否则,就在父类中查找,若有则返回这个方法的直接引用,查找结束。
- 否则,在 \(C\)
实现的接口列表及它们的父接口之中递归查找,是否有简单名称和描述符都与目标匹配的方法,如果存在匹配的方法,说明
\(C\) 为抽象类,抛出
java.lang.AbstractMethodError
并结束 - 否则,查找失败,抛出
java.lang.NoSuchFieldError
最后,如果查找过程返回了直接引用,将会验证权限,如果未通过,则抛出
java.lang.IllegalAccessError
异常。
接口方法解析
同样也需要先解析 class_index
所指的常量,如果解析成功,用 \(C\)
表示该接口,并按照以下步骤检索:
- 与类解析相反,若在
class_index
中发现 \(C\) 是类而非接口,抛出java.lang.IncompatibleClassChangeError
异常。 - 否则,在 \(C\) 中查找是否有简单名称和描述符都与目标匹配的方法,若有则返回,查找结束。
- 否则,递归查询接口,直到
java.lang.Object
中为止,若有匹配的返回直接引用,查找结束 - 对于规则 3,由于接口允许多重继承,所以若父接口有存在名称和描述符都相同的方法,那么会返回其中任意一个并结束查找。《JVM 规范》并未继续限定应当返回哪个。但很多编译器会按照更严格的约束,拒绝编译这种代码,来避免不确定性。
- 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
。
JDK 9 之前接口不需要检查可访问性,因为必须是 public
的,但模块化系统之后就需要检查了。若不能访问,将会抛出
java.lang.IllegalAccessError
。
初始化
这是类加载的最后一个步骤。此时 JVM 才真正开始运行类中的代码。
在准备阶段时,静态字段已经赋了一次零值,此时 JVM 会调用编译器生成的
<clinit>()
方法,赋为代码中的初值。
<clinit>()
方法,是由编译器收集所有类变量的赋值动作和static {}
块合并生成的。编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问之前的变量,而不能访问之后的。但却允许赋值。
public class Test {
static {
= 0;
i System.out.println(i); // 将会报错, 提示「非法向前引用」
}
static int i = 1;
}
<clinit>()
和<init>()
方法不同,它不需要显式调用父类构造器,因为 JVM 会保证父类先被加载。因此,JVM 中第一个执行<clinit>()
方法的类型一定是java.lang.Object
- 由于
<clinit>()
方法先执行,也就意味着父类中定义的静态语句块,会比子类先执行。以下代码将会输出 2:
static class Parent {
public static int A = 1;
static {
= 2;
A }
}
static class Sub extends Parent {
public static int B = A;
}
static class Main {
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
<clinit>()
方法对于类或接口不是必须的,如果没有static {}
或静态变量,那么编译器可以不生成它。- 接口虽然不运行静态语句块,但仍然有变量初始化的复制操作,因此也有
<clinit>()
方法。但接口和类不同,不需要先执行父接口的<clinit>()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。对于接口的实现类也是这样的。 - JVM 保证了
<clinit>()
的线程安全,若多个线程同时初始化一个类,只有一个线程会执行该方法,其他线程则需要阻塞等待初始化完成。所以应当避免在static {}
块中调用耗时操作。
类加载器
类加载器(Class Loader)是 JVM 平台的一大优势。最初用于 Java Applet 基础,然而如今大浪淘沙,浏览器上的 Applet 虽然淘汰了,但类加载器在类层次划分、OSGi、热部署、代码加密等领域却大放异彩。
类与类加载器
类加载器虽然只用于实现类的加载操作,但对程序的影响却不止步于此。
对于任意一个类,要判断它的唯一性,必须将类型本身和类加载器一起比较。也就是说,比较两个类是否「相等」,必须在是一个类加载器的前提下进行;即使两个类来源于同一个类文件,被同一个 JVM 加载,但只要类加载器不同,那这两个类就必定不相等。
这里的相等,包括 Class
对象的 equals()
isAssignableFrom()
isInstance()
方法,也包括了
instanceof
方法做从属关系判断等各种情况。如果没有注意到类加载器的影响。在某些情况下可能会产生令人困惑的结果。以下代码演示了不同类加载器的影响:
package com.example.test
import java.io.IOException
fun main() {
.forName("com.example.test.Test")
Classval test = Test()
val any = TestClassLoader.loadClass("com.example.test.Test").getDeclaredConstructor().apply {
this.isAccessible = true
}.newInstance()
(any is Test)
println(test is Test)
println}
class Test {
companion object {
@JvmStatic
val static = kotlin.run {
("Loaded")
println}
}
}
object TestClassLoader : ClassLoader() {
override fun loadClass(name: String): Class<*> {
try {
val fileName = name.substring(name.lastIndexOf('.') + 1) + ".class"
val input = javaClass.getResourceAsStream(fileName) ?: return super.loadClass(name)
val b = ByteArray(input.available())
.read(b)
inputreturn defineClass(name, b, 0, b.size)
} catch (e: IOException) {
throw ClassNotFoundException(name)
}
}
}
输出:
Loaded
Loaded
false
true
双亲委派模型
从 JVM
视角来看,只有两种不同的类加载器:一种是启动类加载器(Bootstrap
ClassLoader),该类加载器由 JVM 实现(具体而言,HotSpot VM
的启动类加载器使用 C++
实现),是虚拟机自身的一部分;而其他所有的类加载器,在编译后都是字节码形式,独立存在于虚拟机外部,并全都继承自抽象类
java.lang.ClassLoader
。
在开发视角,类加载器自然应该被更细致得划分。自 JDK 1.2 依赖,Java 一直保持着三层类加载器、双亲委派的类加载结构,尽管在 JDK 9 模块化系统出现后,出现了一些调整,但主体结构还是没有改变的。
本节内容针对 JDK 8 之前的 JVM 介绍。
对于这个时期的应用,多数会使用 3 个系统提供的类加载器来加载:
- 启动类加载器(Bootstrap Class Loader):该类加载器负责加载存在
$JAVA_HOME/lib
目录,或者被-Xbootclasspath
参数指定的路径中存放的,而且 JVM 能够识别的(按照文件名识别,如rt.jar
tools.jar
)类库加载到 JVM 内存中。启动类加载器无法被 Java 程序引用,用户在编写自定义类加载器时,如果要把加载请求委派给引导类加载器处理,直接使用null
代替即可,以下代码摘自java.lang.ClassLoader.getClassLoader()
,展示了null
值代表引导类加载器的通用规则:
// Returns the class's class loader, or null if none.
static ClassLoader getClassLoader(Class<?> caller) {
// This can be null if the VM is requesting it
if (caller == null) {
return null;
}
// Circumvent security check since this is package-private
return caller.getClassLoader0();
}
拓展类加载器(Extension Class Loader):负责加载
$JAVA_HOME/lib/ext
中的文件。在 JDK 9 中被删除。应用程序类加载器(Application Class Loader):也叫系统加载器(System Class Loader):用于加载用户类路径上所有的类库,开发者同样也可以使用该类加载器。如果程序中没有定义过该加载器,一般情况下这就是默认的类加载器。
JDK 9 之前的类加载都是由着三个类加载器互相配合来完成的,如果有必要,还可以增加自定义的类加载器来进行拓展,典型的就是加载磁盘之外的类文件,或者通过类加载器实现类隔离、重载等功能。
类加载器之间的层次关系使用双亲委托模型(Parents Delegation Model),它要求除了顶层的类加载器之外,其余的类都应当有自己的父加载器(不是指继承关系,是指代理上的父类)。
该模型的工作流程是:如果一个收到了类加载请求,它首先不会尝试自己加载,而是将请求委派给父类。每一个层次的加载器都是如此。因此所有的加载请求最终都应该传送到最顶层的启动类加载器中。只有当父加载器反馈自己无法完成加载请求(搜索范围中没有找到需要的类)时,子加载器才会尝试自己加载。
该流程有一个显而易见的好处,就是具备了一种带有优先级的层次关系。例如类
java.lang.Object
无论哪个类加载器要加载这个类,最终都是委托给启动类加载器,因此能够保证
java.lang.Object
的同一性。反之,若没有使用双亲委派模型,都由自己加载的话,可能就会出现多个不同的
Object
类,最基础的行为也就无从保证,应用程序会变得一片混乱。
该模型的实现其实很简单:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 委托父加载器进行查找
= parent.loadClass(name, false);
c } else {
= findBootstrapClassOrNull(name);
c }
} catch (ClassNotFoundException e) {
// 说明父类无法完成加载请求
}
if (c == null) {
// 父类无法加载时,再调用本身的 findClass 方法加载
= findClass(name);
c }
}
if (resolve) {
resolveClass(c);
}
return c;
}
值得一提,双亲委派模型并不是一个强制的标准,而是推荐的做法。所以后来根据需求,双亲委派模型会被破坏许多次。
破坏双亲委派模型
第一次破坏
第一次「被破坏」发生在双亲委派模型出现之前——即 JDK 1.2
面世以前。由于双亲委派模型 JDK 1.2 才被加入,但类加载器的概念和抽象类
java.lang.ClassLoader
在 Java
第一个版本中就已存在,面对已经存在的用户自定义类加载器。Java
设计者在引入双亲委派模型时,不得不做出一些妥协,为了兼容这些代码,无法再以技术手段避免
loadClass()
被子类覆盖的可能。只能添加一个新的
protected
方法
findClass()
,并引导用户编写类加载逻辑时,尽可能去重写新方法,而不是更改
loadClass()
。上节我们已经分析过
loadClass()
。按照其中的逻辑,如果父类加载失败,会自动调用自己的
findClass()
方法来完成加载。
第二次破坏
这次是由于自身模型的缺陷导致的。双亲委派很好地解决了,各个类加载器协作时基础类型的一致性问题。基础类型往往总是作为被用户代码继承、调用的 API 存在,但程序设计往往没有绝对不变的规则。
但如果有基础类型需要调回用户的代码,怎么办?
一个典型的例子是 JNDI 服务,它是很基础的 Java 服务,由启动类加载器加载。但 JNDI 的目的是为了查找资源并集中管理,需要通过 SPI 接口(Service Provider Interface)调用其他应用程序代码。然而问题来了,启动类加载器是绝不可能认识、加载这些代码的。
为了解决这个问题。Java
设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context
Class Loader)。这个类可以通过 java.lang.Thread
类的
setContextClassLoader()
方法设置,若创建线程时未被设置,它将会从父线程中继承一个,如果全局范围都未设置,那么默认就是应用程序类加载器。
如此一来,JNDI 就可以使用该加载器去加载所需的 SPI 服务代码,是一种父类加载器请求子类加载器完成加载的行为。实际上是打破了双亲委派模型,逆向使用类加载器。Java 中涉及 SPI 的加载都采用这种方式完成,例如 JNDI、JDBC、JCE、JAXB、JBI 等。
当 SPI 提供者多于一个时,代码就只能根据提供者的类型硬编码判定。所以在
JDK 6 中,新增了 java.lang.ServiceLoader
类,以
META-INF/services
中的配置信息,加上责任连模式,才算给 SPI
加载提供了相对合理的方式。
第三次破坏
为了满足代码热替换、模块热部署等需求。双亲委派模型第三次被破坏了。
用户希望应用程序像电脑外设一样,接上鼠标、U 盘,无需重启即可使用。
Java 急需模块化系统。OSGi 就是由 IBM 主导的模块化系统。它实现类热加载的关键,就是自定义的类加载器机制。每一个程序模块(OSGi 中称作 Bundle)都有一个类加载器,当需要替换 Bundle,就把 Bundle 连同类加载器一起替换掉,实现热替换。OSGi 不再使用双亲委派模型推荐的树状结构,而是更加复杂的网状结构。OSGi 按以下顺序进行类搜索:
- 将
java.*
委派给父类加载器 - 否则,将委派列表名单内的类,委派给父类加载器
- 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器
- 否则,查找当前 Bundle 的 ClassPath,用自己的类加载器加载
- 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器
- 查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载
- 否则,类查找失败
上面的查找顺序中,只有开头两点符合双亲委派模型,其余的类查找都是在平级的类加载器中进行的。
Java 模块化系统
JDK 9 中引入的 Java 模块化系统(JPMS,Java Platform Module System)实现了模块化的关键目标——可配置的封装隔离机制,JVM 也对类加载架构做了相应调整。现在 Java 的模块定义还包括以下内容:
- 依赖其他模块的列表
- 导出包的列表,即其他模块可以使用的列表
- 开放包的列表,即其他模块可反射访问模块的列表
- 使用的服务列表
- 提供服务的实现列表
可配置的封装机制,首先要解决 JDK 9 之前基于类路径来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖,那只能在运行时才能爆出异常。而如果使用了 JPMS 封装,那么就可以显式指定一来,能够在启动时就能验证依赖关系,很大程度上减少了由于类型依赖而引发的运行时异常。
可配置的封装隔离机制还解决了原来类路径上跨 JAR 的 public 修饰符访问类型。JDK 9 中的 public 修饰符不再意味着所有地方的代码都可以访问,模块提供了更精细的可访问性控制,必须明确声明其中哪一些 public 类型可以被其他模块访问。
兼容性
JDK 9
提出了类路径(ClassPath)和模块路径(ModulePath)的概念。简单的来说,某个库是模块还是传统的
JAR 包,只取决于它存放于哪种路径上。只要是放在类路径上的 JAR
文件,无论其中是否包含模块化信息(module-info.class
文件),都会被当作传统的 JAR 来对待;相应地,只要放在模块化路径上的 JAR
文件,即使没有使用 JMOD
后缀,甚至不包含模块化信息,也会被当作模块来看待。
通过以下规则,可以保证老版本的 JAR 不经修改运行在高版本 JVM 中:
所有类路径下的 JAR 文件和资源文件,都视作自动打包在匿名模块(Unnamed Module)里,几乎没有任何隔离,它可以看到和使用类路径上的所有包、JDK 系统模块中所有的导出包,以及模块路径中导出的包。
模块路径下的具名模块,只能访问到它依赖定义中列明的模块和包,匿名模块里所有内容,对于具名模块都是不可见的。即具名模块看不见传统 JAR 包的内容
如果把一个传统 JAR 包放到模块路径,它就会变成一个自动模块(Automatic Module)。尽管不包含模块化信息,但自动模块将自动依赖整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包
此外,JPMS 的目标仅仅是隔离包。不包含解决多版本冲突问题、替换 OSGi 等目标。Oracle 希望维持一个足够简单化的模块化系统。
模块化下的类加载器
首先,拓展类加载器被移除,平台类加载器取而代之。这是一个很正常的变动,因为
JDK 已经全部基于模块化构建了,其中的 Java
类库自然就满足了可拓展需求。$JAVA_HOME/lib/ext
目录就不再必要了。同时 $JAVA_HOME/lib/jre
也被取消了,因为随时都可以通过组合构建出程序运行所需的 JRE
来,假设我们只使用 java.base
模块中的类型,那么随时可以通过以下命令打包出一个 JRE:
jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre
其次,平台类加载器和应用程序都不再派生自
java.net.URLClassLoader
,如果有程序直接依赖了这种关系,那么在高版本的
JDK
中很有可能会崩溃。现在启动类加载器、平台类加载器、应用程序加载器全部继承于
jdk.internal.loader.BuiltinClassLoader
,并实现了新模块化架构下的加载逻辑。
另外,现在 JDK 的 Java 代码中也有 BootClassLoader
存在,是 JVM 和 Java 类库共同协作实现的。
以上,就是类加载相关的内容了。