前提

最近在做一个基础组件项目刚好需要用到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应用程序都是由上面四种类加载器相互配合进行类加载的,如果有必要还可以加入自定义的类加载器。其中,启动类加载器、扩展类加载器、应用程序类加载器和自定义类加载器之间存在着一定的关系:

r-l-1

上图展示的类加载器之间的层次关系称为双亲委派模型(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未知。

r-l-2

双亲委派模型的优势:使用双亲委派模型来组织类加载器之间的关系,一个比较显著的优点是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有打破这个模型的案例吗)?

希望这篇文章能帮助你理解和解决这两个问题。

参考资料:

  • 《深入理解Java虚拟机第二版》
  • JavaSE-8源码

(本文完 c-1-d e-20181014)