类加载器
Java虚拟机通过类加载器实现了使用一个类的全限定名称读取类的二进制字节流的功能,以便让应用程序自己决定如何去获取所需的类,可以将实现这个动作的代码称为”类加载器”(Class Loader)。类加载器可以说是Java语言的一项创新,使得类加载脱离了依靠虚拟机本身,它是早期Java语言能够快速流行的重要原因之一。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
查看下面测试代码:
package com.starsray.test;
import java.io.IOException;
import java.io.InputStream;
/**
* 测试类装入器
*
* @author starsray
* @date 2022/04/25
*/
public class TestClassLoader {
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
ClassLoader loader = new ClassLoader() {
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream inputStream = getClass().getResourceAsStream(fileName);
if (inputStream == null) {
return super.loadClass(name, resolve);
}
byte[] bytes = new byte[inputStream.available()];
inputStream.read(bytes);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundException(name);
}
}
};
Object newInstance = loader.loadClass("com.starsray.test.TestClassLoader").newInstance();
System.out.println(newInstance.getClass());
System.out.println("instanceof result:" + (newInstance instanceof TestClassLoader));
}
}
输出结果:
class com.starsray.test.TestClassLoader
instanceof result:false
虽然是同一个类,但是由不同的类加载器来加载,因此使用类型判断的时候,两者并不是同一个类。
Java中提供了自定义类加载器的功能,也有自身的类加载器,从Java虚拟机角度出发,只有两种类加载器,一种是由C++编写的启动类加载器(Bootstrap ClassLoader),一种是其他类加载器,这些类加载器由Java语言自身实现,独立于Java虚拟机,全部继承自抽象类java.lang.ClassLoader。
双亲委派
通常所说的双亲委派模型中,是对由Java语言实现的类加载器进行了更细致的划分。在类加载中,绝大多数应用都使用了三个由Java提供的类加载器:
启动类加载器(Bootstrap Class Loader):由Java虚拟机负责的加载器,主要用来加载jre/lib目录中所包含的类,或者是被参数-Xbootclasspath指定目录的类,如常用的rt.jar、tools.jar。名字不符合的类即使在该目录也不会被加载,用户自定义类加载器如果需要把加载请求委托给引导类加载器直接指定null即可。
/**
* Returns the class loader for the class. Some implementations may use
* null to represent the bootstrap class loader. This method will return
* null in such implementations if this class was loaded by the bootstrap
* class loader.
*
* If a security manager is present, and the caller's class loader is
* not null and the caller's class loader is not the same as or an ancestor of
* the class loader for the class whose class loader is requested, then
* this method calls the security manager's {@code checkPermission}
* method with a {@code RuntimePermission("getClassLoader")}
* permission to ensure it's ok to access the class loader for the class.
* ......
*/
@CallerSensitive
public ClassLoader getClassLoader() {
ClassLoader cl = getClassLoader0();
if (cl == null)
return null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
}
return cl;
}
扩展类加载器(Extension Class Loader):这个类加载器在类sun.misc.Launcher$ExtClassLoader中以代码实现的。负责加载jre\lib\ext目录中的类,或者被java.ext.dirs环境变量所指定的目录的类。主要用来加载由用户开发具有通用扩展性质的类,以扩展JavaSE的功能,在JDK9之后提供了模块化的功能,这种加载扩展类的机制被以更好的方式所代替。
- 应用程序类加载器(Application Class Loader):这个类加载器在sun.misc.Launcher$AppClassLoader中以Java代码来实现。主要用来加载用户类路径(ClassPath)上的所有类库,如果用户没有自定义类加载器,一般就使用的默认的类加载器。
模型
JDK1.2开始引入双亲委派模型,并被广泛应用于后续的版本中。所谓双亲委派模型,如下图所示,各种类加载器之间的层次关系被称为双亲委派模型(Parents Delegation Model),在这个模型中除了启动类加载器外,要求所有的类加载器都有应该有自己的父类加载器,这个父类并不同于Java中继承关系,也没有所谓的父子关系,只是相对的层级关系。通过组合关系来复用父类加载器的代码,强调的是一种协作关系。
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,以用户类加载器为例,它不会直接进行类加载,而是把这个请求交给父类加载器,当父类加载器无法完成请求(在自己的加载缓存中未查找到该类),会继续请求父类加载器,最终都会传递到启动类加载器。如果在启动类加载器未检索到对应缓存,会向下逐级按照路径进行尝试加载。
概括来说双亲委派体现了一种自下而上请求,自上而下委派加载的机制。看似繁琐,其实这样做有很大的好处,其中最明显的优势就是Java中的类随着加载它的类加载器具备了一种带有优先级的层次关系,例如:在rt.jar中的java.lang.String类,无论哪一个类加载器触发加载请求最终都会由启动类加载器完成该类的加载,这也能保证在整个在整个环境中使用的是同一个String类,如果没有这种机制,每个类加载维护了一个自己的类名称空间又相互独立,会使得程序代码一片混乱,类体系的最基础行为就无法保证。双亲委派模型对于保障Java程序运行环境的稳定性极为重要,此外这种机制也一定程度保障了类加载时的安全,防止恶意代码入侵。
Java双亲委派的意义重大,其实现代码也清晰明了,主要在ClassLoader的loadClass方法中实现:
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) {
// 如果父类加载器抛出ClassNotFoundException异常found
// 说明父类无法完成类加载请求
}
if (c == null) {
// 如果父类加载器无法加载,调用自身的findClass进行加载
long t1 = System.nanoTime();
c = findClass(name);
// 这些已定义类加载器,记录统计
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方法,如果父类加载器返回空默认使用启动类加载器作为父类加载器。如果父类加载器加载失败,抛出ClassNotFoundException,然后调用findClass方法尝试进行加载。
破坏双亲委派
双亲委派模型并不是一种强制约束,而是一种推荐的加载模型。在某些应用场景下,通过某种手段是可以打破双亲委派模型的约束,做个性化扩展的。
在Java发展过程中,双亲委派模型经历过三次被破坏事件,这里说的破坏并不是指一种贬义或者不好的概念,而是设计者们为双亲委派模型做出的一种妥协,解决历史遗留问题,或者不得不打破约束的场景。
- 历史遗留问题,已存在代码兼容。
此次破坏的场景出现在双亲委派模型出现之前的JDK1.2,因此也不算是打破模型,因为这个模型本身就不存在。但是为了兼容原有代码,这也没后面的打破提供了实现的可能性。类加载器和抽象类java.lang.ClassLoader在早期JDK1.2之前就已经存在,针对已经存在的用户类加载器代码,设计者在引入双亲委派模型约束后做了一些妥协,为了兼容性考虑,无法再以技术手段避免loadClass被子类覆盖,因此在ClassLoader中添加了protected方法findClass,并引导用户在编写类加载逻辑时尽可能的重写此方法,而不是直接覆写loadClass加载。这样做的好处是如果loadClass失败会调用重写的findClass方法,即不影响用户的加载意愿,又不破坏双亲委派模型。因为双亲委派模型的逻辑都在loadClass方法中,如果直接重写那就意味着破坏双亲委派模型。
- 打破模型自身缺陷,基于SPI机制,方便加载第三方扩展。
双亲委派模型解决了各类加载器之间的协作关系,越基础的类越由上层类加载器加载,所谓基础指的是他们总是被用户代码继承作为API调用,如果反过来,这些基础类要去加载调用用户代码,那就不能实现,因为在父类加载器加载的指定目录下即使存放了用户代码,也不会被加载,那这种情况下就需要强制打破约束。
其中一个典型的应用场景就是在JDK1.3中出现的JNDI,作为Java的标准服务,其实现代码在rt.jar中,由启动类加载器加载。但是JNDI存在目的就是对资源进行查找和集中管理,需要加载由第三方厂商提供的并部署在ClassPath下的JNDI服务提供者接口(Service Provider Interface,即SPI),这种情况下这些类又不能被启动类加载器加载。为了解决这种问题,Java设计团队引入了线程上下文类加载器(Thread Context ClassLoader),这个类加载器通过setContextClassLoader方法来设置类加载器,如果创建线程时没有设置该参数,会从父线程继承,如果全局范围都没有设置,这个类加载器就默认是应用程序类加载器。使用线程上下文类加载器,JDNI服务就可以去加载所需要的服务类资源,这是一种父类加载器请求子类加载器完成类加载的行为,实际上打通了双亲委派模型的层次结构进行逆向加载,已经违背了双亲委派模型的一般性原则。Java中涉及到SPI机制的如JDBC、JCE、JAXB、JBI都使用这种方式来加载。
说明:JNDI在JDK1.3中引入,setContextClassLoader方法在JDK1.2中引入,setContextClassLoader不是为了解决JNDI等问题而引入,Java设计团队在提供双亲委派模型时就考虑到了其弊端,只是在JNDI采用了这种方式来解决问题。
- 动态加载追求,支持热加载、热部署等特性。
这次破坏旨在用户为了追求动态性,希望Java程序可以支持热操作,热插拔特性,如U盘、鼠标等即插即用。这其中就包括IBM提供的OSGI以及Oracle在JDK9之后模块化中提供的Jigsaw项目。
案例
通过一些实际的应用场景来进一步说明为什么要打破双亲委派模型以及其必要性。
Context ClassLoader
这里继续探讨关于Thread Context Loader的问题,首先再明确以下几点知识点:
- 每个ClassLoader都只能加载自己所绑定目录下的资源
- 加载资源时的ClassLoader可以有多种选择
- SystemClassLoader(通过ClassLoader中getSystemClassLoader方法获得)
- 当前ClassLoader(加载当前类的类加载器)
- Context Loader(通过Thread类中的getContextClassLoader方法获得)
- 自定义类加载器(实现ClassLoader中findClass或loadClass方法)
查看Thread类中setContextClassLoader方法源码:
/* The context ClassLoader for this thread */
private ClassLoader contextClassLoader;
public void setContextClassLoader(ClassLoader cl) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("setContextClassLoader"));
}
contextClassLoader = cl;
}
每一个线程都包含了一个ClassLoader类型的私有成员变量contextClassLoader,而setContextClassLoader方法只是提供了一个线程自定义ClassLoader的入口,具体实现由传入的ClassLoader类决定。其中通过安全管理器校验是否允许执行setContextClassLoader方法。
线程上下文加载实际上是通过硬编码的方式,当SPI有多个是,硬编码是一种不优雅的方式,在JDK6中提供了ServiceLoader加载固定文件META-INF/services中的配置信息,优雅的结局了硬编码的问题,但是这种也方式也有它的缺陷,会加载配置中的所有服务资源,SpringBoot中基于按需加载的自动装配的方式也是基于SPI思想,但是其在实现方式上就显得更为合理、高效。
Tomcat
对于常见的Web服务器,如Tomcat、WebLogic等,在部署应用时都需要解决相同的问题
- 部署在同一个服务器上的多个Web应用程序,当依赖相同第三方类库的不同版本时,不能要求每个类库在同一服务器中只有一份,服务器应当保证两个独立应用程序的类库独立使用,实现相互隔离。但是对于相同资源可以共享的类库,又没必要进行隔离,相同资源互相隔离,是很大的资源浪费,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
- 主流的Java Web服务器自身也是使用Java语言来实现的,服务器本身也有类库依赖的问题。服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
- 支持JSP应用的Web服务器,在视图解析阶段JSP文件最终会被翻译为Class文件执行,支持热加载,就不需要因为页面视图的修改就重启服务器,因此但基于Class文件运行的JSP应用服务器就需要支持HotSwap功能。主流的Web服务器都会支持JSP生成类的热替换,也有特殊场景,如运行在生产模式下的WebLogic服务器默认就不会处理JSP文件的变化。
基于以上应用场景来说,Web服务器都需要打破双亲委派模型的限制,而这种模型本身就是一种规范,Java设计团队在设计ClassLoader之初就考虑过用户自定义进行类加载的场景,因此可扩展就很要必要。
在Tomcat中定义了不同的目录空间,供不同的类加载器来加载资源:
- /common目录:类库可被Tomcat和所有的Web应用程序共同使用
- /server目录:类库可被Tomcat使用,对所有的Web应用程序都不可见
- /shared目录:类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见
- /WebApp/WEB-INF目录:类库仅可被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见
根据这一套目录规则,Tomcat有多个自定义类加载器来加载不同目录的资源,如下图所示,在JDK默认的类加载器之下,分别定义了CatalinaClassLoader、SharedClassLoader、WebAppClassLoader、以及JasperLoader:
总结
想要理解双亲委派模型、为什么要打破双亲委派模型以及常见Web服务器打破模型的方式,首先需要深入理解类加载器的职责,每一种类加载器的职能范围只会加载与自身目录绑定的类,严格的常见会进行安全签名匹配,比如启动类加载器并不会在绑定目录加载不匹配的类。
所谓双亲委派模型,定义的是一种规范,并不是强制限制,其核心思想:
- 每种类加载器只能加载其绑定目录范围内的类
- 每种类加载器加载类时都会去请求父类检索缓存,如果不存在则有自己去尝试加载。
只要违背以上两种原则之一即视为打破双亲委派模型:
- 例如JNDI服务通过SPI机制由父类加载器委托子类去加载,这就打破了双亲委派模型
- Tomcat服务器对相同类加载时进行资源隔离,也是通过打破双亲委派模型去实现
说明:打破双亲委派模型的手段就是自定义类加载器实现ClassLoader抽象类,重写loadClass方法,而findClass方法只是一种在重写类加载器且不打破双亲委派模型的一种兼容策略。
附:以上所说的双亲委派模型基于JDK8版本,在JDK9中,基于模块化的方案后,已经调整为如下模型:
参考文档:
- 深入理解Java虚拟机第三版
- https://docs.oracle.com/javase/specs/index.html
- JDK 8 ClassLoader、Thread-setContextClassLoader源码