记一次类加载器的简单应用

2022-01-18 10:24:38 浏览数 (1)

jvm和java语言是两种产品,java代码编译后生成字节码bytecode(.class文件),jvm解释字节码转换为机器码并真正执行,字节码和虚拟机之间的桥梁就是java开发中常见的类加载器,实现从外部来加载某个类的字节码并传递给虚拟机。

类加载器主要有启动类加载器(BootClassLoader)、扩展类加载器(ExtClassLoader)、应用类加载器(AppClassLoader)以及自定义类加载器(CustomClassLoader,视应用实现有无)四类,类加载器加载类的方式为双亲委托模式,默认的加载流程可以简单表述为:

  1. findLoadedClass:检查class是否已经被加载过,已经加载过直接返回
  2. 检查classloader的parent:尝试从parent加载
  3. 如果parent为空:尝试从BootClassLoader加载
  4. 如果还是没有找到:通过当前classloader加载

类加载的代码可以在java.lang.ClassLoader.loadClass方法中找到,简单画个图,单个classloader内部的加载流程:

假定CustomClassLoader指定了AppClassLoader为双亲(parent classloader),整个加载类控制流的流程图可以简单画作:

其中:

  • BootClassLoader默认加载核心类(jre目录下的lib/*.jar),可以通过-Xbootclasspath追加其他路径,会让指定路径下的class优先被找到;
  • ExtClassLoader加载扩展类,jre目录下的lib/ext/*.jar;
  • AppClassLoader加载应用程序需要的类库,通过-cp传入,或在启动目录下 ".",也可以直接打成一个fat jar;
  • CustomClassLoader主要是用户自定义的实现。

需要注意的一点是,类加载器会通过parent来确认是否需要加载类,但是不会向下通过children来确认,因此高优先级classloader比如BootClassLoader中的类如果要加载AppClassLoader中的类,就需要通过ContextClassLoader来执行,因为ClassLoader不会向下请求,只能单向委托双亲加载,ContextClassLoader可以通过当前工作线程的上下文来传递。

图中虚线箭头表示的类加载方式就是通过context classloader作为中转媒介,当然也可以通过实现自定义的classloader,覆盖loadClass方法来修改加载类文件的控制流。

字节码存在的位置可以是一个与jvm运行在同一操作系统的本地路径,也可以是一个通过网络访问的远端存储,JDK专门提供了URLClassLoader之类的加载器来实现通过网络加载远程bytecode的方法,本地加载的话就可以直接通过classpath告诉系统加载器来加载,本地其实是逻辑上的本地路径,也可以通过操作系统挂载远程文件夹来模拟本地加载远程文件。

背景介绍到这里,接下来解决一个实际问题,因为历史原因,我们的平台系统同时存在高低版本的ElasticSearch(1.3.2/5.1.2,以下简称为Es),又不希望分开两套代码,不便维护,这里有三种解决方法:

  • 自定义classloader,又可以分成两种:
    1. 打包成一个大文件,类似spring boot的加载方式,将jar及其全部依赖打包成一个文件,然后通过不同的文件偏移量来load,一个文件数据段代表一个class;
    2. 从指定目录加载指定jar,不同版本的Es交互代码放在不同的工程模块,打包时将不同的模块打包到不同的文件夹,应用程序启动时通过不同的classloader加载不同文件夹下的class;
  • 通过maven shade plugin来将依赖包重命名,因为Es核心包又有其他依赖,也会导致类冲突,需要将Es核心包及其全部依赖都重命名。

这里我们采用了比较低成本的方法,通过不同文件夹来隔离不兼容的Es核心包及其依赖,利用多个classloader之间加载的class不会冲突以及classloader不会向下请求的方法来实现正常加载高低版本Es及其依赖包,主要的实现思路如下:

  1. 将高低版本Es交互隔离到不同的工程module
  2. 通过module的编译配置(maven assembly),编译时将其输出到target下的不同目录
  3. 配置主工程的assembly,通过文件依赖的方式将第2步的多个目录拷贝到应用程序的lib目录下(lib/ext/*.jar)
  4. 自定义classloader,通过环境变量传入各个Es的lib目录,拼接为不同的classpath
  5. 应用启动时通过多个自定义classloader加载多个目录下的类文件

为了节省篇幅,这里只简要列出主要的实现代码:

代码语言:javascript复制
public void loadFiles() {
    // 通过自定义classloader加载高低版本
    String es5x;
    String es1x;
    String libPath = System.getProperty("lib.path");
    if (StringUtils.isNotBlank(libPath)) {
        es5x = libPath   "/esx5/";
        es1x = libPath   "/esx1/";
    } else {
        // 异常代码省略
    }

    try {
        MyClassLoader loader1 = new MyClassLoader(getClassPath(es1x), getClass().getClassLoader());
        MyClassLoader loader2 = new MyClassLoader(getClassPath(es5x), getClass().getClassLoader());

        // 用不同的classloader来加载es依赖
        Class esclientX1 = loader1.loadClass("com.youzan.platform.esclient.ESClientX1", true);
        Class esclientX5 = loader2.loadClass("com.youzan.platform.esclient.ESClientX5", true);

        // 初始化es client
        for (ESConn conn : config.getConnections()) {
            ESClient esClient;
            if (ESVersion.X5 == conn.getType()) {
                esClient = (ESClient) esclientX5.newInstance();
            } else {
                esClient = (ESClient) esclientX1.newInstance();
            }
            // 原方法初始化ESClient
            esClient.init(conn);
        }
    } catch (Exception e) {
        log.error("initialize es client failed with {}", ExceptionUtils.getStackTrace(e));
        // 异常处理省略
    }
}
代码语言:javascript复制
private URL[] getClassPath(String dir) throws MalformedURLException {
    List<URI> jars = new LinkedList<>();

    // 拼接全部jar文件路径
    Collection<File> files = FileUtils.listFiles(new File(dir), FileFilterUtils.suffixFileFilter(".jar"), null);
    files.forEach((f) -> jars.add(f.toURI()));

    URL[] urls = new URL[jars.size()];
    for (int i = 0; i < jars.size(); i  ) {
        urls[i] = jars.get(i).toURL();
    }

    return urls;
}

这里提一下实现过程中遇到的一个坑,Es1.x启动时需要指定context class loader,Es1.x的内部异常在实际处理时才会load,默认会用AppClassloader加载,而我们实际是通过一个继承自AppClassloader的自定义加载器加载的Es核心包,因为classloader不会向下请求,因此会报运行时异常,解决方法就是在传入Client的初始化参数时设置加载核心包的类加载器:

代码语言:javascript复制
Settings settings = ImmutableSettings.builder()
        // 设置上下文classloader,其他代码省略
        .classLoader(getClass().getClassLoader())
        .build();
TransportClient client = new TransportClient(settings);

Es5.x版本已经fix这个问题了。

另外再提一句,一般实现自定义的classloader都是建议覆盖findClass方法,而不是直接覆盖loadClass方法,避免在不知情的情况下改变类加载的控制流,导致其不符合双亲委托模型,引发ClassNotFoundException或者ClassCastException,因为不同的classloader加载类在jvm看来并不是同一个,即使内部的代码实现甚至class文件都是同一个。

本次问题分析及解决方法就到这里,在构思这篇文章的过程中,也想到了以前遇到的一个问题(错误将一个应用依赖包拷贝到了jre的ext lib目录下,导致应用程序的lib目录中的依赖一直加载失败),假设有多个团队引用了同一个公共包,想要升级这个包的时候就需要通知多个团队配合升级,如果想跳过这个费时的过程直接升级发布,也可以考虑类似的方法,通过更高优先级的classloader来加载公共包,只要保证这个目录下的包能够统一更新,升级问题就变得很省力了。

后序:

如果某种语言的编译器遵守虚拟机规范,编译后输出标准的字节码,那么用这个语言写出的应用程序代码可以通过jvm运行,这应该是java平台产品设计中最成功的点,使之具有相当的生态开放程度,目前运行于jvm之上的衍生语言(jvm language)已经有Scala/Clojure/Groovy/Kotlin等多种(https://en.wikipedia.org/wiki/List_of_JVM_languages)。

0 人点赞