虚拟机执行子系统(二):虚拟机类加载机制
定义:Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
Java与C/C++不同之处在于:Java的类加载、连接、初始化都是在运行期间完成的。这个特性使得Java接口的实现类可以延后确定,可以从网络或者其他地方获取二进制流作为其代码的的一部分。
类加载的时机
类的生命周期包含7个阶段:
加载、验证、准备、解析、初始化、使用、卸载
前五个阶段中,除了解析阶段外,起始的顺序(进行和完成的阶段并不一定)都是确定的,解析阶段可以放到初始化阶段之后再开始(动态绑定)。
主动引用
《Java虚拟机规范》规定以下6种情况(主动引用)必须立即对类进行初始化:
-
遇到
new
、getstatic
、putstatic
或invokestatic
这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。对应Java代码的场景:
new
一个对象- 读取或者设置一个类的
static
字段(被final
修饰、或者在编译期间就把结果放入常量池的静态字段(设置了静态变量的初始值private static int num = 0;
)除外) - 调用类的静态方法
-
使用
java.lang.reflect
包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。 -
当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
-
当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
-
当使用JDK 7新加入的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。 -
当一个接口中定义了JDK 8新加入的默认方法(被
default
关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
被动引用
下列这些引用方式都不会触发对应类的初始化
-
只有直接定义这个字段的类才会被初始化,通过子类引用父类的静态字段,不会导致子类初始化。
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); } } /*************************** Output SuperClass init! ***************************/
-
通过数组定义来引用类,不会触发此类的初始化
package org.fenixsoft.classloading; /** * 被动使用类字段演示二: * 通过数组定义来引用类,不会触发此类的初始化 **/ public class NotInitialization { public static void main(String[] args) { SuperClass[] sca = new SuperClass[10]; } }
这段代码不会输出初始化块的内容,他触发了
[Lorg.fenixsoft.classloading.SuperClass
类的初始化阶段,这是JVM自动生成的直接继承于java.lang.Object
的子类,对于这个自动生成的类只能访问他的length
属性和clone()
方法 -
常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
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!
,因为在编译阶段通过常量传播优化,已经将此常量的值“hello world”
直接存储在NotInitialization
类的常量池中。
接口加载
接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
类加载过程
加载
JVM完成三件事
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在堆内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
用户可以使用自定义的类加载器完成(非数组类,或者说加载阶段中获取类的二进制字节流的动作)类的加载(重写类加载器的findClass()
方法或loadClass()
方法)
数组类:
- 如果数组的组件类型(数组去掉一个维度后的类型,比如二维变为一维)是引用类型,那么递归(分为JVM加载数组或类加载器加载类、非引用类型两种)采用加载过程去加载这个组件类型,这个递归的过程中,数组类将被标识在加载该组件类型的类加载器的类名称空间上
- 如果数组的组件类型不是引用类型(例如
int[]
数组的组件类型为int
),Java虚拟机将会把数组类标记为与引导类加载器关联。 - 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为
public
,可被所有的类和接口访问到。
加载阶段还没结束时,连接阶段可能已经开始了,只保证加载阶段先于连接阶段开始
验证
目的:确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。
个人认为是一个编译检查的过程,只有可以通过编译规则,才能被JVM执行,大体上验证阶段会分为四个阶段:
-
文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
比如:
- 是否以魔数
0xCAFEBABE
开头。 - 主、次版本号是否在当前Java虚拟机接受范围之内。
- 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
- 是否以魔数
-
元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求
- 这个类是否有父类(除了
java.lang.Object
之外,所有的类都应当有父类)。 - 这个类的父类是否继承了不允许被继承的类(被
final
修饰的类)。 - 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的
final
字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
- 这个类是否有父类(除了
-
字节码验证
通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个
int
类型的数据,使用时却按long
类型来加载入本地变量表中”这样的情况。 - 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个
-
符号引用验证
这个验证发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(
private
、protected
、public
、<package>
)是否可被当前类访问。
验证阶段并不是必要阶段,如果不需要验证,就可以考虑使用-Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
该阶段为类变量(static)分配内存(方法区),并设置其初始值(零值而非自定义的值,自定义的值要在初始化阶段才会执行)。如果字段属性表中存在着ConstantValue
属性(基本类型或者String
类型的被final修饰的类变量),那么在准备阶段就会被初始化为指定的初始值
解析
-
概念:JVM将常量池内的符号引用替换为直接引用的过程。
-
发生的时刻:《Java虚拟机规范》只规定了在特定的字节码指令执行之前需要先对他们所使用的符号引用进行解析,因此解析过程可以发生在加载后立刻执行,也可以等到符号引用将被使用之前再解析。
-
可访问性检查:对方法或者字段的访问,在这个阶段检查是否可访问
-
多次解析:
- 非
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种常量类型。字段引用和方法引用,只要在使用(用到了引用或者赋初值之类的)到了当前类使用了自己的或其他类的字段引用(比如
A.num
)或者方法(类A使用了A.method()
或者类A使用了类B的B.method()
)的地方,或者当前类的字段有赋初值的行为的地方,就会在常量池产生字段引用、方法引用。-
类或接口的解析(符号引用指向的是类或者接口)
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
-
C不是数组类型:虚拟机将会把N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
-
C是数组类型:如果数组的元素类型为对象,也就是N的描述符会是类似“
[Ljava/lang/Integer
”的形式,那将会按照第一点的规则加载数组元素类型。(如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer
”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。) -
如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:
- 被访问类C是
public
的,并且与访问类D处于同一个模块。 - 被访问类C是
public
的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的模块进行访问。 - 被访问类C不是
public
的,但是它与访问类D处于同一个包中。
- 被访问类C是
下面两点有个前提是:字段表/方法表集合中不会列出从超类或者父接口中继承而来的字段/方法,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
-
-
字段解析(符号引用指向的是某个字段)
字段的符号引用(CONSTANT_Fieldref_info)包含:字段所属类、字段描述符,字段描述符在常量池中直接存储有其字面量。
解析字段符号引用,首先将会对字段表中的
class_index
项索引的CONSTANT_Class_info
符号引用进行解析(解析该字段所属的类或接口的符号引用),在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。把这个字段所属的类或接口用C(内存中已经解析好了)表示,《Java虚拟机规范》还要求进行后续字段搜索:
-
如果C本身就包含了**简单名称(字段名称)和字段描述符(字段的数据类型)**都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
否则,如果C不是
java.lang.Object
的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。 -
否则,查找失败,抛出
java.lang.NoSuchFieldError
异常。
个人认为在这里的意思是:检查该字段所属的类C,是否确实有该字段。换句话说,确定这个字段到底属于哪个类,查找顺序跟继承有关,如果找到,则将对应的符号引用替换为内存地址
package test; import java.util.*; class NC{ public static int a; } class PNC extends NC{ } public class Test{ public static void main(String[] args) { PNC.a = 3; } } /*********************************************** Constant pool: #1 = Methodref #4.#18 // java/lang/Object."<init>":()V #2 = Fieldref #19.#20 // test/PNC.a:I #3 = Class #21 // test/Test #4 = Class #22 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 LocalVariableTable #10 = Utf8 this #11 = Utf8 Ltest/Test; #12 = Utf8 main #13 = Utf8 ([Ljava/lang/String;)V #14 = Utf8 args #15 = Utf8 [Ljava/lang/String; #16 = Utf8 SourceFile #17 = Utf8 Test.java #18 = NameAndType #5:#6 // "<init>":()V #19 = Class #23 // test/PNC #20 = NameAndType #24:#25 // a:I #21 = Utf8 test/Test #22 = Utf8 java/lang/Object #23 = Utf8 test/PNC #24 = Utf8 a #25 = Utf8 I ****************************************************/
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出
java.lang.IllegalAccessError
异常。实际中,Javac编译器会比上述规则更加严谨,比如他不允许类的父类和接口存在相同的字段(字段名称相同)。
-
-
方法解析
方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的
class_index
项中索引的方法所属的类或接口的符号引用。用C表示这个类(在内存中已经解析好的类C),接下来虚拟机将会按照如下步骤进行后续的方法搜索:
- 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现
class_index
中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError
异常。 - 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出
java.lang.AbstractMethodError
异常。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
。
个人认为在这里的意思是:检查该方法所属的类C,是否确实有该方法。换句话说,确定这个方法到底属于哪个类。因为在类D中执行类C(这里类C可以就是类D)的方法,类D的class文件确实记录这个方法属于类C,但是类C自身的方法表可能并没有这个方法,这个方法可能来自C的父类B
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出
java.lang.IllegalAccessError
异常。 - 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现
-
接口方法解析
需要先解析出接口方法表的
class_index
项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:- 与类的方法解析相反,如果在接口方法表中发现
class_index
中的索引C是个类而不是接口,那
么就直接抛出java.lang.IncompatibleClassChangeError
异常。 - 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方
法的直接引用,查找结束。 - 否则,在接口C的父接口中递归查找,直到
java.lang.Object
类(接口方法的查找范围也会包括
Object
类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方
法的直接引用,查找结束。 - 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并
没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编
译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
- 与类的方法解析相反,如果在接口方法表中发现
-
初始化
在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()
方法的过程。<clinit>()
并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。
<clinit>()
方法:<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}
块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问- 类:
<clinit>()
方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()
方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()
方法的类型肯定是java.lang.Object
。(加载子类之前一定先加载父类)父类中定义的静态语句块要优先于子类的变量赋值操作 - 接口:没有静态语句块,但可以初始化变量。接口与类不同的是,执行接口的
<clinit>()
方法**不需要先执行父接口的<clinit>()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。**此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。 - 多线程初始化类:Java虚拟机必须保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()
方法。这个方法只执行一次,一个线程执行完了,其他线程不再执行。 - 不是必须的:
<clinit>()
方法对于类或接口来说并不是必需的:如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。
类加载器
类加载器并不是JVM内部的一个部分。
类与类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里的“相等”指的是Class
对象的equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回结果以及instanceof
关键字的判定结果
/**
* 类加载器与instanceof关键字演示
*
* @author zzm
*/
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("org.fenixsoft.classloading.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
}
}
/********************************
输出
class org.fenixsoft.classloading.ClassLoaderTest
false
********************************/
双亲委派模型
在JVM角度来看,只有两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader):使用C++语言实现,是虚拟机自身的一部分;
- 其他所有的类加载器:都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader
。
在Java开发人员角度来看,分为三层类加载器:
-
启动类加载器(Bootstrap ClassLoader):这个类加载器负责加载存放在
<JAVA_HOME>\lib
目录,或者被-Xbootclasspath
参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。这个加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null
代替即可。(这是约定好的)/**ClassLoader.getClassLoader()方法的代码片段 Returns the class loader for the class. Some implementations may use null to represent 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; }
-
扩展类加载器(Extension ClassLoader):在类
sun.misc.Launcher$ExtClassLoader
中以Java代码的形式实现。负责加载<JAVA_HOME>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中所有的类库。用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
-
应用程序类加载器(Application ClassLoader):由
sun.misc.Launcher$AppClassLoader
来实现。负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器(通过组合关系复用父加载器的代码)。
工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
好处:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。比如Object
类始终加载的是rt.jar中的类。
实现很简单,全部集中于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;
}
先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()
方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException
异常的话,才调用自己的findClass()
方法尝试进行加载。
破坏双亲委派模型
-
基础类型需要调用用户代码:JNDI服务,由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的)。存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的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的加载提供了一种相对合理的解决方案。 -
代码热替换(Hot Swap)、模块热部署(Hot Deployment):OSGi(开放服务网关协议,Open Service Gateway Initiative)技术是Java动态化模块化系统的一系列规范。
OSGi实现模块化热部署:来源于它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。
当收到类加载请求时,OSGi将按照下面的顺序进行类搜索(不再使用双亲委派,使用网状结构):
- 将以
java.*
开头的类,委派给父类加载器加载。 - 否则,将委派列表名单内的类,委派给父类加载器加载。
- 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
- 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
- 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
- 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
- 否则,类查找失败。
- 将以
Java模块化系统
JDK 9中引入了Java模块化系统(Java Platform Module System,JPMS),JDK 9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:
- 依赖其他模块的列表。
- 导出的包列表,即其他模块可以使用的列表。
- 开放的包列表,即其他模块可反射访问模块的列表。
- 使用的服务列表。
- 提供服务的实现列表。