学习清单:
深入探讨 Java 类加载器
老大难的 Java ClassLoader 再不理解就老了
好怕怕的类加载器
1. 类加载子系统的作用
类加载过程即是指JVM
虚拟机把.class
文件中类信息加载进内存,并进行解析生成对应的class
对象的过程,class
对象就是一份描述Class
结构的元信息对象(类模版对象),通过该元信息对象可以获知Class
的结构信息:如构造函数,属性和方法等,Java
允许用户借由这个Class
相关的元信息对象间接调用Class
对象的功能,这里就是我们经常能见到的Class
类
-
类加载器子系统负责从文件系统或者网络中加载
class
文件,class
文件在文件头有特定的标识(cafe baby
) -
ClassLoader
只负责class文件的加载,至于是否能运行,则有Execution Engine
决定(这里的是否能运行指的是会不会出错) -
加载的类信息存放在一块称为方法区的内存空间,除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量
2. 类的加载过程
2.1 加载
① 加载指的是根据一个类的全限定名将 (class文件) 定义此类的二进制字节流读入内存
虚拟机规范并没有指明二进制文件是从哪里获取,也就是说并不一定是从class
文件中获取,还可以通过以下方式获取
-
运行时计算生成
我们经常使用的动态代理技术就是这样,在java.lang.reflect.Proxy
中使用ProxyGenerator.generateProxyClass
来为特定接口生成形式为*$Proxy
的代理类的二进制字节流 -
由其他文件生成
我们用到的JSP文件也可以生成对应的Class
类 -
从
ZIP
包中读取
常见的就是JAR
,WAR
格式的包的使用
② 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(instanceKlass
)
字节码被加载到方法区,内部采用c++的instancKlass
来描述java类,他的主要field有
- _java_mirror 即 java 的类镜像,指向Class对象,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法表
- jdk1.8是放在了元空间,构成了
instanceKlass
的数据结构,这个instanceKlass
是用来描述类的数据结构 - jdk1.7是放在了堆的永久代
③ 在内存中生成一个代表此类的java.lang.Class
对象,作为访问方法区这些运行时数据结构的入口(Class对象)
Class
对象有指向Klass
的指针,java并不能直接访问instanceKlass
,而需要使用该Class
对象来使用(他就是上面提到的java_mirror
),想要访问Klass
对象要先找到Class
对象,再通过Class
指向Klass
的指针访问instanceKlass
加载完成之后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区之中,然后在内存中实例化一个java.lang.Class
类型的对象(并没有明确要在堆中)
hotspot
选择将Class
对象存储在方法区中(这点比较特殊,他虽然是对象,但是存储在方法区中)持有instanceKlass
的内存地址(instanceKlass
也持有_java_mirror
对象的内存地址),Java虚拟机规范并没有明确要求一定要存储在方法区或堆区中
关于这里的instanceKlass
再提一嘴【理解HotSpot虚拟机】对象在jvm中的表示:OOP-Klass模型
HotSpot
是基于c++
实现,而c++
是一门面向对象的语言,本身具备面向对象基本特征,所以Java
中的对象表示,最简单的做法是为每个Java
类生成一个c++
类与之对应。
但HotSpot JVM
并没有这么做,而是设计了一个OOP-Klass Model
;这里的 OOP
指的是 Ordinary Object Pointer
(普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而 Klass
则包含元数据和方法信息,用来描述Java类。
之所以采用这个模型是因为HotSopt JVM
的设计者不想让每个对象中都含有一个vtable
(虚函数表),所以就把对象模型拆成klass
和oop
,其中oop
中不含有任何虚函数,而Klass
就含有虚函数表,可以进行method dispatch
Klass
简单的说是Java
类在HotSpot
中的c++
对等体,用来描述Java
类,一般jvm
在加载class
文件时,会在方法区创建instanceKlass
,表示其元数据,包括常量池、字段、方法等
OOP
则是在Java程序运行过程中new对象时创建的
类的加载由类加载器完成,JVM提供的类加载器叫做系统类加载器,此外还可以通过继承ClassLoader
基类来自定义类加载器
相对于类生命周期的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载
关于数组类型的加载
数组类本身并不通过类加载器创建,它是由jvm直接创建的,但是的数组的元素类型还是要由类加载器去创建
- 如果数组的类型是引用类型,就会递归加载这个组件类型
- 如果数组的类型不是引用类型,会把数组标记为和引用类加载器相关联
2.2 链接
连接阶段负责把类的二进制数据合并到JRE
中,其又可分为如下三个阶段:
① 校验
此阶段主要确保Class
文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全
为什么不在刚读取二进制字节流后就进行验证,而是在Class
对象生成完成了以后再验证?
深入理解JVM虚拟机这本书说的是加载阶段和连接阶段的部分内容是交叉进行的(比如一部分字节码文件格式验证工作)
② 准备
为类变量分配内存,并将其初始化为默认值,这些内存都将在方法区中分配(此时为默认值,在初始化的时候才会给变量赋值)
public static int value = 123;
此时在准备阶段过后的初始值为0而不是123;将value
赋值为123的putstatic
指令是程序被编译后,存放于类构造器<client>
方法之中
注意: 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象分配到堆中,这里还没对象,自然不会为实例变量分配初始化即在方法区中分配这些变量所使用的内存空间。
注意: static final
的常量在编译的使时候就已经分配值了,准备阶段会显示初始化
public static final int value = 123;
此时value
的值在准备阶段过后就是123
③ 解析
把常量池内的符号引用转换为直接引用
符号引用: 符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行,布局和内存无关
直接引用: 可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在
主要有以下四种:
- 类或接口的解析
- 字段解析
- 类方法解析
- 接口方法解析
假设:一个类有一个静态变量,该静态变量是一个自定义的类型,那么经过解析后,该静态变量将是一个指针,指向该类在方法区的内存地址
2.3 初始化
在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作都是由虚拟机主导和控制的,到了初始化阶段,才开始真正执行Java程序代码
初始化阶段是执行类构造器<clinit>
方法的过程(注意这不是我们平时自己定义的构造器,构造器在JVM角度是<init>
方法)
-
<clinit>
方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的,收集的顺序是由语句在源文件中出现的顺序决定的; -
虚拟机会保证
<clinit>
方法执行之前,父类的<clinit>
方法已经执行完毕;和实例构造器<init>()
不同,不需要去显示的调用父类的构造方法 -
JVM必须确保一个类在初始化的过程中,如果是多线程需要同时初始化它,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程。(所以可以利用静态内部类实现线程安全的单例模式)
-
如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成
<clinit>
()方法 -
静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的不能。前面的静态语句块可以赋值,但不能访问
public class ClassInitTest{
private static int num = 1;
static{
num = 2;
number = 20;
}
private static int number = 10;
public static void main(String[] args){
System.out.print(number);//10
}
}
之前一直搞不懂在上面代码中number = 20;
的赋值操作为什么可以放到private static int number = 10;
声明语句的上面,针对上面的这些步骤再看
① 首先在链接的准备阶段就会为类变量分配内存,并将其初始化为默认值,所以这个时候内存中就已经有了number
,且初值为默认值0
② 静态代码块中number = 20;
的赋值操作是在初始化阶段进行的,所以合情合理
③ 最后打印结果是10,因为<clinit>
方法中指令按照语句在源文件中出现的顺序执行
但是下面的代码是错的,虽然可以在它声明之前赋值,但是不能在它声明之前调用
public class ClassInitTest{
private static int num = 1;
static{
num = 2;
number = 20;
System.out.print(number);//错误,非法的前向引用
}
private static int number = 10;
public static void main(String[] args){
System.out.print(number);//10
}
}
- 接口中不能使用静态语句块,但仍有变量初始化的赋值操作,但是和类不同的是,执行接口的< clinit>()方法不需要先执行父类的,只有当父类接口中定义的变量被使用的时候才会初始化
2.3.1 java中,对于初始化阶段,有且只有以下六种情况才会对要求类立刻“初始化”(加载,验证,准备,自然需要在此之前开始)
- 遇到
new
,getstatic
,putstatic
,invokestatic
指令的时候
- 使用
new
关键字实例化对象(比如new、反射、序列化) - 调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行
getstatic
或者putstatic
指令),(被final
修饰的静态字段除外、编译器优化时已经放入常量池) - 调用一个类型的静态方法时(即在字节码中执行
invokestatic
指令)
-
初始化一个类的派生类时,先触发父类的初始化(Java虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作,接口例外)
需要注意,这一点对于接口来说,初始化接口不要求其父接口都被初始化,注意在真正使用到父接口(如引用父接口的常量)才会初始化 -
使用
java.lang.reflect
包的方法进行反射调用的时候,如果类没有被初始化,则要先初始化。 -
虚拟机启动时,用户会先初始化要执行的主类(含有
main
)
上面称为对一个类的主动引用,除此之外所有引用类的方法都属于被动引用,不会触发初始化
2.3.2 不会引发初始化的几个场景
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化
public class SuperClass{
public static int value = 123;
static{
System.out.printlin("Super init");
}
}
public class SubClass extends SuperClass{
static{
System.out.printlin("Sub init");
}
}
public class NotInitialization{
public static void main(String[] args){
System.out.printlin(SubClass.val);
//Super init
//123
}
}
但是是否会触发子类的加载和验证,在虚拟机规范中并未明确规定,这取决于虚拟机的具体实现
- 定义对象数组和集合,不会触发该类的初始化
package org.fenixsoft.classloading;
public class NotInitialization{
public static void main(String[] args){
SuperClass[] sups = new SuperClass[10];
}
}
但是会触发 [Lorg.fenixsoft.classloading.SuperClass
的类的初始化,这个类代表一个元素类型为org.fenixsoft.classloading.SuperClass
的一位数组,创建动作由字节码指令newArray
触发,数组中的属性和方法(length
,clone()
)都实现在这个类里面;
Java语言对数组的访问比c/c++安全是因为这个类封装了数组元素的访问方法,而c/c++翻译为对数组指针的移动
- 类A引用类B的
static final
常量不会导致类B初始化(注意静态常量必须是字面值常量,否则还是会触发B的初始化)
public class ConstClass{
static{
System.out.printlin("ConstClass init");
}
public static final String HELLO = "hello";
}
public class NotInitialization{
public static void main(String[] args){
System.out.printlin("ConstClass.HELLO");
//hello
}
}
上面的代码并没有输出ConstClass init
,因为虽然引用了ConstClass
类的HELLO
常量,但是在编译阶段通过常量的传播优化,以及将此常量的hello
值存储到了NotInitialization
类的常量池中,以后对ConstClass.Hello
的引用实际都是转换为对自身常量池的引用
-
通过类名获取Class对象,不会触发类的初始化。如
System.out.println(Person.class);
-
通过
Class.forName
加载指定类时,如果指定参数initialize
为false
时,也不会触发类初始化 -
通过
ClassLoader
默认的loadClass
方法,也不会触发初始化动作
不会导致类初始化,不代表类不会经历加载、验证、准备阶段
3. 类加载器
类加载器是负责读取 Java
字节代码,并转换成java.lang.Class
类的一个实例
把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性
3.1 类的唯一性
类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性
正如一个对象有一个唯一的标识一样,一个载入JVM
的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM
中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person
的类,被类加载器ClassLoader
的实例kl
负责加载,则该Person
类对应的Class
对象在JVM
中表示为(Person.pg.kl)
。这意味着两个类加载器加载的同名类:(Person.pg.kl)
和(Person.pg.kl2)
是不同的、它们所加载的类也是完全不同、互不兼容的
这里的相同包括Class对象的的equals
方法,isInstance
方法的返回结果,还包括使用instanceof
关键字做对象所属关系判断等情况
通俗一点来讲,要判断两个类是否“相同”,前提是这两个类必须被同一个类加载器加载,否则这个两个类不“相同”
3.2 类加载器的分类
JVM规范中是这样定义类加载器类型的,JVM支持两种类型的类加载器,分别为
- 引导类加载器(
bootstrap classloader
) - 自定义类加载器(
User-Defined ClassLoader
)
这样分类的原因是bootstrap classloader
是由C++语言编写的,而其他都是派生于抽象类classLoader
的java层面实现的类加载器
而我们也可以按照虚拟机自带的和用户自定义的为标准来分类
这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是以组合的方式来复用父加载器代码
都含有一个ClassLoader parent
成员变量,该变量指向其父加载器,类似单向链表
① 启动类加载器 (bootstrap classloader)
- 这个类加载器使用C/C++语言实现,嵌套在JVM内部
- 它用来加载 Java 的核心类(
$JAVA_HOME
中jre/lib/rt.jar
里所有的class
) - 并不继承自
java.lang.ClassLoader
(C++实现的当然不能继承自Java的体系结构) - 负责装载
<Java_Home>/lib
下面的核心类库或-Xbootclasspath
选项指定的jar包,只加载包名为java
,javax
,sun
开头的类 - 由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作
下面程序可以获得根类加载器所加载的核心类库,并会看到本机安装的Java环境变量指定的jdk中提供的核心jar包路径:
public class ClassLoaderTest {
public static void main(String[] args) {
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for(URL url : urls){
System.out.println(url.toExternalForm());
}
}
}
② 扩展类加载器 (ExtClassLoader)
- 拓展类类加载器,它用来加载
<JAVA_HOME>/jre/lib/ext
路径以及java.ext.dirs
系统变量指定的类路径下的类 - 派生自
ClassLoader
类
③ 应用程序类加载器 (AppClassLoader)
- 应用程序类类加载器,它主要加载应用程序
ClassPath
下的类(包含jar包中的类)。它是java应用程序默认的类加载器,一般来说,java应用的类都是由他来加载
除了系统提供的类加载器以外,开发人员可以通过继承 java.lang.ClassLoader
类的方式实现自己的类加载器,以满足一些特殊的需求
⑤ 自定义类加载器
为什么要自定义类加载器
把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性
-
隔离加载类
如果引入了不同的中间件,他们定义的一些类的名字一样,路径一样,就会出现类的冲突的;这个时候让他们使用各自的类加载器加载就会实现不同的中间件的隔离 -
修改类加载的方式
可以在需要的时候再动态加载 -
拓展加载源
从其他地方加载二进制文件
我们需要的类不一定存放在已经设置好的classPath
下(有系统类加载器AppClassLoader
加载的路径),对于自定义路径中的class
类文件的加载,我们需要自己的ClassLoader
-
防止源码泄漏
有时我们不一定是从类文件中读取类,可能是从网络的输入流中读取类,这就需要做一些加密和解密操作,这就需要自己实现加载类的逻辑,当然其他的特殊处理也同样适用 -
可以定义类的实现机制,实现类的热部署,如
OSGi
中的bundle
模块就是通过实现自己的ClassLoader
实现的
怎么自定义加载器
ClassLoader
里面有三个重要的方法 loadClass()
、findClass()
和 defineClass()
。
loadClass()
方法是加载目标类的入口,它首先会查找当前 ClassLoader
以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass()
让自定义加载器自己来加载目标类。ClassLoader
的 findClass()
方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass()
方法将字节码转换成 Class
对象,关于为什么是让子类重写 findClass()
方法而不是直接重写loadClass
方法,是因为在loadClass
方法中有双亲委派机制的逻辑,最后如果双亲都无法加载,在去调用自身的 findClass()
方法
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) {
// 父类加载器无法完成加载请求
}
if (c == null) {
// 父类无法加载再调用自身的findClass方法加载
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
findClass
方法中我们实际上需要做的就是读取以下需要加载的二进制文件,再交给defineClass
方法把二进制流字节组成的文件转换为一个java.lang.Class
public class MyClassLoader extends ClassLoader{
public MyClassLoader(){
}
public MyClassLoader(ClassLoader parent){
super(parent);
}
protected Class<?> findClass(String name) throws ClassNotFoundException{
File file = getClassFile(name);
try{
byte[] bytes = getClassBytes(file);
Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
return c;
}
catch (Exception e){
e.printStackTrace();
}
return super.findClass(name);
}
private File getClassFile(String name){
File file = new File("D:/Person.class");
return file;
}
private byte[] getClassBytes(File file) throws Exception{
// 这里要读入.class的字节,因此要使用字节流
FileInputStream fis = new FileInputStream(file);
FileChannel fc = fis.getChannel();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
WritableByteChannel wbc = Channels.newChannel(baos);
ByteBuffer by = ByteBuffer.allocate(1024);
while (true){
int i = fc.read(by);
if (i == 0 || i == -1)
break;
by.flip();
wbc.write(by);
by.clear();
}
fis.close();
return baos.toByteArray();
}
}
自定义类加载器不要破坏双亲委派规则,不要轻易覆盖 loadClass
方法。否则可能会导致自定义加载器无法加载内置的核心类库。在使用自定义加载器时,要明确好它的父加载器是谁,将父加载器通过子类的构造器传入。如果父类加载器是 null
,那就表示父加载器是「根加载器」
3.3 双亲委派机制
学习老大难的 Java ClassLoader 再不理解就老了
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类的时候才会将他的Class文件加载到内存中生成class对象;而在加载某个类的class文件的时候,Java虚拟机采用的是双亲委派机制,即把请求交给父类处理,他是一种任务委派模式
上面提到了双亲委派机制,其实就是,当一个类加载器去加载类时先尝试让父类加载器去加载,如果父类加载器加载不了再尝试自身加载。这也是我们在自定义ClassLoader
时java
官方建议遵守的约定。
AppClassLoader
在加载一个未知的类名时,它并不是立即去搜寻 Classpath,它会首先将这个类名称交给 ExtensionClassLoader
来加载,如果 ExtensionClassLoader
可以加载,那么 AppClassLoader
就不用麻烦了。否则它就会搜索 Classpath
。
而 ExtensionClassLoader
在加载一个未知的类名时,它也并不是立即搜寻 ext 路径,它会首先将类名称交给 BootstrapClassLoader
来加载,如果 BootstrapClassLoader
可以加载,那么 ExtensionClassLoader
也就不用麻烦了。否则它就会搜索 ext 路径下的 jar 包。这三个 ClassLoader
之间形成了级联的父子关系,每个 ClassLoader
都很懒,尽量把工作交给父亲做,父亲干不了了自己才会干。每个 ClassLoader
对象内部都会有一个 parent
属性指向它的父加载器
class ClassLoader {
...
private final ClassLoader parent;
...
}
值得注意的是图中的 ExtensionClassLoader
的 parent
指针画了虚线,这是因为它的 parent
的值是 null
,当 parent
字段是 null
时就表示它的父加载器是「根加载器」。如果某个 Class
对象的 classLoader
属性值是 null
,那么就表示这个类也是「根加载器」加载的
这种机制有以下好处:
① 避免类的重复加载
② 保护程序安全,防止核心API被随用篡改(沙箱安全机制)
对于第一点,假设有以下的场景,两个类A和类B都要加载System
类:
-
如果不用委托而是自己加载自己的,那么类A就会加载一份
System
字节码,然后类B又会加载一份System
字节码,这样内存中就出现了两份System
字节码 -
如果使用委托机制,会递归的向父类查找,也就是首选用
Bootstrap
尝试加载,如果找不到再向下。这里的System
就能在Bootstrap
中找到然后加载,类加载器在成功加载某个类之后,会把得到的java.lang.Class
类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即loadClass
方法不会被重复调用,如果此时类B也要加载System
,也从Bootstrap
开始,此时Bootstrap
发现已经加载过了System
那么直接返回内存中的System
即可而不需要重新加载,这样内存中就只有一份System
的字节码了
对于第二点,假设有以下的场景,我们自己写个类叫java.lang.System
:
类加载采用委托机制,这样可以保证爸爸们优先,爸爸们能找到的类,儿子就没有机会加载。而System
类是Bootstrap
加载器加载的,就算自己重写,也总是使用Java
系统提供的System
,自己写的System
类根本没有机会得到加载,保护程序安全,防止核心API被随用篡改
3.4 双亲委派机制的缺陷及打破
浅谈双亲委派机制的缺陷及打破双亲委派机制
以JDBC为例谈双亲委派模型的破坏
3.4.1 缺陷
由于BootstrapClassloader
是顶级类加载器,BootstrapClassloader
无法委派AppClassLoader
来加载类,也就是说BootstrapClassloader
中加载的类中无法使用由AppClassLoader
加载的类。可能绝大部分情况这个不算是问题,因为BootstrapClassloader
加载的都是基础类,供AppClassLoader
加载的类调用的类。但是万事万物都不是绝对的,比如经典的JAVA SPI
机制
3.4.2 双亲委派机制的打破
双亲委派模型主要出现过三次大规模被破坏的情况
这里大致说一下JAVA SPI
机制为什么要打破,并且是如何使用线程上下文类加载器打破双亲委派机制的
下面这段话引自真正理解线程上下文类加载器
Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。
这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar包被包含进类路径(
CLASSPATH
)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由**启动类加载器(BootstrapClassloader
)来加载的;SPI的实现类是由系统类加载器(SystemClassLoader
)**来加载的。引导类加载器是无法找到 SPI的实现类的,因为依照双亲委派模型,BootstrapClassloader
无法委派AppClassLoader
来加载类。而线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。
具体看这篇JDBC详解
3.5 分工与合作
这里我们重新理解一下 ClassLoader
的意义,它相当于类的命名空间,起到了类隔离的作用。位于同一个 ClassLoader
里面的类名是唯一的,不同的 ClassLoader
可以持有同名的类。ClassLoader
是类名称的容器,是类的沙箱
不同的 ClassLoader
之间也会有合作,它们之间的合作是通过 parent
属性和双亲委派机制来完成的。parent
具有更高的加载优先级。除此之外,parent
还表达了一种共享关系,当多个子 ClassLoader
共享同一个 parent
时,那么这个 parent
里面包含的类可以认为是所有子 ClassLoader
共享的。这也是为什么 BootstrapClassLoader
被所有的类加载器视为祖先加载器,JVM 核心类库自然应该被共享
3.6 Class.forName
这是手动加载类的常见方式
public static Class<?> forName(String className)
但是他是使用哪个类加载器来加载的呢?看一下方法的具体实现
public static Class<?> forName(String className)
throws ClassNotFoundException {
// 使用native方法获取调用类的Class对象
Class<?> caller = Reflection.getCallerClass();
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
其中getClassLoader(caller)
设置了所使用的类加载器,继续看其实现:
static ClassLoader getClassLoader(Class<?> caller) {
if (caller == null) {
return null;
}
return caller.getClassLoader0();
}
}
这段代码的官方注解是“返回caller的类加载器”,即native
方法getClassLoader0()
返回调用者的类加载器。也就是说假设在A类里执行forName(String className)
,那么所使用的ClassLoader
就是加载A的ClassLoader
forName
还提供了多参数版本,可以指定使用哪个 ClassLoader 来加载
Class<?> forName(String name, boolean initialize, ClassLoader cl)
通过这种形式的 forName
方法可以突破内置加载器的限制,通过使用自定类加载器允许我们自由加载其它任意来源的类库。根据 ClassLoader
的传递性,目标类库传递引用到的其它类库也将会使用自定义加载器加载
3.7 钻石依赖
项目管理上有一个著名的概念叫着「钻石依赖」,是指软件依赖导致同一个软件包的两个版本需要共存而不能冲突
我们平时使用的 maven
是这样解决钻石依赖的,它会从多个冲突的版本中选择一个来使用,如果不同的版本之间兼容性很糟糕,那么程序将无法正常编译运行。Maven 这种形式叫「扁平化」依赖管理
使用 ClassLoader
可以解决钻石依赖问题。不同版本的软件包使用不同的 ClassLoader
来加载,位于不同 ClassLoader
中名称一样的类实际上是不同的类,上面提到过
ClassLoader
固然可以解决依赖冲突问题,不过它也限制了不同软件包的操作界面必须使用反射或接口的方式进行动态调用。Maven
没有这种限制,它依赖于虚拟机的默认懒惰加载策略,运行过程中如果没有显示使用定制的 ClassLoader
,那么从头到尾都是在使用 AppClassLoader
,而不同版本的同名类必须使用不同的 ClassLoader
加载,所以 Maven
不能完美解决钻石依赖