深入理解 JVM 类加载机制
简述:虚拟机把描述类的数据从 class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
下面我们具体来看类加载的过程:
类的生命周期
类从被加载到内存中开始,到卸载出内存,经历了加载、连接、初始化、使用四个阶段,其中连接又包含了验证、准备、解析三个步骤。这些步骤总体上是按照图中顺序进行的,但是 Java 语言本身支持运行时绑定,所以解析阶段也可以是在初始化之后进行的。以上顺序都只是说开始的顺序,实际过程中是交叉进行的,加载过程中可能就已经开始验证了。
类加载的时机
首先要知道什么时候类需要被加载,Java 虚拟机规范并没有约束这一点,但是却规定了类必须进行初始化的 5 种情况,很显然加载、验证、准备得在初始化之前,下面具体来说说这 5 种情况:
类加载时机
其中情况 1 中的 4 条字节码指令在 Java 里最常见的场景是:
1 . new 一个对象时
2 . set 或者 get 一个类的静态字段(除去那种被 final 修饰放入常量池的静态字段)
3 . 调用一个类的静态方法
类加载的过程
下面我们一步一步分析类加载的每个过程
1. 加载
加载是整个类加载过程的第一步,如果需要创建类或者接口,就需要现在 Java 虚拟机方法区创建于虚拟机实现规定相匹配的内部表示。一般来说类的创建是由另一个类或者接口触发的,它通过自己的运行时常量池引用到了需要创建的类,也可能是由于调用了 Java 核心类库中的某些方法,譬如反射等。
一般来说加载分为以下几步:
- 通过一个类的全限定名获取此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
创建名字为 C 的类,如果 C 不是数组类型,那么它就可以通过类加载器加载 C 的二进制表示(即 Class 文件)。如果是数组,则是通过 Java 虚拟机创建,虚拟机递归地采用上面提到的加载过程不断加载数组的组件。
Java 虚拟机支持两种类加载器:
- 引导类加载器(Bootstrap ClassLoader)
- 用户自定义类加载器(User-Defined Class Loader)
用户自定义的类加载器应该是抽象类 ClassLoader 的某个子类的实例。应用程序使用用户自定义的类加载器是为了扩展 Java 虚拟机的功能,支持动态加载并创建类。比如,在加载的第一个步骤中,获取二进制字节流,通过自定义类加载器,我们可以从网络下载、动态产生或者从一个加密文件中提取类的信息。
关于类加载器,会新开一篇文章描述。
2. 验证
验证作为链接的第一步,用于确保类或接口的二进制表示结构上是正确的,从而确保字节流包含的信息对虚拟机来说是安全的。Java 虚拟机规范中关于验证阶段的规则也是在不断增加的,但大体上会完成下面 4 个验证动作。
验证
1 . 文件格式验证:主要验证字节流是否符合 Class 文件格式规范,并且能被当前版本的虚拟机处理。
主要验证点:
- 是否以魔数
0xCAFEBABE
开头 - 主次版本号是否在当前虚拟机处理范围之内
- 常量池的常量是否有不被支持的类型 (检查常量 tag 标志)
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
- Class 文件中各个部分及文件本身是否有被删除的或者附加的其他信息
…
实际上验证的不仅仅是这些,关于 Class 文件格式可以参考我的深入理解 JVM 类文件格式,这阶段的验证是基于二进制字节流的,只有通过文件格式验证后,字节流才会进入内存的方法区中进行存储。
2 . 元数据验证:主要对字节码描述的信息进行语义分析,以保证其提供的信息符合 Java 语言规范的要求。
主要验证点:
- 该类是否有父类(只有 Object 对象没有父类,其余都有)
- 该类是否继承了不允许被继承的类(被 final 修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,出现不符合规则的方法重载,例如方法参数都一致,但是返回值类型却不同)
…
3 . 字节码验证:主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
主要有:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似的情况:操作数栈里的一个 int 数据,但是使用时却当做 long 类型加载到本地变量中
- 保证跳转不会跳到方法体以外的字节码指令上
- 保证方法体内的类型转换是合法的。例如子类赋值给父类是合法的,但是父类赋值给子类或者其它毫无继承关系的类型,则是不合法的。
- 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段解析阶段发生。符号引用是对类自身以外(常量池中的各种符号引用)的信息进行匹配校验。
通常有:
- 符号引用中通过字符串描述的全限定名是否找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、方法、字段的访问性(private,public,protected、default)是否可被当前类访问
符号引用验证的目的是确保解析动作能够正常执行,如果无法通过符号引用验证,那么将会抛出一个 java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。
验证阶段非常重要,但不一定必要,如果所有代码极影被反复使用和验证过,那么可以通过虚拟机参数 -Xverify: none
来关闭验证,加速类加载时间。
3. 准备
准备阶段的任务是为类或者接口的静态字段分配空间,并且默认初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令,在初始化阶段才会显示的初始化这些字段,所以准备阶段不会做这些事情。假设有:
public static int value = 123;
value 在准备阶段的初始值为 0 而不是 123,只有到了初始化阶段,value 才会为 0。
下面看一下 Java 中所有基础类型的零值:
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
一种特殊情况是,如果字段属性表中包含 ConstantValue 属性,那么准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值,比如上面的 value 如果这样定义:
public static final int value = 123;
编译时,value 一开始就指向 ConstantValue,所以准备期间 value 的值就已经是 123 了。
4. 解析
解析阶段是把常量池内的符号引用替换成直接引用的过程,符号引用就是 Class 文件中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量。下面我们看符号引用和直接引用的定义。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以唯一定位到目标即可。符号引用于内存布局无关,所以所引用的对象不一定需要已经加载到内存中。各种虚拟机实现的内存布局可以不同,但是接受的符号引用必须是一致的,因为符号引用的字面量形式已经明确定义在 Class 文件格式中。
直接引用(Direct References):直接引用时直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机上翻译出来的直接引用一般不会相同。如果有了直接引用,那么它一定已经存在于内存中了。
以下 Java 虚拟机指令会将符号引用指向运行时常量池,执行任意一条指令都需要对它的符号引用进行解析:
引起解析的命令
对同一个符号进行多次解析请求是很常见的,除了 invokedynamic 指令以外,虚拟机基本都会对第一次解析的结果进行缓存,后面再遇到时,直接引用,从而避免解析动作重复。
对于 invokedynamic 指令,上面规则不成立。当遇到前面已经由 invokedynamic 指令触发过解析的符号引用时,并不意味着这个解析结果对于其他 invokedynamic 指令同样生效。这是由 invokedynamic 指令的语义决定的,它本来就是用于动态语言支持的,也就是必须等到程序实际运行这条指令的时候,解析动作才会执行。其它的命令都是“静态”的,可以再刚刚完成记载阶段,还没有开始执行代码时就解析。
下面来看几种基本的解析:
类与接口的解析:假设 Java 虚拟机在类 D 的方法体中引用了类 N 或者接口 C,那么会执行下面步骤:
- 如果 C 不是数组类型,D 的定义类加载器被用来创建类 N 或者接口 C。加载过程中出现任何异常,可以被认为是类和接口解析失败。
- 如果 C 是数组类型,并且它的元素类型是引用类型。那么表示元素类型的类或接口的符号引用会通过递归调用来解析。
- 检查 C 的访问权限,如果 D 对 C 没有访问权限,则会抛出
java.lang.IllegalAccessError
异常。
字段解析:
要解析一个未被解析过的字段符号引用,首先会对字段表内 class_index 项中索引的 CONSTANT_Class_info
符号引用进行解析,这边记不清的可以继续回顾深入理解 JVM 类文件格式, 也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段解析失败。如果解析完成,那将这个字段所属的类或者接口用 C 表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。
1 . 如果 C 本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。
2 . 否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3 . 再不然,如果 C 不是 java.lang.Object
的话,将会按照继承关系从下往上递归搜索其父类,如果在类中包含
了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4 . 如果都没有,查找失败退出,抛出 java.lang.NoSuchFieldError
异常。如果返回了引用,还需要检查访问权限,如果没有访问权限,则会抛出 java.lang.IllegalAccessError
异常。
在实际的实现中,要求可能更严格,如果同一字段名在 C 的父类和接口中同时出现,编译器可能拒绝编译。
类方法解析
类方法解析也是先对类方法表中的 class_index 项中索引的方法所属的类或接口的符号引用进行解析。我们依然用 C 来代表解析出来的类,接下来虚拟机将按照下面步骤对 C 进行后续的类方法搜索。
1 . 首先检查方法引用的 C 是否为类或接口,如果是接口,那么方法引用就会抛出 IncompatibleClassChangeError
异常
2 . 方法引用过程中会检查 C 和它的父类中是否包含此方法,如果 C 中确实有一个方法与方法引用的指定名称相同,并且声明是签名多态方法(Signature Polymorphic Method), 那么方法的查找过程就被认为是成功的,所有方法描述符所提到的类也需要解析。对于 C 来说,没有必要使用方法引用指定的描述符来声明方法。
3 . 否则,如果 C 声明的方法与方法引用拥有同样的名称与描述符,那么方法查找也是成功。
4 . 如果 C 有父类的话,那么按照第 2 步的方法递归查找 C 的直接父类。
5 . 否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在相匹配的方法,说明类 C 时一个抽象类,查找结束,并且抛出java.lang.AbstractMethodError
异常。
- 否则,宣告方法失败,并且抛出
java.lang.NoSuchMethodError
。
最后的最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,那么会抛出java.lang.IllegalAccessError
异常。
接口方法解析
接口方法也需要解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。
1 . 与类方法解析不同,如果在接口方法表中发现 class_index 对应的索引 C 是类而不是接口,直接抛出 java.lang.IncompatibleClassChangeError
异常。
2 . 否则,在接口 C 中查找是否有简单名称和描述符都与目标匹配的方法,如果有则直接返回这个方法的直接引用,查找结束。
3 . 否则,在接口 C 的父接口中递归查找,直到 java.lang.Object
类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4 . 否则,宣告方法失败,抛出 java.lang.NoSuchMethodError
异常。
由于接口的方法默认都是 public 的,所以不存在访问权限问题,也就基本不会抛出 java.lang.IllegalAccessError
异常。
5. 初始化
初始化是类加载的最后一步,在前面的阶段里,除了加载阶段可以通过用户自定义的类加载器加载,其余部分基本都是由虚拟机主导的。但是到了初始化阶段,才开始真正执行用户编写的 java 代码了。
在准备阶段,变量都被赋予了初始值,但是到了初始化阶段,所有变量还要按照用户编写的代码重新初始化。换一个角度,初始化阶段是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块 (static 语句块) 中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
public class Test {
static {
i=0; // 可以赋值
System.out.print(i); // 编译器会提示“非法向前引用”}
static int i=1;
}
<clinit>()
方法与类的构造函数 <init>()
方法不同,它不需要显示地调用父类构造器,虚拟机会宝成在子类的 <clinit>()
方法执行之前,父类的 <clinit>()
已经执行完毕,因此在虚拟机中第一个被执行的 <clinit>()
一定是 java.lang.Object
的。
也是由于 <clinit>()
执行的顺序,所以父类中的静态语句块优于子类的变量赋值操作,所以下面的代码段,B 的值会是 2。
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);
}
<clinit>()
方法对于类来说不是必须的,如果一个类中既没有静态语句块也没有静态变量赋值动作,那么编译器都不会为类生成 <clinit>()
方法。
接口中不能使用静态语句块,但是允许有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()
方法,但是接口中的 <clinit>()
不需要先执行父类的,只有当父类中定义的变量使用时,父接口才会初始化。除此之外,接口的实现类在初始化时也不会执行接口的 <clinit>()
方法。
虚拟机会保证一个类的 <clinit>()
方法在多线程环境中能被正确的枷锁、同步。如果多个线程初始化一个类,那么只有一个线程会去执行 <clinit>()
方法,其它线程都需要等待。
6.Java 虚拟机退出
Java 虚拟机退出的一般条件是:某些线程调用 Runtime 类或 System 类的 exit 方法,或者时 Runtime 类的 halt 方法,并且 Java 安全管理器也允许这些 exit 或者 halt 操作。
除此之外,在 JNI(Java Native Interface)规范中还描述了当使用 JNI API 来加载和卸载(Load & Unload)Java 虚拟机时,Java 虚拟机退出过程。
JVM 系列之类加载流程 - 自定义类加载器
JVM 系列之类加载流程 - 自定义类加载器
老实说,类加载流程作者还是比较熟悉而且有实战经验的,因为有过一次自定义类加载器的实战经验(文章最后会和大家分享),虽然大部分小伙伴觉得这部分对 coding 没什么实际意义,如果你一直写 CRUD 并且用现有的高级语言业务框架,我可以告诉你,确实没什么用。但话说回来,你如果想多了解底层,并且在类加载时做一些手脚,那么这一块就很有必要学了。很多框架都是利用了类加载机制里的动态加载特性来搞事情,像比较出名的 OSGI 模块化(一个模块一个类加载器),JSP(运行时转换为字节流让加载器动态加载),Tomcat(自定义了许多类加载器用来隔离不同工程)… 这里就不一一列举了。本文还是先把类加载流程先讲一讲,然后分享一下作者的一次自定义类加载的经验心得, 概要如下:
文章结构
1 类加载的各个流程讲解
2 自定义类加载器讲解
3 实战自定义类加载器
1. 类加载的各个流程讲解
作者找了下网上的图,参考着自己画了一张类生命周期流程图:
类的生命周期图
注意点: 图中各个流程并不是严格的先后顺序,比如在进行 1 加载时,其实 2 验证已经开始了,是交叉进行的。
加载
加载阶段说白了,就是把我们编译后的.Class 静态文件转换到内存中(方法区), 然后暴露出来让程序员能访问到。具体展开:
- 通过一个类的全限定名来获取定义此类的二进制字节流(可以是.class 文件,也可以是网络上的 io, 也可以是 zip 包等)
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中 (HotSpot 的实现其实就是在方法区) 生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
验证
加载阶段获得的二进制字节流并不一定是来自.class 文件,比如网络上发来的,那么如果不进行一定的格式校验,肯定是不能加载的。所以验证阶段实际上是为了保护 JVM 的。对于一般 Javaer 来说,俺们都是.java 文件编译出来的.class 文件,然后转换成相应的二进制流,没啥危害。所以不用太关心这一部分。
准备
准备阶段主要是给 static 变量分配内存 (方法区中),并设置初始值。
比如: public static Integer value =1; 在准备阶段的值其实是为 0 的。需要注意的是常量是在准备阶段赋值的:
public static final Integer value =1 ; 在准备阶段 value 就被赋值为了 1;
解析
解析阶段就更抽象了,稍微说一下,因为不太重要,有两个概念,符号引用,直接引用。说的通俗一点但是不太准确,比如在类 A 中调用了 new B(); 大家想一想, 我们编译完成.class 文件后其实这种对应关系还是存在的,只是以字节码指令的形式存在,比如 “invokespecial #2” 大家可以猜到 #2 其实就是我们的类 B 了,那么在执行这一行代码的时候,JVM 咋知道#2 对应的指令在哪, 这就是一个静态的家伙,假如类 B 已经加载到方法区了,地址为 (#f00123), 所以这个时候就要把这个#2 转成这个地址(#f00123), 这样 JVM 在执行到这时不就知道 B 类在哪了,就去调用了。(说的这么通俗,我都怀疑人生了). 其他的,像方法的符号引用,常量的符号引用,其实都是一个意思,大家要明白,所谓的方法,常量,类,都是高级语言(Java) 层面的概念,在.class 文件中,它才不管你是啥,都是以指令的形式存在, 所以要把那种引用关系 (谁调用谁,谁引用谁) 都转换为地址指令的形式。好了。说的够通俗了。大家凑合理解吧。这块其实不太重要,对于大部分 coder 来说,所以我就通俗的讲了讲。
初始化
这一块其实就是调用类的构造方法, 注意是类的构造方法,不是实例构造函数,实例构造函数就是我们通常写的构造方法,类的构造方法是自动生成的,生成规则:
static 变量的赋值操作 +static 代码块
按照出现的先后顺序来组装。
注意:1 static 变量的内存分配和初始化是在准备阶段.2 一个类可以是很多个线程同时并发执行,JVM 会加锁保证单一性,所以不要在 static 代码块中搞一些耗时操作。避免线程阻塞。
使用 & 卸载
使用就是你直接 new 或者通过反射.newInstance 了.
卸载是自动进行的,gc 在方发区也会进行回收. 不过条件很苛刻,感兴趣可以自己看一看,一般都不会卸载类.
2. 自定义类加载器讲解
2.1 类加载器
类加载器,就是执行上面类加载流程的一些类,系统默认的就有一些加载器,站在 JVM 的角度,就只有两类加载器:
- 启动类加载器(Bootstrap ClassLoader):由 C ++ 语言实现(针对 HotSpot), 负责将存放在
<JAVA_HOME>
/lib 目录或 -Xbootclasspath 参数指定的路径中的类库加载到内存中。 -
其他类加载器:由 Java 语言实现,继承自抽象类 ClassLoader。如:
- 扩展类加载器(Extension ClassLoader):负责加载
<JAVA_HOME>
/lib/ext 目录或 java.ext.dirs 系统变量指定的路径中的所有类库。 - 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
- 自定义类加载器, 用户根据需求自己定义的。也需要继承自 ClassLoader.
- 扩展类加载器(Extension ClassLoader):负责加载
2.2 双亲委派模型
如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即 ClassNotFoundException),子加载器才会尝试自己去加载。见下图:
双亲委派模型
需要注意的是,自定义类加载器可以不遵循双亲委派模型,但是图中红色区域这种传递关系是 JVM 预先定义好的,谁都更改不了。双亲委派模型有什么好处呢?举个例子,比如有人故意在自己的代码中定义了一个 String 类,包名类名都和 JDK 自带的一样,那么根据双亲委派模型,类加载器会首先传递到父类加载器去加载,最终会传递到启动类加载器,启动加载类判断已经加载过了,所以程序员自定义的 String 类就不会被加载。避免程序员自己随意串改系统级的类。
2.3 自定义类加载器
上面说了半天理论,我都有点迫不及待的想上代码了。下面看看如何来自定义类加载器,并且如何在自定义加载器时遵循双亲委派模型(向上传递性). 其实非常简单,在这里 JDK 用到了模板的设计模式,向上传递性其实已经帮我们封装好了,在 ClassLoader 中已经实现了,在 loadClass 方法中:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{synchronized (getClassLoadingLock(name)) {
// 1\. 检查是否已经加载过。Class c = findLoadedClass(name);
if (c == null) {long t0 = System.nanoTime();
try {if (parent != null) {
//2 . 如果没有加载过,先调用父类加载器去加载
c = parent.loadClass(name, false);
} else {
// 2.1 如果没有加载过,且没有父类加载器,就用 BootstrapClassLoader 去加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
//3\. 如果父类加载器没有加载到,调用 findClass 去加载
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {resolveClass(c);
}
return c;
}
}
从上面代码可以明显看出,loadClass(String, boolean)函数即实现了双亲委派模型!整个大致过程如下:
- 检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
- 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用 parent.loadClass(name, false);). 或者是调用 bootstrap 类加载器来加载。
- 如果父加载器及 bootstrap 类加载器都没有找到指定的类,那么调用当前类加载器的 findClass 方法来完成类加载。默认的 findclass 毛都不干,直接抛出 ClassNotFound 异常,所以我们自定义类加载器就要覆盖这个方法了。
- 可以猜测:ApplicationClassLoader 的 findClass 是去 classpath 下去加载,ExtentionClassLoader 是去 java_home/lib/ext 目录下去加载。实际上就是 findClass 方法不一样罢了。
由上面可以知道, 抽象类 ClassLoader 的 findClass 函数默认是抛出异常的。而前面我们知道,loadClass 在父加载器无法加载类的时候,就会调用我们自定义的类加载器中的 findeClass 函数,因此我们必须要在 loadClass 这个函数里面实现将一个指定类名称转换为 Class 对象.
如果是是读取一个指定的名称的类为字节数组的话,这很好办。但是如何将字节数组转为 Class 对象呢?很简单,Java 提供了 defineClass 方法,通过这个方法,就可以把一个字节数组转为 Class 对象啦~
defineClass: 将一个字节数组转为 Class 对象,这个字节数组是 class 文件读取后最终的字节数组.
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
throws ClassFormatError {return defineClass(name, b, off, len, null);
上面介绍了自定义类加载器的原理和几个重要方法(loadClass,findClass,defineClass),相信大部分小伙伴还是一脸蒙蔽,没关系,我先上一副图,然后上一个自定义的类加载器:
自定义类加载器方法调用流程图
样例自定义类加载器:
import java.io.InputStream;
public class MyClassLoader extends ClassLoader
{public MyClassLoader()
{ }
public MyClassLoader(ClassLoader parent)
{
// 一定要设置父 ClassLoader 不是 ApplicationClassLoader, 否则不会执行 findclass
super(parent);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException
{
//1\. 覆盖 findClass,来找到.class 文件,并且返回 Class 对象
try
{String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
//2\. 如果没找到,return null
return null;
}
byte[] b = new byte[is.available()];
is.read(b);
//3\. 讲字节数组转换成了 Class 对象
return defineClass(name, b, 0, b.length);
}
catch (Exception e)
{e.printStackTrace();
}
return null;
}
}
稍微说一下:
其实很简单,继承 ClassLoader 对象,覆盖 findClass 方法,这个方法的作用就是找到.class 文件, 转换成字节数组,调用 defineClass 对象转换成 Class 对象返回。就这么 easy..
演示下效果:
MyClassLoader mcl = new MyClassLoader();
Class<?> c1 = Class.forName("Student", true, mcl);
Object obj = c1.newInstance();
System.out.println(obj.getClass().getClassLoader());
System.out.println(obj instanceof Student);
返回结果:
sun.misc.Launcher$AppClassLoader@6951a712
true
MyClassLoader mcl = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent());
Class<?> c1 = Class.forName("Student", true, mcl);
Object obj = c1.newInstance();
System.out.println(obj.getClass().getClassLoader());
System.out.println(obj instanceof Student);
返回结果:
MyClassLoader@3918d722
false
重点分析:
第一个代码和第二个代码唯一一点不同的就是在 new MyClassLoader()时,一个传入的 ClassLoader.getSystemClassLoader().getParent();(这个其实就是扩展类加载器)
-
当不传入这个值时,默认的父类加载器为 Application ClassLoader, 那么大家可以知道,在这个加载器中已经加载了 Student 类 (ClassPath 路径下的 Student 类),我们在调用 Class.forName 时传入了自定义的类加载器,会调用自定义类加载器的 loadClass, 判断自己之前没有加载过,然后去调用父类的(ApplicationClassLoader) 的 loadClass,判断结果为已经加载,所以直接返回。所以打印 ClassLoader 为 AppClassLoader.
验证默认父类加载器为 ApplicationClassLoader:MyClassLoader mcl = new MyClassLoader(); System.out.println(mcl.getParent().getClass());
打印结果:class sun.misc.Launcher$AppClassLoader
- 当我们传入父类加载器为扩展类加载器时,当调用父类 (扩展类加载器) 的 loadeClass 时,由于扩展类加载器只加载 java_home/lib/ext 目录下的类,所以 classpath 路径下的它不能加载, 返回 null, 根据 loadClass 的逻辑,接着会调用自定义类加载器 findClass 来加载。所以打印 ClassLoader 为 MyClassLoader.
- instanceof 返回 true 的条件是 (类加载器 + 类) 全部一样,虽然这里我们都是一个 Student 类,一个文件,但是由两个类加载器加载的,当然返回 false 了。
- 在 JVM 中判断一个类唯一的标准是 (类加载器 +.class 文件) 都一样. 像 instanceof 和强制类型转换都是这样的标准。
- 注意,这里所说的父类类加载器,不是以继承的方式来实现的,而是以成员变量的方式实现的。当调用构造函数传入时,就把自己的成员变量 parent 设置成了传入的加载器。
- 课外衍生: 这里作者是遵循了双亲委托模型,所以覆盖了 findClass,没有覆盖 loadClass, 其实 loadClass 也是可以覆盖的,比如你覆盖了 loadClass,实现为 ” 直接加载文件,不去判断父类是否已经加载 ”, 这样就打破了双亲委托模型,一般是不推荐这样干的。不过小伙伴们可以试着玩玩.
自定义类加载器就给大家说完了,虽然作者感觉已经讲清楚了,因为无非就是几个方法的问题(loadClass,findClass,defineClass), 但还是给大家几个传送门,可以多阅读阅读,相互参阅一下:
www.cnblogs.com/xrq730/p/48…
www.importnew.com/24036.html
3. 实战自定义类加载器
其实上面基本已经把自定义类加载器给讲清楚了,这里和大家分享一下作者一次实际的编写自定义类加载器的经验。背景如下:
我们在项目里使用了某开源通讯框架,但由于更改了源码,做了一些定制化更改,假设更改源码前为版本 A,更改源码后为版本 B,由于项目中部分代码需要使用版本 A,部分代码需要使用版本 B。版本 A 和版本 B 中所有包名和类名都是一样。那么问题来了,如果只依赖 ApplicationClassLoader 加载,它只会加载一个离 ClassPath 最近的一个版本。剩下一个加载时根据双亲委托模型,就直接返回已经加载那个版本了。所以在这里就需要自定义一个类加载器。大致思路如下图:
双版本设计图
这里需要注意的是,在自定义类加载器时一定要把父类加载器设置为 ExtentionClassLoader,如果不设置,根据双亲委托模型,默认父类加载器为 ApplicationClassLoader,调用它的 loadClass 时,会判定为已经加载(版本 A 和版本 B 包名类名一样),会直接返回已经加载的版本 A,而不是调用子类的 findClass. 就不会调用我们自定义类加载器的 findClass 去远程加载版本 B 了。
顺便提一下,作者这里的实现方案其实是为了遵循双亲委托模型,如果作者不遵循双亲委托模型的话,直接自定义一个类加载器,覆盖掉 loadClass 方法,不让它先去父类检验,而改为直接调用 findClass 方法去加载版本 B,也是可以的. 大家一定要灵活的写代码。
结语
好了,JVM 类加载机制给大家分享完了,希望大家在碰到实际问题的时候能想到自定义类加载器来解决。Have a good day .
关注下面的标签,发现更多相
打破双亲委派模型
上文提到过双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的类加载器实现方式。在 Java 的世界中大部分的类加载器都遵循这个模型,但也有例外。
双亲委派模型的一次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的 API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?这并非是不可能的事情,一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器去加载(在 JDK 1.3 时放进去的 rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 Class Path 下的 JNDI 接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码,因为启动类加载器的搜索范围中找不到用户应用程序类,那该怎么办?为了解决这个问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(Application ClassLoader)。
有了线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。
双亲委派模型的另一次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换(HotSwap)、模块热部署(HotDeployment)等,说白了就是希望应用程序能像我们的计算机外设那样,接上鼠标、U 盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用停机也不用重启。对于个人计算机来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是企业级软件开发者具有很大的吸引力。Sun 公司所提出的 JSR-294、JSR-277 规范在与 JCP 组织的模块化规范之争中落败给 JSR-291(即 OSGi R4.2),虽然 Sun 不甘失去 Java 模块化的主导权,独立在发展 Jigsaw 项目,但目前 OSGi 已经成为了业界“事实上”的 Java 模块化标准,而 OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。
在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:
1)将以 java.* 开头的类委派给父类加载器加载。
2)否则,将委派列表名单内的类委派给父类加载器加载。
3)否则,将 Import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载。
4)否则,查找当前 Bundle 的 Class Path,使用自己的类加载器加载。
5)否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载。
6)否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。
7)否则,类查找失败。
上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类加载器中进行的。
只要有足够意义和理由,突破已有的原则就可认为是一种创新。正如 OSGi 中的类加载器并不符合传统的双亲委派的类加载器,并且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但在 Java 程序员中基本有一个共识:OSGi 中对类加载器的使用是很值得学习的,弄懂了 OSGi 的实现,就可以算是掌握了类加载器的精髓。
Tomcat 的类加载器架构
主流的 Java Web 服务器(也就是 Web 容器),如 Tomcat、Jetty、WebLogic、WebSphere 或其他笔者没有列举的服务器,都实现了自己定义的类加载器(一般都不止一个)。因为一个功能健全的 Web 容器,要解决如下几个问题:
1)部署在同一个 Web 容器上 的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应当保证两个应用程序的类库可以互相独立使用。
2)部署在同一个 Web 容器上 的两个 Web 应用程序所使用的 Java 类库可以互相共享。这个需求也很常见,例如,用户可能有 10 个使用 spring 组织的应用程序部署在同一台服务器上,如果把 10 份 Spring 分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到 Web 容器的内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
3)Web 容器需要尽可能地保证自身的安全不受部署的 Web 应用程序影响。目前,有许多主流的 Java Web 容器自身也是使用 Java 语言来实现的。因此,Web 容器本身也有类库依赖的问题,一般来说,基于安全考虑,容器所使用的类库应该与应用程序的类库互相独立。
4)支持 JSP 应用的 Web 容器,大多数都需要支持 HotSwap 功能。我们知道,JSP 文件最终要编译成 Java Class 才能由虚拟机执行,但 JSP 文件由于其纯文本存储的特性,运行时修改的概率远远大于第三方类库或程序自身的 Class 文件。而且 ASP、PHP 和 JSP 这些网页应用也把修改后无须重启作为一个很大的“优势”来看待,因此“主流”的 Web 容器都会支持 JSP 生成类的热替换,当然也有“非主流”的,如运行在生产模式(Production Mode)下的 WebLogic 服务器默认就不会处理 JSP 文件的变化。
由于存在上述问题,在部署 Web 应用时,单独的一个 Class Path 就无法满足需求了,所以各种 Web 容都“不约而同”地提供了好几个 Class Path 路径供用户存放第三方类库,这些路径一般都以“lib”或“classes”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相应的自定义类加载器去加载放置在里面的 Java 类库。现在,就以 Tomcat 容器为例,看一看 Tomcat 具体是如何规划用户类库结构和类加载器的。
在 Tomcat 目录结构中,有 3 组目录(“/common/”、“/server/”和“/shared/”)可以存放 Java 类库,另外还可以加上 Web 应用程序自身的目录“/WEB-INF/”,一共 4 组,把 Java 类库放置在这些目录中的含义分别如下:
①放置在 /common 目录中:类库可被 Tomcat 和所有的 Web 应用程序共同使用。
②放置在 /server 目录中:类库可被 Tomcat 使用,对所有的 Web 应用程序都不可见。
③放置在 /shared 目录中:类库可被所有的 Web 应用程序共同使用,但对 Tomcat 自己不可见。
④放置在 /WebApp/WEB-INF 目录中:类库仅仅可以被此 Web 应用程序使用,对 Tomcat 和其他 Web 应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如下图所示。
上图中灰色背景的 3 个类加载器是 JDK 默认提供的类加载器,这 3 个加载器的作用已经介绍过了。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebappClassLoader 则是 Tomcat 自己定义的类加载器,它们分别加载 /common/、/server/、/shared/和 /WebApp/WEB-INF/中的 Java 类库。其中 WebApp 类加载器和 Jsp 类加载器通常会存在多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 Jsp 类加载器。
从图中的委派关系中可以看出,CommonClassLoader 能加载的类都可以被 Catalina ClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 Shared ClassLoader 自己能加载的类则与对方相互隔离。WebAppClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebAppClassLoader 实例之间相互隔离。而 JasperLoader 的加载范围仅仅是这个 JSP 文件所编译出来的那一个.Class 文件,它出现的目的就是为了被丢弃:当 Web 容器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过再建立一个新的 Jsp 类加载器来实现 JSP 文件的 HotSwap 功能。
对于 Tomcat 的 6.x 版本,只有指定了 tomcat/conf/catalina.properties 配置文件的 server.loader 和 share.loader 项后才会真正建立 Catalina ClassLoader 和 Shared ClassLoader 的实例,否则在用到这两个类加载器的地方都会用 Common ClassLoader 的实例代替,而默认的配置文件中没有设置这两个 loader 项,所以 Tomcat 6.x 顺理成章地把 /common、/server 和 /shared 三个目录默认合并到一起变成一个 /lib 目录,这个目录里的类库相当于以前 /common 目录中类库的作用。这是 Tomcat 设计团队为了简化大多数的部署场景所做的一项改进,如果默认设置不能满足需要,用户可以通过修改配置文件指定 server.loader 和 share.loader 的方式重新启用 Tomcat 5.x 的加载器架构。
Tomcat 加载器的实现清晰易懂,并且采用了官方推荐的“正统”的使用类加载器的方式。如果读者阅读完上面的案例后,能完全理解 Tomcat 设计团队这样布置加载器架构的用意,那说明已经大致掌握了类加载器“主流”的使用方式,那么笔者不妨再提一个问题让读者思考一下:前面曾经提到过一个场景,如果有 10 个 Web 应用程序都是用 Spring 来进行组织和管理的话,可以把 Spring 放到 Common 或 Shared 目录下让这些程序共享。Spring 要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在 /WebApp/WEB-INF 目录中的,那么被 CommonClassLoader 或 SharedClassLoader 加载的 Spring 如何访问并不在其加载范围内的用户程序呢?如果研究过虚拟机类加载器机制中的双亲委派模型,相信读者可以很容易地回答这个问题。
分析:如果按主流的双亲委派机制,显然无法做到让父类加载器加载的类 去访问子类加载器加载的类,上面在类加载器一节中提到过通过线程上下文方式传播类加载器。
答案是使用线程上下文类加载器来实现的,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。看 spring 源码发现,spring 加载类所用的 Classloader 是通过 Thread.currentThread().getContextClassLoader()来获取的,而当线程创建时会默认 setContextClassLoader(AppClassLoader),即线程上下文类加载器被设置为 AppClassLoader,spring 中始终可以获取到这个 AppClassLoader(在 Tomcat 里就是 WebAppClassLoader)子类加载器来加载 bean,以后任何一个线程都可以通过 getContextClassLoader()获取到 WebAppClassLoader 来 getbean 了。
本篇博文内容取材自《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》
微信公众号【Java 技术江湖】一位阿里 Java 工程师的技术小站。(关注公众号后回复”Java“即可领取 Java 基础、进阶、项目和架构师等免费学习资料,更有数据库、分布式、微服务等热门技术学习视频,内容丰富,兼顾原理和实践,另外也将赠送作者原创的 Java 学习指南、Java 程序员面试指南等干货资源)