最近工作中有个事情用到了类加载机制,所以想趁这个机会梳理一下这块的东西。

类加载基础

熟悉的话可以跳过本节。

Java 类生命周期如下:

ba3131c6-4bda-4a9b-844e-29be73e582c1

来自 《深入理解 java 虚拟机》Kindle 版图 7-1

编译后的 class 是一组二进制字节流,Java 虚拟机需要将字节流通过加载流程将 class 读入内存、Linking 并初始化后使用。

类的加载需要通过类加载器 Class Loader 完成,相同的 Class 二进制流必须是同一个 Class Loader 加载,得到的 Class 才是相同的,== 判断才能通过。使用两个不同的 Class Loader 实例去加载相同的 Class 二进制流得到的 Class 实例也无法通过 == 判断。

上面每个环节具体会做什么事情在The Java VirtualMachine SpecificationJava SE 8 Edition有详细说明。

介绍类加载的文章多如牛毛,还有很多:

Java Classloader tips | Julien's tech blog

Demystifying class loading problems, Part 1: An introduction to class loading and debugging tools

Java programming dynamics, Part 1: Java classes and class loading

启动 Java 程序时候可以通过增加 -verbose:class 参数从而在加载类时打印加载的类名,能帮助你看到类加载过程。

类加载过程

class 二进制字节流由 ClassLoader 负责加载至内存,最基础的三个 ClassLoader 是:

  • Bootstrap Class Loader. 负责加载 JDK 原生最基本的 class,这些 class 放在 JAVA_HOME/lib 或由 -Xbootclasspath 指定的位置。用户在程序中无法直接获取这个 Class Loader,比如 rt.jar 下的 String 使用 Class 类的 getClassLoader 得到结果会是 null,即用 null 代表 Bootstrap Class Loader
  • Extension Class Loader. 用户能使用它,是 ExtClassLoader,负责加载 JAVA_HOME/lib/ext目录下或 java.ext.dirs 系统变量指定路径下的所有类库
  • Application Class Loader. 用户也能使用它,可以通过 ClassLoader.getSystemClassLoader() 获取到,所以也叫系统类加载器。负责加载用户指定的 -classpath 路径下的所有类库

类加载器一般是使用叫做 Class Loader Delegation 的模型完成类的加载,中文翻译为双亲委派模型,不知道 “双亲” 是怎么来的。这个模型只是官方推荐模型,也是上述三个基础 Class Loader 实现的模型,但并不是强制的,用户自己实现的 Class Loader 可以不用这个模型。

这个模型下会将 Class Loader 组成一个层级结构,类似:

9137c619-b8e7-4dc0-bae2-c5c570aafcfa

图片来自:https://www.ibm.com/developerworks/library/j-dclp1/index.html

Class Loader 加载类的一般步骤是:

  • 先在本地内存 Cache 中找,看自己是否已经加载过一个类如果已经加载过就直接返回 Class 结果
  • 交给父加载器让父加载器去加载类,父如果能加载一个类就返回父加载器加载结果
  • 自己加载目标类

如果上述过程失败无法正常加载目标类则最终会抛异常 ClassNotFoundException

这种加载方式的好处是让最基本的比如 java.lang.Object 类一定是 Bootstrap Class Loader 来完成加载。因为两个 Class 相同必须是 Class 和 Class Loader 都相同才相同,这种层级加载结构能保证系统内不会出现两个 java.lang.Object 类实例,以避免混乱。事实上如果我们自己写一个加载器强制不将 java.lang.Object 交给 Bootstrap Class Loader 去加载,而是在我们自己的加载器内加载,则会在加载时因为不通过 Java 的 Security Manager 检查而抛错。

Abstract ClassLoader

要实现自己的 Class Loader 都得继承或间接继承最基础的 java.lang.ClassLoader,它里面实现了 Class Loader 最基础的行为。

最重要的是 loadClass 如下,只贴跟类加载相关的最基本逻辑,加锁、打日志、Security 检查等都去掉了:

// 先从本地 Cache 检查 Class 是否已经被 load 过
Class<?> c = findLoadedClass(name);
if (c == null) {
    // 找 Parent 去 Load Class,如果 Parent 是 Bootstrap Class Loader
    // 则用特殊的 method 找 Bootstrap Loader
    try {
        if (parent != null) {
            c = parent.loadClass(name, false);
        } else {
            c = findBootstrapClassOrNull(name);
        }
    } catch (ClassNotFoundException e) {
        // Parent Class Loader 可能通过返回 null 或抛异常两种方式告知自己不能加载目标 Class
    }

    if (c == null) {
        // Parent Class Loader 没找到则用 findClass 来靠自己找 Class
        c = findClass(name);
    }
}
// 根据 resolve 参数看要不要 resolve 新 load 的 class
if (resolve) {
    resolveClass(c);
}
return c;

为了维护 Class Loader Delegation 的逻辑,一般是建议自己实现 Class Loader 时候不去 Override loadClass 而是去 Override findClass。即只实现查找 Class 的过程。从而保证自定义 Class Loader 也是符合 Class Loader Delegation 模型的。

自己实现一个最简单的 Class Loader

实际例子来自 java.lang.ClassLoader 的 comment,不过从这个例子能看到 findClassdefineClass 的实现举例。

class NetworkClassLoader extends ClassLoader {
     String host;
     int port;

     public Class findClass(String name) {
         byte[] b = loadClassData(name);
         return defineClass(name, b, 0, b.length);
     }

     private byte[] loadClassData(String name) {
         // 根据 name 自己想办法拿到 class 的二进制字节流
     }
}

看到 findClass 是 Parent Class Loader 无法加载目标类时候调用,自定义的 Class Loader 在 findClass 里自己想办法根据 name 拿到 class 的二进制字节流,之后调用 java.lang.ClassLoader 内的 defineClass 将二进制字节变成 Class 实例并返回。

至此最基本的 Class 加载流程就全部梳理通了。从 NetworkClassLoader 这个例子也能看到,虽然画图时候会画 Class 是先加载,再 Linking 再初始化什么的。但实际 Linking 的操作是和加载操作混在一起做的。比如上面的 defineClass,最终会调用 ClassLoader 内的 defineClass,是个 native 的 method,传入二进制字节流后就会检查 Class 是否正确即 Linking 里的 Verify 流程,而此时 Class 并没有加载完毕。

为什么要实现自定义的 Class Loader

  • 最基本的 Class Loader 上面看到都有自己特定的获取 class 二进制流的位置,如果你的 class 二进制流不能放在上述位置,比如根本就是从网络上获取的或者动态的随着程序运行定义的,那上面基本的 Class Loader 就无法满足条件,需要自己实现 (仅指实现除了上面三种基本 Class Loader 之外的 Class Loader)
  • 基础的 Class Loader 都是满足 Class Loader Delegation Model 的,这个 Model 是推荐使用但并不强制,如果你因为什么原因不想拘束于这个 Model 就得自己实现 Class Loader,Override 那个 loadClass method
  • 动态 Reloading。我是从这里看到的。具体不记录了,感觉不是很优雅但至少是个使用场景,也挺有意思的。

自己实现 Class Loader 的原因肯定还有很多,只是我这里只能列出来这三个了。

我是遇到什么场景不想使用 Class Loader Delegation Model 呢?

这也是我写本文的原因。一直以来大致知道类加载过程,可没什么机会或需求去实现自己的 Class Loader。最近在工作中需要实现一个 Plugin 机制。Plugin 是一些 Jar 包,放在某些固定的路径下。在程序运行过程中,能检查这个路径看有没有 Jar 包存在,如果存在就去把 Jar 包中的 class 全部加载进来。

这些 Plugin 因为并不是我们写的,只是会依赖某些我们实现的接口,所以 Plugin 具体会做什么我们无法知道。加载这些 Plugin 一方面有安全顾虑,并不敢完全相信它们,最好是能有权限机制能控制它能做什么事情不能做什么事情,另一方面 Plugin 会引入什么依赖我们无法提前知晓也无法去控制,假若我们程序依赖了某个版本的第三方库,而 Plugin 又依赖了另一个版本的同一个第三方库可能就会导致故障,或意想不到的行为。

我们自己的程序会先运行,所以大多数我们程序依赖的第三方库会先被加载入内存,当需要 Load Plugin 时,如果在 Class Loader Delegation 模式下,加载所有新的 Class 都得先询问 Parent Class Loader,Plugin 如果依赖一个已经被我们自己程序加载过的 Class 就不会再从 Plugin 的 Jar 包内读 Plugin 依赖 Class 的二进制数据,而是直接返回已加载的 Class 实例。所以如果 Plugin 使用了一个不同版本的第三方 Class 就不会被加载入内存,还是会使用我们自己程序依赖的那个版本的第三方 Class。因为 Java Class 是用到时候才会被加载,同样道理如果某个 Class 是先在 Plugin 内被使用先加载了,那我们主程序就无法再加载这个 Class,如果依赖的版本不通也有问题。

为了隔离,我们可以自己实现一个打破 Class Loader Delegation Model 的 Class Loader 专门用于加载 Plugin 的类,组成下图的层级:

9137c619-b8e7-4dc0-bae2-c5c570aafcfa

对于我们自己的主程序的类,各种第三方库我们使用 Common 的 Class Loader 进行加载,并遵守 Class Loader Delegation Model。对于 Plugin 我们为了隔离为每个 Plugin 都新生成一个专用的 Plugin Class Loader,这种 Class Loader 不遵守 Class Loader Delegation Model,对于 JDK 内的类或者我们程序定义的接口使用 Parent Class Loader 也即 Common Class Loader 进行加载,对于第三方库 Class 都先去 Plugin 的 Jar 包内查找 Class 二进制文件,找不到时候再去 Parent Class Loader 找。这样因为 Class Loader 不同所以即使加载名字完全相同的 Class 也会生成不同的 Class 实例,Plugin 之间以及 Plugin 和我们主程序之间都不会相互影响。

Tomcat 为了做到 Container 隔离也是这么做的,它在这里有记录。我们来简单看看 Tomcat 是怎么做的。

Tomcat 的类加载机制

下面所有内容基于 Tomcat 9.0.16 的代码。

Tomcat 的类加载机制主要内容实现在 org.apache.catalina.loader.WebappClassLoaderBase下。最重要的还是它的 loadClass method,因为其内容很长,所以分开来看看。

  1. 检查本地 Cache 看目标类是否已经加载过,加载过就直接返回结果;
Class<?> clazz = null;

// 先检查 WebappClassLoaderBase 内的 Cache
clazz = findLoadedClass0(name);
if (clazz != null) {
    if (resolve)
        resolveClass(clazz);
    return clazz;
}

// 再检查 java.lang.ClassLoader 内的 Cache
clazz = findLoadedClass(name);
if (clazz != null) {
    if (resolve)
        resolveClass(clazz);
    return clazz;
}
  1. 使用最后一个 parent 是 null 的 Class Loader 或者说 Parent 是 Bootstrap Class Loader 的 Class Loader 来加载 JDK 内基础类
String resourceName = binaryNameToPath(name, false);

// 这里是从 System Class Loader 开始递归的 get 出最后一个 parent 是 null 的
// Class Loader。也基本就是 Extension Class Loader
ClassLoader javaseLoader = getJavaseClassLoader();
boolean tryLoadingFromJavaseLoader;
try {
    // 使用 getResource 来判断目标类是否能被 javaseLoader 加载
    // 使用 getResource 避免了抛 ClassNotFound 异常 
    // 这里本来还有 privileged 检查但为了简单我去掉了,有兴趣的可以去源码看
    URL url = javaseLoader.getResource(resourceName);

    // 拿到 url 说明目标类能被 javaseLoader 加载
    tryLoadingFromJavaseLoader = (url != null);
} catch (Throwable t) {
    // 处理异常说明说明 getResource 无法判断目标类是否能被 javaseLoader 加载
    // 所以下一步只能用 javaseLoader 的 loadClass 方法尝试一下
    tryLoadingFromJavaseLoader = true;
}

if (tryLoadingFromJavaseLoader) {
    try {
        clazz = javaseLoader.loadClass(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }
    } catch (ClassNotFoundException e) {
        // Ignore
    }
}
  1. 权限检查。上面代码我把权限检查东西去掉了,这个留下大概看一眼。走到这里说明目标类不是 JDK 内的类,就先判断一下目标类的 Package 我们自定义的 Class Loader 是否有权限去加载。可以通过配置 Security Manager 的 Policy 来禁止用户 Container 内去加载不该加载的 Package。
if (securityManager != null) {
    int i = name.lastIndexOf('.');
    if (i >= 0) {
        try {
            securityManager.checkPackageAccess(name.substring(0,i));
        } catch (SecurityException se) {
            String error = sm.getString("webappClassLoader.restrictedPackage", name);
            log.info(error, se);
            throw new ClassNotFoundException(error, se);
        }
    }
}
  1. 判断是否要用 Parent 去加载目标类。delegate 是 org.apache.catalina.loader.WebappClassLoaderBase 类的成员变量,在构造函数中传进来,告知 org.apache.catalina.loader.WebappClassLoaderBase 是否要用 Class Loader Delegation Model 去加载类。默认是 false,也即默认先尝试自己加载目标类,加载不了才交给 Parent。

还看到有个 filter,主要是用于过滤出必须由 Parent 加载的类。比如 Tomcat 主程序总是会定义一些接口、父类等让 Container 去实现,去继承,从而将 Tomcat 主程序和 Container 关联起来。如果这些接口、父类也都让org.apache.catalina.loader.WebappClassLoaderBase 去加载,那就会出错。

比如现在有个 interface A, Class B implements A。有:

B b = BFactory.createB()
A a = b

上述代码本来是很正常的。但假如加载 B 时没有做任何过滤错误的让 A 也被 WebappClassLoaderBase 加载了,那 B 实现的就是 WebappClassLoaderBase 这个 Class Loader 实例下的 A。而如果 A a = b 左侧的 A 是 Tomcat 主程序加载的 A,那它一定不是 B implements 的那个 A,因为 Tomcat 加载的 A 使用的 Class Loader 一定不会是加载 B 的 WebappClassLoaderBase Class Loader。所以要用 filter 强制将一些基础类用 parent Class Loader 加载。

需要说明一下,我这里写 WebappClassLoaderBase 负责加载 Container 实际是错的,WebappClassLoaderBase 只是 Tomcat 加载器的父类,正式的加载器是 WebappClassLoaderParallelWebappClassLoader。我这里图简单就统一写 WebappClassLoaderBase 了。

boolean delegateLoad = delegate || filter(name, true);
if (delegateLoad) {
    try {
        clazz = Class.forName(name, false, parent);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }
    } catch (ClassNotFoundException e) {
        // Ignore
    }
}
  1. 对于默认 delegate 是 false 或没被 filter 过滤掉的不认识的 Class 都用 WebappClassLoaderBase 加载。findClass 内会:

  2. 检查是否有 define 目标 Class 的权限

  3. 在本地容器内查找目标 Class 二进制流,找到后就执行 defineClass
  4. 如果 addURL 被调用过,即除了 Container 所在位置之外,还补充过其它搜索路径,则还要用 WebappClassLoaderBase 的父类 URLClassLoaderfindClass 找目标 Class
  5. 找不到 Class 则抛错 ClassNotFoundException
try {
    clazz = findClass(name);
    if (clazz != null) {
        if (resolve)
            resolveClass(clazz);
        return clazz;
    }
} catch (ClassNotFoundException e) {
    // Ignore
}
  1. 最后如果还能走到这里,并且还没尝试用 parent 加载过目标类就用 Parent Class Loader 加载目标类,如果已经尝试过用 Parent Class Loader 加载目标类则抛异常。
if (!delegateLoad) {
    try {
        clazz = Class.forName(name, false, parent);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }
    } catch (ClassNotFoundException e) {
        // Ignore
    }
}
throw new ClassNotFoundException(name);

整个过程还是比较清晰,容易理解的。还有个很值得看的是 findClassInternal method,在 findClass 内调用。这里可以看怎么处理 Jar 文件的 Manifest,怎么先定义 Package 再定义 Class。怎么控制权限检查等等内容。

我工作中用到的 Plugin 加载方法就是跟 Tomcat 学的,就不在这里记录了。

Tomcat 架构细节在这里能看看:Tomcat 系统架构与设计模式,第 1 部分: 工作原理

Tomcat 中大量使用了 Security Manager 来做权限管理,通过提供 Policy 来控制每个 Container 能做什么事情。可以先看看 这里 对 Policy 有个大概了解,再看看这里对 Tomcat Security 的说明

Clojure Reloading 的实现

Clojure 有个很好用的技能,在发现某个用 defn 定义的函数有 bug 后,可以通过 REPL 连上目标服务,将函数修改后再重新定义一遍,之后调用这个函数时就会调用修改后的函数版本,从而实现在线的修 Bug,Hot Patch。它是怎么实现的呢?

;; 用 REPL 随便进一个 namespace 定义一个函数
ylgrgyq.test=> (defn hello [] (println "hello"))
#'ylgrgyq.test/hello

;; 看到函数所在 Var 的 type 是 ylgrgyq.test$hello
ylgrgyq.test=> (type ylgrgyq.test/hello)
ylgrgyq.test$hello

;; 再看到 ylgrgyq.test$hello 的 type 是 Class
ylgrgyq.test=> (type ylgrgyq.test$hello)
java.lang.Class

;; 获取 ylgrgyq.test$hello 的 Class Loader 看到是 clojure.lang.DynamicClassLoader
ylgrgyq.test=> (.getClassLoader ylgrgyq.test$hello)
#object[clojure.lang.DynamicClassLoader 0xbebe4ed "clojure.lang.DynamicClassLoader@bebe4ed"]

如果我们再重复执行一遍上述全部过程重新定义出来一个 hello 函数,再获取一次 hello 函数的 Class Loader 会看到还是 DynamicClassLoader 但会是另一个实例 (从实例的 Hash Code 看出来)。

于是我们知道每个 Clojure 内的函数都会被编译为一个单独的 Class,其 Class Name 就是 Clojure 的 namespace + 函数名 (比如 ylgrgyq.test$hello) 。这些类都需要通过 DynamicClassLoader 加载,因为是用 defn 来定义函数,每次定义出的函数被编译、加载入内存后会让新加载的 Class 和指定的 Var (在例子中是 ylgrgyq.test/hello ) 绑定。从而通过 ylgrgyq.test/hello 这个 Var 去调用函数时会调用最新版本的 Class 也即最新版本的函数。

但为什么每次定义同名的函数都要用全新的 DynamicClassLoader 实例去加载呢?我们可以多做尝试看到只要是定义函数,每一个函数都会用自己独立的 DynamicClassLoader 实例来加载,不管这个函数是否已经被定义过。

这原因一方面是因为对于 defn 来说编译后的 Class Name 是不变的,DynamicClassLoader 内有 static 的 Cache 缓存自己加载过的 Class,重复加载同一个 Name 的 Class 时会返回同一个 Class 实例。另一个原因在 Linking 步骤的 Resolution 环节上。从前面记录的内容能看到,ClassLoader 在加载完 Class 后还需要进行 Resolution,这个步骤只能是执行 java.lang.ClassLoader 下的 resolveClass method 来完成,而这个 method 是 final 的不可能被 Override,并且主要功能实现在 resolveClass0 这个 native method 内。这个 method 会保证一个 ClassLoader 实例只能 Resolve 一次某 name 的 Class,重复 Resolve 同一个 Class 一定会返回之前已经被 Resolve 过的 Class 实例。所以,在这两点原因下,为了能重新加载相同名字但二进制流完全不同的 Class,必须使用全新的 Class Loader 实例去加载每一个函数编译后的 Class。

DynamicClassLoader

简单看一下 DynamicClassLoader 吧。DynamicClassLoader的实现很简单,其继承自 URLClassLoader 但改动不多。其 Override 了 loadClass,但只是为了在 Load Class 时能先在本地的 static Cache 中查找一下是否已经加载过目标 Class。加载过则直接返回,没加载过就用 Parent 加载。

Class c = findLoadedClass(name);
if (c == null) {
    c = findInMemoryClass(name);
    if (c == null)
        c = super.loadClass(name, false);
}
if (resolve)
    resolveClass(c);
return c;

再有就是 Override 了 defineClass,每次 defineClass 时候都需要更新本地的 Static 的 Cache。

Util.clearCache(rq, classCache);
Class c = defineClass(name, bytes, 0, bytes.length);
classCache.put(name, new SoftReference(c,rq));
return c;

因为 Clojure 编译后得到的是 Class 的二进制数据,所以每次都是直接用 defineClass 从二进制 Class 数据加载得到 Class 实例。Hot Patch 时,重新定义函数后编译完的 Class 二进制数据也是通过 defineClass 进行加载,从而自动清理了 static 的 Class Cache。

Clojure 内的 Var 如果指向的是函数,实际一开始只存了函数的 Class Name,第一次调用时候会根据 Class Name 执行 loadClass 去从 DynamicClassLoader 的 static Cache 中拿到 Class 实例,再去完成函数调用。

至此,Clojure 能实现函数 Reloading 的过程就基本都联系起来了。