类加载器与 Class.getResourceAsStream 问题解决

    • 一源码分析
    • 二类加载器ClassLoader
      • 1 双亲委派模型
      • 2 Java 中的类加载器
    • 三Tomcat 类加载器架构
    • 四解决 ObjectclassgetResourceAsStream 问题
    • 六总结
    • 七参考

最近遇到一个问题:tomcat 服务器中通过 Object.class.getResourceAsStream("ss.properties") 加载 webapps 下某 webapp 中某个文件,服务器上可以正常加载,本地运行却不能正确加载,提示找不到文件

在接下来排查问题的过程中,发现服务器和本地的配置不同:

  • 服务器:通过 tomcat 的 bin/setclasspath.sh 脚本将 ss.properties 所在的目录设置为了 classpath;
  • 本地:将 ss.properties 放置在了 webapps/webapp1/WEB-INF/classes 文件夹下,也就是 WAR 文件格式规定的 classpath 目录之一。

为什么这样配置的不同,就导致了文件不能被正确加载。

一、源码分析

为了找出原因,我们第一步看 Object.class.getResourceAsStream("ss.properties") 的源码:

// Class.java
public InputStream getResourceAsStream(String name) {
    name = resolveName(name);
    ClassLoader cl = getClassLoader0();// 获取加载该Class的ClassLoader
    if (cl==null) {
        // A system class.
        return ClassLoader.getSystemResourceAsStream(name);
    }
    return cl.getResourceAsStream(name);
}

从 javadoc 文档和源码中可以看出:

  1. Class.getResourceAsStream 代理给了加载该 class 的 ClassLoader 去实现,调用 ClassLoader.getResourceAsStream
  2. 如果该类的 ClassLoader 为 null,说明该 class 一个系统 class,所以委托给 ClassLoader.getSystemResourceAsStream

通过源码的分析,可以看出来加载资源的动作和该类的类加载器有关,所以下面我们需要介绍什么是类加载器。

二、类加载器(ClassLoader)

我们都知道 Java 文件被运行,第一步,需要通过 javac 编译器编译为 class 文件;第二步,JVM 运行 class 文件,实现跨平台。而 JVM 虚拟机第一步肯定是 加载 class 文件,所以,类加载器实现的就是(来自《深入理解Java虚拟机》):

通过一个类的全限定名来获取描述此类的二进制字节流

类加载器有几个重要的特性:

  1. 每个类加载器都有自己的预定义的搜索范围,用来加载 class 文件;
  2. 每个类和加载它的类加载器共同确定了这个类的唯一性,也就是说如果一个 class 文件被不同的类加载器加载到了 JVM 中,那么这两个类就是不同的类,虽然他们都来自同一份 class 文件;
  3. 双亲委派模型。

2.1 双亲委派模型

  1. 所有的类加载器都是有层级结构的,每个类加载器都有一个父类类加载器(通过组合实现,而不是继承),除了启动类加载器(Bootstrap ClassLoader)
  2. 当一个类加载器接收到一个类加载请求时,首先将这个请求委派给它的父加载器去加载,所以每个类加载请求最终都会传递到顶层的启动类加载器,如果父加载器无法加载时,子类加载器才会去尝试自己去加载;

通过双亲委派模型就实现了类加载器的三个特性:

  1. 委派(delegation):子类加载器委派给父类加载器加载;
  2. 可见性(visibility):子类加载器可访问父类加载器加载的类,父类不能访问子类加载器加载的类;
  3. 唯一性(uniqueness):可保证每个类只被加载一次,比如 Object 类是被 Bootstrap ClassLoader 加载的,因为有了双亲委派模型,所有的 Object 类加载请求都委派到了 Bootstrap ClassLoader,所以保证了只被加载一次。

以上就是类加载器的一些特性,那么在 Java 中类加载器是如何实现的呢?

2.2 Java 中的类加载器

从 JVM 虚拟机的角度来看,只存在两种不同的类加载器:

  1. 启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分;
  2. 所有其他的类加载器,独立于虚拟机外部,都继承自抽象类 java.lang.ClassLoader

而绝大多数 Java 应用都会用到如下 3 中系统提供的类加载器:

  1. 启动类加载器(Bootstrap/Primordial/NULL ClassLoader):顶层的类加载器,没有父类加载器。负责加载 /lib 目录下的,或则被 -Xbootclasspath 参数所指定路径中的,并被 JVM 识别的(仅按文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录也不会被加载)类库加载到虚拟机内存中。所有被 Bootstrap classloader 加载的类,它的 Class.getClassLoader 方法返回的都是 null,所以也称作 NULL ClassLoader。
  2. 扩展类加载器(Extension CLassLoader):由 sun.misc.Launcher$ExtClassLoader 实现,负责加载 /lib/ext 目录下,或被 java.ext.dirs 系统变量所指定的目录下的所有类库;
  3. 应用程序类加载器(Application/System ClassLoader):由 sun.misc.Launcher$AppClassLoader 实现。它是 ClassLoader.getSystemClassLoader() 方法的默认返回值,所以也称为系统类加载器(System ClassLoader)。它负责加载 classpath 下所指定的类库,如果应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

如下,就是 Java 程序中的类加载器层级结构图:

类加载器与 Class.getResourceAsStream 问题解决_第1张图片

以上,我们介绍了 Java 系统的类加载器,但是我们的应用是运行在 tomcat 中的,那么我们当然也应该探究 tomcat 是如何加载类的。

三、Tomcat 类加载器架构

根据 Class Loader HOW-TO 中的描述,tomcat7 主要有如下的类加载器层级:

      Bootstrap
          |
      Extension
          |
       System
          |
       Common
       /     \
  Webapp1   Webapp2 ...

从图中可以看出,除了系统类加载器(Bootstrap、Extension、System),tomcat 还自定义了自己的类加载器(Common、Webapp等)。

  1. BootstrapExtension:和前面介绍 Java 系统类加载器一样,这里不再赘述;
  2. System:从 CLASSPATH 系统变量指定的目录中加载类库。该加载器加载的类对 tomcat 本身和 web 应用都可见。但是,标准的 tomcat 启动脚本($CATALINA_HOME/bin/catalina.sh or %CATALINA_HOME%\bin\catalina.bat)都会忽略系统变量 CLASSPATH 的值,而会使用如下的类库来创建 System 类加载器(setclasspath 脚本设置的 CLASSPATH 变量对 tomcat 有用):
    • $CATALINA_HOME/bin/bootstrap.jar
    • $CATALINA_BASE/bin/tomcat-juli.jar 或 $CATALINA_HOME/bin/tomcat-juli.jar
    • $CATALINA_HOME/bin/commons-daemon.jar
  3. Common:通过该类加载器加载的类库可被 Tomcat 和所有的 Web 应用共同使用。该类加载器的搜索位置是通过 $CATALINA_BASE/conf/catalina.properties 文件中的 common.loader 属性指定的,默认包括如下位置:
    • $CATALINA_BASE/lib 下未打包的类和资源;
    • $CATALINA_BASE/lib 下的 jar 包;
    • $CATALINA_HOME/lib 下未打包的类和资源;
    • $CATALINA_HOME/lib 下的 jar 包。
  4. WebappX:每个 Web 应用自己的类加载器,能够加载 /WEB-INF/classes/WEB-INF/lib 下的类和资源。能够被此 Web 应用使用,但对其他 Web 应用不可见。

对于 WebappX 类加载器,它并不是双亲委派模型的。当 WebappX 类接收到一个类加载请求时,它会先尝试自己去加载,自己不能加载时,再委派给父类加载器。但是,例外就是JRE 相关的类不能被覆盖。除了,WebappX 类加载器,其他的类加载器都符合通常的双亲委派模型

四、解决 Object.class.getResourceAsStream 问题

有了以上有关类加载器的知识,现在应该能够解决为什么配置不同,导致文件不能被正确加载的问题了。

第一步,看源码:

// Class.java
public InputStream getResourceAsStream(String name) {
    name = resolveName(name);
    ClassLoader cl = getClassLoader0();// 获取加载该Class的ClassLoader
    if (cl==null) {
        // A system class.
        return ClassLoader.getSystemResourceAsStream(name);
    }
    return cl.getResourceAsStream(name);
}

// ClassLoader.java
public static InputStream getSystemResourceAsStream(String name) {
    URL url = getSystemResource(name);
    try {
        return url != null ? url.openStream() : null;
    } catch (IOException e) {
        return null;
    }
}

public static URL getSystemResource(String name) {
    ClassLoader system = getSystemClassLoader();// 获取 System ClassLoader
    if (system == null) {
        return getBootstrapResource(name);
    }
    return system.getResource(name);
}

现在,我们来分析整个加载过程:

  1. 获取该类的类加载器,判断是否为 null,为 null 说明是 JRE 相关的类(此处是 Object,所以类加载器是 null),那么委派给 ClassLoader.getSystemResourceAsStream(String)
  2. 获取 System 类加载器,通过 System 类加载器去加载文件;

现在,我们知道问题就出在这个 System 类加载器上:

  • 服务器:通过设置 CLASSPATH 变量,所以 System 类加载器能够找到 ss.properties
  • 本地:本地环境下,ss.properties 最终是放在 Web 应用下的 /WEB-INF/classes 文件夹下,不能被 System 类加载器获取到,所以加载失败。

解决方案:通过 getClass().getResouceAsStream(String) 去加载资源,这样首先就在 WebappX 类加载器中去寻找资源,所以无论如何都能找到。

六、总结

通过解决一个文件加载的问题,学习了 Java 应用的类加载器和 Tomcat 的类加载器架构,了解了加载的底层原理,很有成就感。

在解决该问题的过程中,有几点小心得:

  1. 出现问题时,从源头找问题,比如从源码去看加载的逻辑;
  2. 学会看源码,可能很多问题,一看源码就解决了;
  3. 多 Google,看资料时融汇贯通,比如解决这个问题时,最主要的帮助来自于:1、源码;2、《深入理解 Java 虚拟机》;3、Tomcat ClassLoader 的文档。

七、参考

  1. 《深入理解 Java 虚拟机》
  2. Class Loader HOW-TO

你可能感兴趣的:(JavaCore)