Java类加载机制与双亲委派机制探析
类加载器的定义及种类
类加载器是一种能够通过类的全限定名获取该类二进制字节流的功能组件。主要存在以下几类类加载器:
- 启动类加载器:用于加载Java的核心类库,这类类库无法被Java程序直接进行引用。
- 扩展类加载器:其职责是加载Java的扩展库。Java虚拟机会提供一个扩展库的目录,该类加载器会在这个目录中查找并加载相应的Java类。
- 系统类加载器:它依据应用的类路径来加载Java类,可以通过
ClassLoader.getSystemClassLoader()
方法获取到该类加载器。 - 自定义类加载器:通过继承
java.lang.ClassLoader
类的方式来进行实现。
JVM类加载机制是什么?
Java的类加载器机制以及双亲委派模型是Java虚拟机(JVM)在加载类文件时所采用的一种体系架构。它有助于确保Java应用程序中类的单一性、安全性以及加载的顺序。
- 全盘负责:当某一个类加载器负责加载某个类时,该类所依赖和引用的其他类也会由这个类加载器来进行载入,除非明确使用其他类加载器来进行载入。
- 缓存机制:缓存机制会保证所有已经加载过的类都会被缓存起来。当程序需要使用某个类时,类加载器会首先从缓存区中查找该类,只有当缓存区中不存在时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,然后存入缓存区。这也就是为什么修改了类之后,必须重启JVM,程序的修改才能生效的原因。
- 双亲委派机制:要是一个类加载器接收到了类加载的请求,它首先不会自己去尝试加载这个类,而是将请求委托给父类加载器去完成,如此层层向上委派,所以所有的类加载请求最终都应该传递到顶层的启动类加载器中。只有当父类加载器在它的搜索范围内没有找到所需的类时,也就是无法完成该加载操作时,子加载器才会尝试自己去加载该类。
双亲委派机制的含义
当一个类加载器接收到一个类的加载请求时,它不会立刻自己去尝试加载这个类,而是将这个请求委派给父类加载器去完成,这样层层委派下去,所以所有的加载请求最终都会传送到顶层的启动类加载器那里。只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
双亲委派模型的具体实现代码在java.lang.ClassLoader
中,该类的loadClass()
方法运行流程是:先检查类是否已经被加载过,如果没有被加载过,就让父类加载器去加载。当父类加载器加载失败时会抛出ClassNotFoundException
,此时子加载器才会尝试自己去加载。
双亲委派模型的目的
能够防止内存中出现多份相同的字节码。假如没有双亲委派模型,而是由各个类加载器自行加载的话,要是用户编写了一个与java.lang.Object
同名的类并放在ClassPath中,多个类加载器都去加载这个类到内存中,那么系统中就会出现多个不同的Object类,这样类之间的比较结果以及类的唯一性就无法得到保证。
何时需要打破双亲委派模型?
比如存在类A和类B,它们都有一个同名的类A,但是两者的内容不一致。如果不打破双亲委派模型,那么类A只会被加载一次。只要在加载类的时候,不按照UserClassLoader->Application ClassLoader->Extension ClassLoader->Bootstrap ClassLoader
这样的顺序来加载,就算是打破了双亲委派模型。例如自定义一个ClassLoader,重写loadClass
方法(不按照往上寻找类加载器的顺序),就属于打破双亲委派机制。
打破双亲委派模型的方式
有两种方式:
- 自定义一个类加载器的类,并重写抽象类
java.lang.ClassLoader
中的loadClass(...)
方法,不再优先委派“父”加载器进行类加载。(例如Tomcat) - 主动违背类加载器的依赖传递原则
- 比如在一个由BootstrapClassLoader加载的类中,又通过APPClassLoader来加载所依赖的其他类,这就打破了“双亲委派模型”中的层次结构,逆转了类之间的可见性。
- 典型的例子是Java SPI机制,它在类
ServiceLoader
中,会使用线程上下文类加载器来逆向加载classpath中的第三方厂商提供的Service Provider类。(比如JDBC)
依赖传递原则是什么?
如果一个类是由类加载器A加载的,那么这个类所依赖的类也是由“相同的类加载器”来进行加载的。
Tomcat是如何打破双亲委派模型的?
在Tomcat部署项目时,会把war包放到tomcat的webapp下,这意味着一个tomcat可以运行多个Web应用程序。
假设现在有两个Web应用程序,它们都有一个类叫User,并且它们的类全限定名相同,比如都是com.yyy.User
,但具体实现不一样。那么Tomcat是如何保证它们不冲突的呢?
Tomcat会为每个Web应用创建一个类加载器实例(WebAppClassLoader),这个加载器重写了loadClass
方法,优先加载当前应用目录下的类,如果当前找不到,才一层一层往上找,这样就实现了Web应用层级的隔离。
不过并不是Web应用程序的所有依赖都需要隔离,比如用到Redis时,Redis可以在Web应用程序之间共享,没必要每个Web应用程序都独自加载一份。因此Tomcat在WebAppClassLoader上加了个父加载器ShareClassLoader,如果WebAppClassLoader没有加载到这个类,就委托给ShareClassLoader去加载。(意思类似于将需要共享的类放到一个共享目录下)
Web应用程序有自己的类,而Tomcat本身也有自己的类,为了隔离这两类,就用CatalinaClassLoader类加载器来进行隔离,CatalinaClassLoader加载Tomcat本身的类。
Tomcat与Web应用程序还有类需要共享,那就再用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器,来加载它们之间的共享类。
Tomcat的加载结构图如下:

JDBC是如何打破双亲委派模型的?
实际上JDBC定义了接口,具体的实现类是由各个厂商进行实现的(比如MySQL)。
类加载有一个规则:如果一个类由类加载器A加载,那么这个类所依赖的类也是由“相同的类加载器”来进行加载的。
在用JDBC的时候,是使用DriverManager获取Connection的,DriverManager在java.sql包下,显然是由BootStrap类加载器进行装载的。当使用DriverManager.getConnection()时,需要得到对应厂商(如Mysql)实现的类。这里在获取Connection的时候,是使用“线程上下文加载器”去加载Connection的,线程上下文加载器会直接指定对应的加载器去加载。也就是说,在BootStrap类加载器利用“线程上下文加载器”指定了对应的类的加载器去加载。

什么是线程上下文加载器?
Java提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的SPI有JDBC。
这些SPI的接口由Java核心库提供,而这些SPI的实现代码则作为Java应用所依赖的jar包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。问题在于,SPI的接口是Java核心库的一部分,由启动类加载器加载;SPI的实现类由系统类加载器加载。启动类加载器无法找到SPI的实现类,因为它只加载Java的核心库,也不能委派给系统类加载器,因为它是系统类加载器的祖先类加载器。
线程上下文类加载器正好解决了这个问题。如果不做任何设置,Java应用的线程的上下文类加载器默认就是系统上下文类加载器。在SPI接口的代码中使用线程上下文类加载器,就可以成功加载到SPI实现的类。线程上下文类加载器在很多SPI的实现中都会用到。
线程上下文加载器的一般使用模式(获取 - 使用 - 还原):
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
try {
// 设置线程上下文类加载器为自定义的加载器
Thread.currentThread().setContextClassLoader(targetTccl);
myMethod(); // 执行自定义的方法
} finally {
// 还原线程上下文类加载器
Thread.currentThread().setContextClassLoader(classLoader);
}
能否用自定义类加载器加载java.lang.String?
很多人存在误区,认为双亲委派机制不能被打破,不能用自定义类加载器加载java.lang.String。但实际上并非如此,只要重写ClassLoader的loadClass()
方法就可以打破。
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
public class MyClassLoader extends URLClassLoader {
public MyClassLoader(URL[] urls) {
super(urls);
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 只对MyClassLoader和String使用自定义的加载,其他的还是走双亲委派
if (name.equals("MyClassLoader") || name.equals("java.lang.String")) {
return super.findClass(name);
} else {
return getParent().loadClass(name);
}
}
public static void main(String[] args) throws Exception {
// urls指定自定义类加载器的加载路径
URL url = new File("J:/apps/demo/target/classes/").toURI().toURL();
URL url3 = new File("C:/Program Files/Java/jdk1.8.0_191/jre/lib/rt.jar").toURI().toURL();
URL[] urls = {
url,
url3
};
MyClassLoader myClassLoader = new MyClassLoader(urls);
Class<?> c1 = MyClassLoader.class.getClassLoader().loadClass("MyClassLoader");
Class<?> c2 = myClassLoader.loadClass("MyClassLoader");
System.out.println(c1 == c2); // false
System.out.println(c1.getClassLoader()); // AppClassLoader
System.out.println(c2.getClassLoader()); // MyClassLoader
System.out.println(myClassLoader.loadClass("java.lang.String")); // Exception
}
}
加载同一个类MyClassLoader,使用的类加载器不同,说明打破了双亲委派机制,但是尝试加载String类时报错。
看代码是ClassLoader类里面的限制,只要加载java开头的包就会报错。所以真正原因是JVM安全机制,而不是因为双亲委派。
既然是ClassLoader里面的代码做的限制,那把ClassLoader.class修改了不就好了吗。
写了个java.lang.ClassLoader,把preDefineClass()方法里那段if直接删掉,再用编译后的class替换rt.jar里面的,直接通过命令jar uvf rt.jar java/lang/ClassLoader/class即可。
不过事与愿违,修改之后还是报错:
Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at com.example.demo.mini.test.MyClassLoader.loadClass(MyClassLoader.java:17)
at com.example.demo.mini.test.MyClassLoader.main(MyClassLoader.java:31)
仔细看报错和之前的不一样了,这次是native方法报错了。这就比较难整了,看来要自己重新编译个JVM才行了。理论上来说,编译JVM的时候把校验的代码去掉就行了。
结论:自定义类加载器加载java.lang.String,必须修改jdk的源码,自己重新编译个JVM才行。