jvm和java语言是两种产品,java代码编译后生成字节码bytecode(.class文件),jvm解释字节码转换为机器码并真正执行,字节码和虚拟机之间的桥梁就是java开发中常见的类加载器,实现从外部来加载某个类的字节码并传递给虚拟机。
类加载器主要有启动类加载器(BootClassLoader)、扩展类加载器(ExtClassLoader)、应用类加载器(AppClassLoader)以及自定义类加载器(CustomClassLoader,视应用实现有无)四类,类加载器加载类的方式为双亲委托模式,默认的加载流程可以简单表述为:
类加载的代码可以在java.lang.ClassLoader.loadClass方法中找到,简单画个图,单个classloader内部的加载流程:

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

其中:
需要注意的一点是,类加载器会通过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),又不希望分开两套代码,不便维护,这里有三种解决方法:
这里我们采用了比较低成本的方法,通过不同文件夹来隔离不兼容的Es核心包及其依赖,利用多个classloader之间加载的class不会冲突以及classloader不会向下请求的方法来实现正常加载高低版本Es及其依赖包,主要的实现思路如下:

为了节省篇幅,这里只简要列出主要的实现代码:
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));
// 异常处理省略
}
}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的初始化参数时设置加载核心包的类加载器:
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)。