前提
最近在做一个基础组件项目刚好需要用到 JDK 中的资源加载, 这里说到的资源包括类文件和其他静态资源, 刚好需要重新补充一下类加载器和资源加载的相关知识, 整理成一篇文章.
理解类的工作原理
这一节主要分析类加载器和双亲委派模型.
什么是类加载器
虚拟机设计团队把类加载阶段中的 "通过一个类的全限定名来获取描述此类的二进制字节流" 这个动作放到了 Java 虚拟机外部实现, 以便让应用程序自己决定如何去获取所需要的类, 而实现这个动作的代码模块称为 "类加载器(ClassLoader)".
类加载器虽然只用于实现类加载的功能, 但是它在 Java 程序中起到的作用不局限于类加载阶段. 对于任意一个类, 都需要由加载它的类加载器和这个类本身一同确立类在 Java 虚拟机中的唯一性, 每一个类加载器, 都拥有一个独立的类命名空间. 上面这句话直观来说就是: 比较两个类是否 "相等", 只有在这两个类是由同一个类加载器加载的前提下才有意义, 否则, 即使这个两个类是来源于同一个 Class 文件, 被同一个虚拟机加载, 只要加载它们的类加载器不同, 那么这两个类必然 "不相等". 这里说到的 "相等" 包括代表类的 Class 对象的 equals()方法, isAssignableFrom()方法, isInstance()方法的返回结果, 也包括使用 instanceOf 关键字做对象所属关系判定等情况.
类和加载它的类加载器确定类在 Java 虚拟机中的唯一性这个特点为后来出现的热更新类, 热部署等技术提供了基础.
双亲委派模型
从 Java 虚拟机的角度来看, 只有两种不同的类加载器:
1, 第一种是启动类加载器(Bootstrap ClassLoader), 这个类加载器使用 C++ 编程语言实现, 是虚拟机的一部分.
2, 另一种是其他的类加载器, 这些类加载器都是由 Java 语言实现, 独立于虚拟机之外, 一般就是内部于 JDK 中, 它们都继承自抽象类加载器 java.lang.ClassLoader.
JDK 中提供几个系统级别的类加载器:
1, 启动类加载器 (Bootstrap ClassLoader): 这个类加载器负责将存放在 ${JAVA_HONE}\lib 目录中, 或者被 XbootstrapPath 参数所指定的目录中, 并且是虚拟机基于一定规则(如文件名称规则, 如 rt.jar) 标识的类库加载到虚拟机内存中. 启动类加载器无法被 Java 程序直接引用, 开发者在编写自定义类加载器如果想委派到启动类加载器只需直接使用 null 替代即可.
2, 扩展类加载器(Extension ClassLoader): 这个类加载器由 sun.misc.Launcher 的静态内部类 ExtClassLoader 实现, 它负责加载 ${JAVA_HONE}\lib\ext 目录中, 或者通过 java.ext.dirs 系统变量指定的路径中的所有类库, 开发者可以直接使用此类加载器.
3, 应用程序类加载器(Application ClassLoader): 这个类加载器由 sun.misc.Launcher 的静态内部类 AppClassLoader 实现, 但是由于这个类加载器的实例是 ClassLoader 中静态方法
getSystemClassLoader()
中的返回值, 一般也称它为系统类加载器. 它负责加载用户类路径 (ClassPath) 上所指定的类库, 开发者可以直接使用这个类加载器, 如果应用程序中没有自定义过自实现的类加载器, 一般情况下这个系统类加载器就是应用程序中默认使用的类加载器.
4, 线程上下文类加载器(Thread Context ClassLoader): 这个在下一小节 "破坏双亲委派模型" 再分析.
Java 开发者开发出来的 Java 应用程序都是由上面四种类加载器相互配合进行类加载的, 如果有必要还可以加入自定义的类加载器. 其中, 启动类加载器, 扩展类加载器, 应用程序类加载器和自定义类加载器之间存在着一定的关系:
上图展示的类加载器之间的层次关系称为双亲委派模型 (Parents Delegation Model). 双亲委派模型要求除了顶层的类加载器(Java 中顶层的类加载器一般是 Bootstrap ClassLoader), 其他的类加载器都应当有自己的父类加载器. 这些类加载器之间的父子关系一般不会以继承(Inheritance) 的关系来实现, 而是通过组合 (Composition) 的关系实现. 类加载器层次关系这一点可以通过下面的代码验证一下:
- public class Main {
- public static void main(String[] args) throws Exception{
- ClassLoader classLoader = Main.class.getClassLoader();
- System.out.println(classLoader);
- System.out.println(classLoader.getParent());
- System.out.println(classLoader.getParent().getParent());
- }
- }
- // 输出结果, 最后的 null 说明是 Bootstrap ClassLoader
- sun.misc.Launcher$AppClassLoader@18b4aac2
- sun.misc.Launcher$ExtClassLoader@4629104a
- null
双亲委派模型的工作机制: 如果一个类加载器收到了类加载的请求, 它首先不会自己尝试去加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的类加载请求最终都应该传送到顶层的类加载器中, 只有当父类加载器反馈自己无法完成当前的类加载请求的时候(也就是在它的搜索范围中没有找到所需要的类), 子类加载器才会尝试自己去加载类. 不过这里有一点需要注意, 每一个类加载器都会缓存已经加载过的类, 也就是重复加载一个已经存在的类, 那么就会从已经加载的缓存中加载, 如果从当前类加载的缓存中判断类已经加载过, 那么直接返回, 否则会委派类加载请求到父类加载器. 这个缓存机制在 AppClassLoader 和 ExtensionClassLoader 中都存在, 至于 BootstrapClassLoader 未知.
双亲委派模型的优势: 使用双亲委派模型来组织类加载器之间的关系, 一个比较显著的优点是 Java 类随着加载它的类加载器一起具备了一种带有优先级的层次关系. 例如 java.lang 包中的类库, 它存放在 rt.jar 中, 无论使用哪一个类加载加载 java.lang 包中的类, 最终都是委派给处于模型顶层的启动类加载器进行加载, 因此 java.lang 包中的类如 java.lang.Object 类在应用程序中的各类加载器环境中加载的都是同一个类. 试想, 如果可以使用用户自定义的 ClassLoader 去加载 java.lang.Object, 那么用户应用程序中就会出现多个 java.lang.Object 类, Java 类型体系中最基础的类型也有多个, 类型体系的基础行为无法保证, 应用程序也会趋于混乱. 如果尝试编写 rt.jar 中已经存在的同类名的类通过自定义的类加载进行加载, 将会接收到虚拟机抛出的异常.
双亲委派模型的实现: 类加载器双亲委派模型的实现提现在 ClassLoader 的源码中, 主要是 ClassLoader#loadClass()中.
- public Class<?> loadClass(String name) throws ClassNotFoundException {
- return loadClass(name, false);
- }
- protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
- synchronized (getClassLoadingLock(name)) {
- // First, check if the class has already been loaded
- Class<?> c = findLoadedClass(name);
- if (c == null) {
- long t0 = System.nanoTime();
- try {
- // 父加载器不为 null, 说明父加载器不是 BootstrapClassLoader
- if (parent != null) {
- c = parent.loadClass(name, false);
- } else {
- // 父加载器为 null, 说明父加载器是 BootstrapClassLoader
- c = findBootstrapClassOrNull(name);
- }
- } catch (ClassNotFoundException e) {
- // ClassNotFoundException thrown if class not found
- // from the non-null parent class loader
- }
- // 所有的父加载加载失败, 则使用当前的类加载器进行类加载
- if (c == null) {
- // If still not found, then invoke findClass in order
- // to find the class.
- 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;
- }
- }
破坏双亲委派模型
双亲委派模型在 Java 发展历史上出现了三次比较大 "被破坏" 的情况:
1,ClassLoader 在 JDK1.0 已经存在, JDK1.2 为了引入双亲委派模型并且需要向前兼容, java.lang.ClassLoader 类添加了一个新的 protected 的 findClass()方法, 在这之前, 用户去继承 java.lang.ClassLoader 只能重写其 loadClass()方法才能实现自己的目标.
2, 双亲委派模型自身存在缺陷: 双亲委派很好地解决了各个类加载器的基础类的加载的统一问题 (越基础的类由越上层的类加载器加载), 这些所谓的基础类就是大多数情况下作为用户调用的基础类库和基础 API, 但是无法解决这些基础类需要回调用户的代码这一个问题, 典型的例子就是 JNDI.JNDI 的类库代码是启动类加载器加载的, 但是它需要调用独立厂商实现并且部署在应用的 ClassPath 的 JNDI 的服务接口提供者(SPI, 即是 Service Provider Interface) 的代码, 但是启动类加载器无法加载 ClassPath 下的类库. 为了解决这个问题, Java 设计团队引入了不优雅的设计: 线程上下文类加载器 (Thread Context ClassLoader), 这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 设置, 这样子, JNDI 服务就可以使用线程上下文类加载器去加载所需的 SPI 类库, 但是父类加载器中请求子类加载器去加载类这一点已经打破了双亲委派模型. 目前, JNDI,JDBC,JCE,JAXB 和 JBI 等模块都是通过此方式实现.
3, 基于用户对应用程序动态性的热切追求: 如代码热替换(HotSwap), 热模块部署等, 说白了就是希望应用程序能像我们的计算机外设那样可以热插拔, 因此催生出 JSR-291 以及它的业界实现 OSGi, 而 OSGi 定制了自己的类加载规则, 不再遵循双亲委派模型, 因此它可以通过自定义的类加载器机制轻易实现模块的热部署.
JDK 中提供的资源加载 API
前边花大量的篇幅去分析类加载器的预热知识, 是因为 JDK 中的资源加载依赖于类加载器 (其实类文件本来就是资源文件的一种, 类加载的过程也是资源加载的过程). 这里先列举出 JDK 中目前常用的资源(Resource) 加载的 API, 先看 ClassLoader 中提供的方法.
ClassLoader 提供的资源加载 API
- //1. 实例方法
- public URL getResource(String name)
- // 这个方法仅仅是调用 getResource(String name)返回 URL 实例直接调用 URL 实例的 openStream()方法
- public InputStream getResourceAsStream(String name)
- // 这个方法是 getResource(String name)方法的复数版本
- public Enumeration<URL> getResources(String name) throws IOException
- //2. 静态方法
- public static URL getSystemResource(String name)
- // 这个方法仅仅是调用 getSystemResource(String name)返回 URL 实例直接调用 URL 实例的 openStream()方法
- public static InputStream getSystemResourceAsStream(String name)
- // 这个方法是 getSystemResources(String name)方法的复数版本
- public static Enumeration<URL> getSystemResources(String name)
总的来看, 只有两个方法需要分析: getResource(String name)和 getSystemResource(String name). 查看 getResource(String name)的源码:
- public URL getResource(String name) {
- URL url;
- if (parent != null) {
- url = parent.getResource(name);
- } else {
- url = getBootstrapResource(name);
- }
- if (url == null) {
- url = findResource(name);
- }
- return url;
- }
是否似曾相识? 这里明显就是使用了类加载过程中类似的双亲委派模型进行资源加载, 这个方法在 API 注释中描述通常用于加载数据资源如 images,audio,text 等等, 资源名称需要使用路径分隔符'/'.getResource(String name)方法中查找的根路径我们可以通过下面方法验证:
- public class ResourceLoader {
- public static void main(String[] args) throws Exception {
- ClassLoader classLoader = ResourceLoader.class.getClassLoader();
- URL resource = classLoader.getResource("");
- System.out.println(resource);
- }
- }
- // 输出: file:/D:/Projects/rxjava-seed/target/classes/
很明显输出的结果就是当前应用的 ClassPath, 总结来说: ClassLoader#getResource(String name)是基于用户应用程序的 ClassPath 搜索资源, 资源名称必须使用路径分隔符'/'去分隔目录, 但是不能以'/'作为资源名的起始, 也就是不能这样使用: classLoader.getResource("/img/doge.jpg"). 接着我们再看一下 ClassLoader#getSystemResource(String name)的源码:
- public static URL getSystemResource(String name) {
- // 实际上 Application ClassLoader 一般不会为 null
- ClassLoader system = getSystemClassLoader();
- if (system == null) {
- return getBootstrapResource(name);
- }
- return system.getResource(name);
- }
此方法优先使用应用程序类加载器进行资源加载, 如果应用程序类加载器为 null(其实这种情况很少见), 则使用启动类加载器进行资源加载. 如果应用程序类加载器不为 null 的情况下, 它实际上退化为 ClassLoader#getResource(String name)方法.
总结一下: ClassLoader 提供的资源加载的方法中的核心方法是 ClassLoader#getResource(String name), 它是基于用户应用程序的 ClassPath 搜索资源, 遵循 "资源加载的双亲委派模型", 资源名称必须使用路径分隔符'/'去分隔目录, 但是不能以'/'作为资源名的起始字符, 其他几个方法都是基于此方法进行衍生, 添加复数操作等其他操作. getResource(String name)方法不会显示抛出异常, 当资源搜索失败的时候, 会返回 null.
Class 提供的资源加载 API
java.lang.Class 中也提供了资源加载的方法, 如下:
- public java.NET.URL getResource(String name) {
- name = resolveName(name);
- ClassLoader cl = getClassLoader0();
- if (cl==null) {
- // A system class.
- return ClassLoader.getSystemResource(name);
- }
- return cl.getResource(name);
- }
- public InputStream getResourceAsStream(String name) {
- name = resolveName(name);
- ClassLoader cl = getClassLoader0();
- if (cl==null) {
- // A system class.
- return ClassLoader.getSystemResourceAsStream(name);
- }
- return cl.getResourceAsStream(name);
- }
从上面的源码来看, Class#getResource(String name)和 Class#getResourceAsStream(String name)分别比 ClassLoader#getResource(String name)和 ClassLoader#getResourceAsStream(String name)只多了一步, 就是搜索之前先进行资源名称的预处理 resolveName(name), 我们重点看这个方法做了什么:
- private String resolveName(String name) {
- if (name == null) {
- return name;
- }
- if (!name.startsWith("/")) {
- Class<?> c = this;
- while (c.isArray()) {
- c = c.getComponentType();
- }
- String baseName = c.getName();
- int index = baseName.lastIndexOf('.');
- if (index != -1) {
- name = baseName.substring(0, index).replace('.', '/')
- +"/"+name;
- }
- } else {
- name = name.substring(1);
- }
- return name;
- }
逻辑相对比较简单:
1, 如果资源名称以'/'开头, 那么直接去掉'/', 这个时候的资源查找实际上退化为 ClassPath 中的资源查找.
2, 如果资源名称不以'/'开头, 那么解析出当前类的实际类型(因为当前类有可能是数组), 取出类型的包路径, 替换包路径中的'.'为'/', 再拼接原来的资源名称. 举个例子:"club.throwable.Main.class" 中调用了
Main.class.getResource("doge.jpg")
, 那么这个调用的处理资源名称的结果就是
- club/throwable/doge.jpg
- .
小结: 如果看过我之前写过的一篇 URL 和 URI 相关的文章就清楚, 实际上 Class#getResource(String name)和 Class#getResourceAsStream(String name)的资源名称处理类似于相对 URL 的处理, 而 "相对 URL 的处理" 的根路径就是应用程序的 ClassPath. 如果资源名称以'/'开头, 那么相当于从 ClassPath 中加载资源, 如果资源名称不以'/'开头, 那么相当于基于当前类的实际类型的包目录下加载资源.
实际上类似这样的资源加载方式在 File 类中也存在, 这里就不再展开.
小结
理解 JDK 中的资源加载方式有助于编写一些通用的基础组件, 像 Spring 里面的 ResourceLoader,ClassPathResource 这里比较实用的工具也是基于 JDK 资源加载的方式编写出来. 下一篇博文《浅析 JDK 中 ServiceLoader 的源码》中的主角 ServiceLoader 就是基于类加载器的功能实现, 它也是 SPI 中的服务类加载的核心类.
说实话, 类加载器的 "双亲委派模型" 和 "破坏双亲委派模型" 是常见的面试题相关内容, 这里可以简单列举两个面试题:
1, 谈谈对类加载器的 "双亲委派模型" 的理解.
2, 为什么要引入线程上下文类加载器(或者是对于问题 1 有打破这个模型的案例吗)?
希望这篇文章能帮助你理解和解决这两个问题.
来源: https://www.cnblogs.com/throwable/p/9785944.html