Jvm类加载机制
类加载机制
类加载机制是指Java虚拟机(JVM)如何 将.class
文件(实际为二进制字节流)加载到内存,并创建出对应的Class对象的过程。这一过程涉及加载、验证、准备、解析和初始化五个阶段。
Class文件
“Class文件”并非特指某个存在于具体磁盘中的文件,而应当是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、网络、数据库、内存或者动态产生等。
类加载过程
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图所示:
加载:查找并加载类的二进制数据,存放到方法区中。
验证:确保加载的类信息符合JVM规范,没有安全威胁。
准备:为类的静态变量分配内存,并赋予初始值(如int型默认为0)。
解析:将常量池中的符号引用转为直接引用,以便直接定位到类、方法、字段等信息在内存中的地址。
初始化:执行类的静态初始化代码(如静态变量赋值、静态代码块),赋予静态变量最终的值。
加载
在JVM的类加载过程中,加载阶段(Loading)是第一个阶段,其主要任务是将类的二进制字节流读入到内存中,并转换为JVM可以处理的形式。加载阶段由类加载器(ClassLoader)执行,类加载器负责以下主要任务:
获取类的二进制字节流:类加载器需要获取类的二进制字节流,这个字节流可以来自不同的来源,如文件系统中的
.class
文件、网络资源、数据库甚至是动态生成的字节码。加载器通过类的全限定名(例如com.example.MyClass
)来定位并获取相应的字节流。将字节流转换为数据结构:加载器将获取到的字节流转换为JVM可以理解的内部数据结构,这个过程会生成方法区(Method Area)中的类信息结构,以及堆中对应的
java.lang.Class
对象。生成Class对象:在堆中创建一个
java.lang.Class
对象,这个对象将作为方法区中类数据的外部接口,用于封装类的元数据信息和运行时数据。Class
对象是程序与JVM类数据结构交互的主要途径。
注意事项
类加载器的层次结构:类加载过程是由类加载器完成的,而类加载器本身具有层次结构,包括启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用类加载器(Application ClassLoader)。它们之间存在双亲委派机制,这意味着类加载请求会先传递给父加载器,如果父加载器无法加载,子加载器才会尝试加载。
类的唯一性:同一个类加载器加载的同一个类的二进制字节流,会被认为是同一个类。即使二进制字节流完全相同,如果使用不同的类加载器加载,也会被认为是不同的类。这意味着类的相等性不仅仅取决于类的二进制内容,还取决于加载它们的类加载器。
类的可见性:类加载器还负责控制类的可见性,即哪些类对哪些类加载器是可见的。这通过类加载器的层次结构和双亲委派模型来实现,确保了类的正确隔离和安全加载。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。
验证
验证(Verification)阶段是类加载过程中的重要环节,其目的是确保类文件的字节码符合Java虚拟机的规范,不会对JVM造成危害,保证程序的正确性和安全性。验证阶段主要分为四个部分:
文件格式验证(File Format Verification)
检查输入的类文件是否符合Class文件格式的规范,确保字节流能正确地解析并存储到方法区中。文件格式验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了
元数据验证(Metadata Verification)
对字节码描述的信息进行语义分析,确保其描述的信息符合Java语言规范。字节码验证(Bytecode Verification)
确保程序的运行不会危害JVM的安全性,如避免栈溢出、非法的类型转换、空指针引用等问题。符号引用验证(Symbolic Reference Verification)
确保解析动作能够正确执行,即验证类文件中的符号引用(如类、字段、方法)是否可以成功转换为直接引用。
- 魔数是否正确(通常是`0xCAFEBABE`)。
- 主次版本号是否在当前虚拟机的处理范围之内。
- 常量池中的常量类型是否合法。
- 访问标志是否合法。
- 字段表、方法表以及属性表的条目是否有正确的数目。
- 字段和方法的访问标志是否有效。
- 操作码的合法性检查,例如不允许出现`return`后面跟有操作数的情况。
- 类是否有父类(除了`java.lang.Object`)。
- 继承链的正确性,确保不存在循环继承。
- 方法和字段是否有正确的修饰符。
- 方法签名是否正确,如参数类型和返回类型是否匹配。
- 父类和接口的继承一致性,确保实现接口的方法签名与接口声明的一致。
- 构造器和方法的参数类型和返回类型是否正确。
- 操作数栈的数据类型与指令集是否匹配。
- 类中的方法是否有可能抛出异常,异常处理器是否正确。
- 类是否试图违反访问控制。
- 类是否试图执行特权操作。
- 类中的运算是否符合类型安全规则,如尝试将`long`赋值给`int`类型变量。
- 类是否存在。
- 字段或方法是否在类中声明。
- 访问权限是否允许当前类访问目标类、字段或方法。
- 字段或方法的类型是否与引用类型匹配。
这些验证动作确保了加载到JVM中的类文件是安全和有效的,防止了潜在的运行时错误和安全漏洞。验证阶段是JVM类加载过程中的重要组成部分,它在类被加载后立即执行,是类初始化之前的必要步骤。通过这一系列的检查,JVM可以保证加载的类能够安全地在虚拟机中运行。
准备
准备(Preparation)阶段主要负责为类的静态变量分配内存,并设置默认初始值。
内存分配:为类的静态变量分配内存空间。这些变量的内存通常位于方法区(在JVM术语中,方法区是JVM内存区域的一部分,用于存储类信息、常量、静态变量等)。
设置默认值:为静态变量设置默认初始值。例如,对于整型变量,默认值为0;对于浮点型变量,默认值为0.0;对于布尔型变量,默认值为false;对于引用类型,默认值为null。
注意事项
与初始化的区别:在准备阶段设置的初始值仅仅是变量的默认值,而不是程序员在代码中指定的初始化值。初始化阶段会覆盖这些默认值。
变量作用域:准备阶段只针对类的静态变量,非静态变量(实例变量)不在这个阶段分配内存,它们将在创建类实例时分配。
静态变量与实例变量:需要注意区分静态变量和实例变量的初始化时机和方式。静态变量在类加载的准备阶段被初始化,而实例变量则在创建对象时初始化。
类加载顺序:如果类A依赖于类B的静态变量,则在类A的准备阶段之前,类B必须已经被完全加载和准备完毕。这是因为静态变量的引用可能存在于类A的常量池中,JVM需要确保这些引用的有效性。
假设有一个类Example
,如下所示:
public class Example {
public static int staticInt = 10;
public static String staticString = "Hello";
}
在准备阶段,staticInt
和staticString
都将被分配内存,并分别被初始化为0和null。但是,当Example
类被初始化时,staticInt
将被设置为10,staticString
将被设置为"Hello"。
解析
解析(Resolution)的主要任务是将类中的符号引用替换为直接引用。符号引用是编译期间产生的,而直接引用则是运行时可以直接定位到目标的引用,如内存地址。
符号引用与直接引用
1. 符号引用(Symbolic References)
符号引用是编译结果中的一部分,是Class文件里的常量池表里的一种数据类型,它以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能够唯一标识目标即可。
符号引用可以分为三种类型:
- 类和接口的符号引用:如
java/lang/String
,用于描述类或接口的全限定名。 - 字段的符号引用:如
java/lang/String::length
,用于描述类的字段,包括字段的名称和描述符。 - 方法和构造器的符号引用:如
java/lang/String::<init>(Ljava/lang/String;)V
,用于描述类的方法或构造器,包括方法或构造器的名称、参数列表和返回类型。
作用:符号引用在编译时生成,它主要用于描述源代码中的各种引用信息,如类引用、字段引用和方法引用。符号引用是静态的,它在编译时就已经确定,不会随着程序运行时环境的变化而变化。
2. 直接引用(Direct References)
直接引用是指向目标实体的直接指针,如内存地址。它依赖于虚拟机的实现细节,可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
作用:直接引用用于在运行时快速定位到目标实体。与符号引用不同,直接引用是在运行时动态解析出来的,它与程序运行时的环境密切相关。
符号引用在编译时生成,用于描述类、字段和方法的引用信息;而直接引用在运行时解析,用于快速定位和访问实体。
解析阶段的处理内容:
符号引用转直接引用:将类、字段、方法和构造器的符号引用转换为直接引用。符号引用是类文件中的字符串描述,比如类名、方法名和字段名。直接引用则是指向内存中数据的指针、偏移量或间接定位指令。
方法解析:解析静态方法和私有方法,因为这些方法的调用在编译期就可以确定,不需要在运行时查找。
字段解析:解析类的静态字段和实例字段,确保对字段的引用能够直接定位到具体的数据位置。
接口解析:如果类实现了接口,解析阶段会确定接口中声明的所有方法的实际实现。
注意事项
解析时机:解析阶段并不一定在类的初始化阶段之前完成,它可以延迟到第一次使用到某个类、字段、方法或者构造器的时候。这种延迟解析的策略有助于减少类加载的开销。
方法解析的区别:静态方法和私有方法在解析阶段就会被解析,因为它们的调用在编译期间就可以确定。而虚方法(即实例方法)和接口方法的解析则会在运行时动态解析,以支持动态绑定和多态性。
接口方法解析:如果一个类实现了多个接口,且这些接口中有同名方法,那么在解析阶段,JVM会根据实现顺序选择第一个接口中的方法作为解析结果。
异常情况:如果在解析阶段遇到问题,如找不到引用的目标,JVM会抛出
IncompatibleClassChangeError
、NoSuchFieldError
或NoSuchMethodError
等异常。性能考虑:解析阶段的延迟执行可以提高JVM的启动速度,因为它减少了启动时的解析工作量。然而,这也可能导致应用程序在运行时遇到解析失败的异常,增加了调试难度。
解析阶段是类加载过程中的最后一个正式阶段,但并不总是发生,具体取决于类的具体使用情况。解析阶段的灵活执行和延迟策略是JVM设计中的一个重要特性,它平衡了类加载的速度和动态性,同时也支持了Java语言的核心特性,如多态和动态绑定。
初始化
初始化阶段是类加载过程的最后一个步骤,也是类从被动状态转变为主动状态的关键时刻。在这个阶段 JVM 主要执行以下操作:
执行静态初始化器:JVM 执行类中的静态初始化块(如果存在)。静态初始化块是被
static
关键字修饰的代码块,其语法形式为{ ... }
,位于类体内部。这些初始化块在类的第一次使用时执行,且只执行一次。初始化静态变量:类的所有静态变量被赋予其初始值或默认值(如果未显式初始化)。初始化顺序遵循代码中声明的顺序。
初始化阶段就是执行类构造器 <clinit>()
方法的过程, <clinit>()
方法包含了所有静态初始化块和对静态变量赋值语句的字节码。该方法在类的首次使用时调用,且只执行一次。
clinit 方法
生成原则:每当类中有静态变量初始化或静态初始化块时,JVM 都会自动为该类生成一个名为
<clinit>()
的方法。如果没有上述元素,<clinit>()
方法则不会生成。调用时机:
<clinit>()
方法在类的首次使用时由 JVM 调用,例如:- 创建类的实例。
- 调用类的静态方法。
- 访问或修改类的静态字段。
- 反射(
Class.forName("fully_qualified_name").newInstance()
)。
执行顺序:
<clinit>()
方法的执行顺序遵循其在字节码中的顺序,先父类后子类,先超类后子类。这意味着子类的<clinit>()
方法会在父类的<clinit>()
方法之后执行。
静态语句块中只能访问到定义在静态语句块之前的变量,因为编译器收集的顺序是由语句在源文件中出现的顺序决定的,定义在它之后的变量,在前面的静态语句块可以赋值但是不能访问,参照下例:
public class MyClass {
static int var1 = 10; // 声明并初始化
static {
System.out.println("Static block 1");
var1 = 20; // 修改 var1
var2 = 30; // 给 var2 赋值,但不能访问
}
static int var2; // 声明,但未初始化
static {
System.out.println("Static block 2");
System.out.println(var1); // 可以访问 var1
System.out.println(var2); // 可以访问 var2
}
}
var2
在第一个静态初始化块之后被声明,所以这个初始化块可以给 var2
赋值,但是不能访问它(因为 var2
还没有被初始化)。
初始化时机
在 Java 中,类的加载和初始化是由 Java 虚拟机(JVM)自动管理的。类加载和初始化的过程并非在程序启动时一次性完成,而是按需进行的,即在类被首次主动使用时才触发类加载。这种按需加载的机制有助于提高应用程序的启动速度和运行效率。
类的加载和初始化过程是由特定的事件驱动的,这些事件可以分为两大类:被动引用(Passive Use)和主动引用(Active Use)。
初始化时机 / 主动引用(Active Use)
主动引用是指那些会导致类被完全加载和初始化的类引用行为。主动引用会触发类的<clinit>()
方法执行,完成静态成员变量的初始化和静态初始化块的执行。以下是一些常见的主动引用场景:
1. 创建类的实例
当你使用new
关键字创建一个类的实例时,JVM 将会加载并初始化该类。
2. 调用类的静态方法
如果调用了类的静态方法,即使没有创建该类的实例,JVM 也会加载并初始化这个类。例如:MyClass.staticMethod();
3. 访问或修改类的静态字段
当你的代码访问或修改一个类的静态字段时,JVM 会加载并初始化这个类。例如:MyClass.staticField
4. 反射调用
使用反射 API(如Class.newInstance()
或Method.invoke()
等)创建类的实例,调用类的静态方法及其他非静态变量和方法的使用,也会触发类的加载和初始化。
5. 初始化一个类的子类
当你初始化一个类的子类时,JVM 也会加载并初始化其父类。这是因为子类的初始化可能依赖于父类的状态。
6. JIT编译需求
虽然较为少见,但在某些情况下,JIT 编译器的优化可能需要类的初始化。
被动引用和主动引用是 Java 类加载过程中的两种关键行为。主动引用会触发类的完全加载和初始化,而被动引用仅加载类而不执行初始化。
被动引用(Passive Use)
被动引用指的是那些不会触发类的初始化(即执行<clinit>()
方法)的类引用行为。在被动引用中,类被加载到内存中,但其静态成员变量的初始化和静态初始化块不会被执行。以下是几种典型的被动引用场景:
1. 通过子类引用父类的静态字段:
当你通过子类去引用父类的静态字段时,父类会被加载和初始化,但子类只会被加载,不会初始化。这是因为引用父类的静态字段不需要子类的初始化。
2. 反射中不创建实例或访问静态成员:
- 使用反射 API(如
Class.forName()
)加载类,获取一些基本的元数据及属性时,如果不进一步创建类的实例或访问其静态成员,类会被加载但不会初始化。
3. 访问类的静态常量(final static fields):
如果一个静态字段被声明为final
且在编译时就能确定其值,访问这样的静态常量不会触发类的初始化。这是因为编译器会将对常量的引用替换为实际的值。
4. 数组声明:
声明一个类的数组类型,如ClassName[] array
,会加载类但不会初始化。然而,如果通过数组的length
属性或clone()
方法访问数组,这将触发类的初始化。
类的加载和初始化是 Java 应用程序运行时的重要机制,它确保了类的资源在首次使用时按需加载,既节省了内存又提高了程序的启动速度。理解类加载和初始化的触发时机对于编写高效、可靠的 Java 程序至关重要。
类加载器
在 Java 中,类加载器(Class Loader)是负责加载 Java 类到 Java 虚拟机(JVM)中的组件。每个类在 JVM 中都是通过类加载器加载的。类加载器在 Java 中起到隔离作用,可以确保加载的类是正确的,并且避免类的重复加载。
自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构
- 启动类加载器(Bootstrap Class Loader):加载Java核心库。
- 扩展类加载器(Extension Class Loader):加载扩展目录中的类库。
- 应用程序类加载器(Application Class Loader):加载用户类路径下的类。
- 自定义类加载器:用户自定义,加载特定位置的类。
Tips
站在Java虚拟机的角度来看,只存在两种不同的类加载器:
- 一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;
- 另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader
。
引导类加载器(Bootstrap Class Loader):是最顶层的类加载器,负责加载 Java 核心类库(存放在
<JAVA_HOME>\lib
目录,或者被-Xbootclasspath
参数所指定的路径中存放的类,如rt.jar
),它没有父类加载器。( 按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载 )扩展类加载器(Extension Class Loader):负责加载
/lib/ext
目录下的类库,或者是由java.ext.dirs
系统变量指定的目录下的类库。应用程序类加载器(Application Class Loader):也称为系统类加载器,负责加载用户应用程序类路径(classpath)上的类。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
在某些场景下,可能需要自定义类加载器以适应特定需求。Java 提供了java.lang.ClassLoader
类作为自定义类加载器的基础,可以通过重写findClass(String name)
方法来实现自定义的类加载逻辑。
双亲委派模型
双亲委派模型(Parent Delegation Model)是 Java 类加载器体系中的一种设计模式,用于解决类的唯一性问题,并增强系统的安全性。其核心思想是:当一个类加载器收到加载类的请求时,首先不会尝试自己加载这个类,而是把类加载请求委托给父类加载器,依次向上递归,直到顶层的引导类加载器。如果父类加载器无法加载这个类,子类加载器才会尝试加载。
Tips
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码
双亲委派模型的优点包括:
- 类的唯一性:通过双亲委派模型,可以确保加载的类在 Java 虚拟机中只有一个实例,避免了类的重复加载,确保了类的一致性。
例如:Object类,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。 - 安全性:双亲委派模型增强了 Java 系统的安全性,因为系统类库由顶层的引导类加载器加载,避免了用户自定义的类加载器替换或篡改核心类库的类。
- 稳定性:双亲委派模型有助于保持 Java 平台的稳定,因为系统的核心类库不会受到外部类加载器的影响。
双亲委派模型的工作流程
加载请求:当 Java 应用程序需要加载一个类时,加载请求通常会从应用程序类加载器开始。
递归委派:应用程序类加载器会尝试将加载请求委派给父类加载器,即扩展类加载器。扩展类加载器同样会将请求委派给它的父类加载器,即引导类加载器。
加载:如果引导类加载器无法加载该类,加载请求会沿原路返回,由扩展类加载器尝试加载。如果扩展类加载器也无法加载,请求将传递给应用程序类加载器。
最后尝试:如果所有父类加载器都无法加载该类,那么请求将回到最初的类加载器,由它尝试加载该类。
双亲委派模型的源码实现:
双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委派的代码只有短短十余行,全部集中在java.lang.ClassLoader
的 loadClass()
方法之中
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;
}
破坏双亲委派
破坏双亲委派模型(Parent Delegation Model)在 Java 中通常指的是对标准类加载机制的改变,这种改变可能出于多种原因,比如实现热部署、解决特定的类加载冲突、或是满足一些特殊的运行时需求。下面是几种常见的破坏双亲委派模型的方式及其背后的原因。
1. 重写 loadClass
方法
在 Java 的 ClassLoader
类中,loadClass
方法是实现双亲委派的核心。如果想要破坏这个模型,最常见的做法就是重写 ClassLoader
的 loadClass
方法。在默认实现中,loadClass
方法会尝试先让父类加载器加载类,只有在父类加载器无法加载时,子类加载器才会尝试加载。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name);
} else {
c = findBootstrapClass(name);
}
} catch (ClassNotFoundException e) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
重写 loadClass
方法可以改变类加载的顺序,例如,可以优先尝试加载类,然后再委托给父类加载器,或者完全绕过父类加载器。
2. 使用线程上下文类加载器
线程上下文类加载器(Thread Context ClassLoader)是 Java 中一个重要的概念,它允许在特定线程中更改当前类加载器。这在 JNDI、JDBC 等服务中特别常见,这些服务需要调用由不同类加载器加载的 SPI(Service Provider Interface)实现。
线程上下文类加载器可以通过 Thread.currentThread().getContextClassLoader()
获取,并通过 Thread.currentThread().setContextClassLoader(ClassLoader cl)
设置。在多线程环境中,这允许临时改变类加载器,以便访问特定的类或资源。
3. OSGi 和模块化环境
在模块化环境中,如 OSGi(Open Service Gateway Initiative),每个模块都有自己的类加载器,这导致类加载器不再是简单的树形结构,而是变成了一个网状结构。OSGi 实现了热部署和热替换功能,允许在不重启整个应用程序的情况下更新和替换模块,这显然打破了传统的双亲委派模型。
4. 热部署和热替换
热部署(Hot Deployment)和热替换(Hot Swapping)是指在不重启应用的情况下更新代码。在某些应用服务器和开发环境中,为了支持代码的实时更新,自定义的类加载器被设计成能够识别类的更新并重新加载它们。这通常涉及对类加载器的定制以及对双亲委派模型的调整。
破坏双亲委派模型可能带来一些潜在的问题,如类的多重定义、类加载器泄漏、资源泄露以及类的版本冲突等。因此,这种操作需要谨慎执行,并且要确保充分理解其后果。在大多数情况下,双亲委派模型提供了必要的隔离和安全保证,破坏这一模型应被视为最后的手段。
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况
双亲委派模型的三次被破坏详情
双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的“远古”时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的.
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?
- 这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?
- 为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
- 有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。说白了就是希望Java应用程序能像我们的电脑外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用关机也不用重启。对于个人电脑来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是大型系统或企业级软件开发者具有很大的吸引力。
- 早在2008年,在Java社区关于模块化规范的第一场战役里,由Sun/Oracle公司所提出的JSR-294、JSR-277规范提案就曾败给以IBM公司主导的JSR-291(即OSGi R4.2)提案。尽管Sun/Oracle并不甘心就此失去Java模块化的主导权,随即又再拿出Jigsaw项目迎战,但此时OSGi已经站稳脚跟,成为业界“事实上”的Java模块化标准。曾经在很长一段时间内,IBM凭借着OSGi广泛应用基础让Jigsaw吃尽苦头,其影响一直持续到Jigsaw随JDK 9面世才算告一段落。而且即使Jigsaw现在已经是Java的标准功能了,它仍需小心翼翼地避开OSGi运行期动态热部署上的优势,仅局限于静态地解决模块间封装隔离和访问控制的问题,这部分内容笔者在7.5节中会继续讲解,现在我们先来简单看一看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的实现,就算是掌握了类加载器的精粹。
自定义类加载器
在 Java 中,自定义类加载器通常是通过继承 java.lang.ClassLoader
类并重写其 findClass
方法来实现的。
下面是一个简单的自定义类加载器示例(它从文件系统中的指定目录加载类,而不是从标准的类路径):
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public class MyClassLoader extends ClassLoader {
private String path;
public MyClassLoader(String path) {
this.path = path;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 将包名转换为文件路径
String fileName = name.replaceAll("\\.", "/") + ".class";
File file = new File(path + File.separator + fileName);
if (!file.exists()) {
throw new ClassNotFoundException("Class not found: " + name);
}
byte[] bytes = new byte[(int) file.length()];
try (FileInputStream fis = new FileInputStream(file)) {
fis.read(bytes);
}
// 加载并定义类
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("Error loading class: " + name, e);
}
}
}
接下来,可以使用上面的自定义类加载器:
public class CustomClassLoaderDemo {
public static void main(String[] args) {
try {
// 指定包含类文件的目录
String classPath = "path/to/your/classes";
// 创建自定义类加载器实例
MyClassLoader customLoader = new MyClassLoader(classPath);
// 加载类
Class<?> myClass = customLoader.loadClass("com.example.MyClass");
// 创建类的实例
Object instance = myClass.getDeclaredConstructor().newInstance();
// 调用类的方法
myClass.getMethod("myMethod").invoke(instance);
} catch (Exception e) {
e.printStackTrace();
}
}
}
类加载器之间的“父类”和“子类”关系
在类加载器体系中,“父类”类加载器和“子类”类加载器之间的关系是通过引用和委托关系建立的,而不是通过继承关系。例如,应用程序类加载器(系统类加载器)有一个对扩展类加载器的引用,扩展类加载器又有一个对引导类加载器的引用。这种引用关系形成了类加载器层次结构,但是类加载器本身并没有继承自其他类加载器。
自定义类加载器的 parent
:
自定义类加载器的 parent
可以通过构造器来指定,默认情况下将是 System Class Loader
。
在创建自定义类加载器实例时,可以通过以下两种方式之一来指定 parent
:
使用默认构造器:当你使用默认构造器创建自定义类加载器时,
parent
将被设置为调用该构造器的类加载器。这通常意味着你的自定义类加载器的parent
会是Application Class Loader
(应用程序类加载器)。public class MyCustomClassLoader extends ClassLoader { public MyCustomClassLoader() { super(); // 使用默认构造器,parent 将是 System Class Loader } }
使用带参数的构造器:如果你希望自定义类加载器的
parent
是某个特定的类加载器,你可以使用带ClassLoader
参数的构造器,并传入相应的类加载器作为parent
。public class MyCustomClassLoader extends ClassLoader { public MyCustomClassLoader(ClassLoader parent) { super(parent); // 这里可以传入任何有效的类加载器作为 parent } }
例如,如果你想让你的自定义类加载器的
parent
是Extension Class Loader
,你可以这样:ClassLoader extensionLoader = URLClassLoader.getSystemClassLoader().getParent(); MyCustomClassLoader customLoader = new MyCustomClassLoader(extensionLoader);
通过正确设置 parent
,自定义类加载器能够遵循双亲委派模型,从而确保类加载的一致性和安全性。如果需要打破双亲委派模型,需要重写 loadClass
方法而不是仅仅重写 findClass
方法,并在方法中自己实现加载逻辑。但是,这样做需要非常小心,因为打破了双亲委派模型可能会引入类加载的不一致性和潜在的安全问题。
模块化系统
在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。JDK 9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:
- 依赖其他模块的列表。
- 导出的包列表,即其他模块可以使用的列表。
- 开放的包列表,即其他模块可反射访问模块的列表。
- 使用的服务列表。
- 提供服务的实现列表。
可配置的封装隔离机制首先要解决JDK 9之前基于类路径(ClassPath)来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接时才会报出运行的异常。而在JDK 9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。
可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型的可访问性问题。JDK 9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类加载过程中完成的。
在 Java 9 及更高版本中,模块化系统引入了新的类加载器:
- Platform Module Loader(平台模块加载器):负责加载 Java 平台模块系统(JRT)中的模块。
- Application Module Loader(应用程序模块加载器):负责加载应用程序模块路径中的模块。