版权声明:本文为博主原创文章,未经博主允许不得转载。
摘要
最近在项目中需要实现ClassLoader动态加载类的功能,虽然以前在资料上没少见过ClassLoader的加载原理,但在开发和优化这个功能的过程中还是遇到了不少问题,也踩了不少坑。现在将这些问题和踩坑经验介绍一下,希望能帮到其他同学少踩一些坑。如果有描述不准确的地方,欢迎指正。
背景
将问题背景简化一下,应用程序App中要访问Database(非真正的数据库,只是作为抽象与实现的典型例子),Database抽象类由clabc-core.jar定义,具体的实现放在不同用户jar包中,每个jar包由自己的URLClassLoader来加载。
Database抽象类的定义如下:
public abstract class Database {
/**
* 配置文件
*/
private String config;
/**
* 查询接口
* @return
*/
abstract public String query();
/**
* 初始化接口
*/
abstract public void init() throws IOException;
public String getConfig() {
return config;
}
public void setConfig(String config) {
this.config = config;
}
}
同时还提供了DatabaseFactory从本地加载用户jar包的工厂类:
public class DatabaseFactory {
/**
* 内部classLoader
*/
private URLClassLoader urlClassLoader;
/**
* 指定本地jar包路径和配置文件的构造器
* @param classPaths
* @throws Exception
*/
public DatabaseFactory(String... classPaths) throws Exception {
List urls = new ArrayList<>();
if (classPaths != null && classPaths.length > 0) {
for (String cp : classPaths) {
urls.add(new File(cp).toURI().toURL());
}
}
urlClassLoader = new URLClassLoader(urls.toArray(new URL[] {}));
}
/**
* 从内部urlClassLoader中加载指定的Database实现类
* @param name 类名
* @return
* @throws ClassNotFoundException
* @throws IllegalAccessException
* @throws InstantiationException
*/
public Database create(String name)
throws ClassNotFoundException, IllegalAccessException, InstantiationException {
Class clz = this.urlClassLoader.loadClass(name);
ClassLoader oldClassLoader = Thread.currentThread().getContextClassLoader();
//上下文类加载器切换,以下实现是有问题的,后面会介绍
Thread.currentThread().setContextClassLoader(this.urlClassLoader);
Database database = (Database) clz.newInstance();
Thread.currentThread().setContextClassLoader(oldClassLoader);
return database;
}
}
测试类App功能很简单,只需要指定本地用户jar包,配置文件路径和加载具体哪个实现类,然后初始化Database并执行查询,App代码如下:
public class App {
public static void main(String[] args)
throws Exception {
//具体实现类的jar包路径
String[] jarPaths = new String[]{"jar_path1","jar_path2"};
//相应的配置文件名
String[] configs = new String[]{"config1","config2"};
//具体实现类
String[] classNames = new String[]{"className1","className2"};
for (int i=0; i<2; i++) {
//指定本地jar包和配置文件路径
DatabaseFactory databaseFactory = new DatabaseFactory(
jarPaths[i], configs[i]);
try{
//加载指定的实现类
Database database = databaseFactory.create(classNames[i]);
//初始化
database.init();
//查询
System.out.println(database.query());
} catch(Throwable e) {
//logger.error
}
}
}
}
现在有两种不同实现类HBase和Mysql,由不同的用户实现:
public class HBase extends Database {
@Override
public String query() {
return String.format("HBase(%s) result: bbbb", this.getConfig());
}
@Override
public void init() {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
if (classLoader instanceof URLClassLoader) {
//这里使用上下文类加载器加载配置文件,在OSGI环境下会存在问题,后面会提到
String config = FileUtils.readResourceAsString("hbase.config", classLoader);
this.setConfig(config);
} else {
throw new IOException("cant load hbase.conf from " + classLoader);
}
}
}
public class Mysql extends Database {
@Override
public String query() {
return String.format("Mysql(%s) result: aaaa", this.getConfig());
}
@Override
public void init() {
String config = FileUtils.readResourceAsString("mysql.config", this.getClass().getClassLoader());
this.setConfig(config);
}
}
以上是背景和示例代码,接下来介绍实践中踩到的坑。
实践经验
1. 错误的依赖
背景 App中已经引入了clabc-core的maven依赖以及指定了用户jar包的路径,但同时也不慎引入了用户实现类的maven依赖,而通过maven依赖引入的用户实现中是不带配置文件的(它是用户动态打入到jar包中的)。
现象 使用HBase查询时报错:找不到配置文件hbase.conf
原因 如果已经加载了用户jar包,那么通过HBase类的ClassLoader应该可以访问到jar包里的配置文件。在检查HBase类的ClassLoader之后发现,它并不是指定用户jar路径的URLClassLoader,而是AppClassLoader系统类加载器,而AppClassLoader中HBase类是通过maven引入的,不带配置文件。原本打算通过URLClassLoader加载器加载HBase类,但是却委托给父加载器AppClassLoader加载了,碰巧AppClassLoader中也有相同的类,自然由父类先加载了,所以找不到配置文件。排除用户实现类的maven依赖后,问题得以解决。
左图是我们预想的加载方式,右图是实际的加载方式
经验 在排查类或配置文件找不到时,首先检查类或配置文件是否存在,而这个case给我们另外一个切入点,那就是观察是不是由错误的类加载器加载了。
2. 版本冲突
背景 clabc-core新增了抽象方法close(),而用户jar包没有升级,没有相应的实现,导致抛错java.lang.AbstractMethodError。
public abstract class Database {
/**
* 配置文件
*/
private String config;
/**
* 查询接口
* @return
*/
abstract public String query();
/**
* 初始化接口
*/
abstract public void init() throws IOException;
/**
* 新增接口:关闭连接
*/
abstract public void close();
...
}
原因 类似这种因为版本冲突原因而导致的错误问题很多,比如:接口版本升级,删除了一些接口或者更改一些接口的签名,那么用户在使用低版本实现类时,会遇到NoSuchMethodError, NoClassDefFound等异常;甚至有的版本升级直接更改了一些类的继承体系,那么还会遇到VerifyError异常,Type 'xxx.xx' is not assignable to 'xxx.xx'。
经验 分析比较难见的异常一般是debug查看出错类的ClassLoader是否是预期的或直接跟踪类的parent分析类的继承体系。
3. 使用ContextClassLoader
背景 我们在DatabaseFactory中切换了上下文类加载器后才开始执行构建database实例,这段代码的目的主要是为了解决这个问题:当前类对象的实例化或运行时依赖了上下文类加载器,而上下文类加载器有可能并非该类的实际类加载器,比如HBase Client(非本例中的HBase)初始化时使用到的org.apache.hadoop.conf.Configuration类:
static {
ClassLoader cL = Thread.currentThread().getContextClassLoader();
if (cL == null) {
cL = Configuration.class.getClassLoader();
}
if (cL.getResource("hadoop-site.xml") != null) {
LOG.warn("DEPRECATED: hadoop-site.xml found in the classpath. Usage of hadoop-site.xml is deprecated. Instead use core-site.xml, mapred-site.xml and hdfs-site.xml to override properties of core-default.xml, mapred-default.xml and hdfs-default.xml respectively");
}
addDefaultResource("core-default.xml");
addDefaultResource("core-site.xml");
...
}
这段代码使用当前上下文类加载器加载配置文件,在一般环境下问题不大,上下文类加载器与Configuration的类加载器是同一个。而在OSGI环境下,如果HBase client被封装成一个Bundle,那么他的类加载器是这个Bundle的类加载器,但如果直接初始化HBase client实例的是另一个Bundle的话,虽然能通过Bundle之间的依赖能找到HBase client类,但是执行初始化时就会出错。因为实例化HBase cient过程中需要使用上下文类加载器,而上下文类加载器此时却是调用HBase client的Bundle的类加载器,因此在当前类加载器中去加载xml等配置文件时肯定会抛异常。参考文章
/**
* 从内部urlClassLoader中加载指定的Database实现类
* @param name 类名
* @return
* @throws ClassNotFoundException
* @throws IllegalAccessException
* @throws InstantiationException
*/
public Database create(String name)
throws ClassNotFoundException, IllegalAccessException, InstantiationException {
Class clz = this.urlClassLoader.loadClass(name);
ClassLoader oldClassLoader = Thread.currentThread().getContextClassLoader();
//错误的上下文类加载器切换
Thread.currentThread().setContextClassLoader(this.urlClassLoader);
Database database = (Database) clz.newInstance();
Thread.currentThread().setContextClassLoader(oldClassLoader);
return database;
}
以上解释了为什么需要在初始化Database对象前后切换上下文类加载器。但是这样切换了上下文类加载器就没问题了吗?这种做法会导致类加载器链变得混乱。
原因 看看这种场景(如图2所示),首先加载hbase-database-v1.jar,在HBase实例化使其抛出异常,那么当前线程上下文类加载器被切换成hbase-database-v1.jar的URLClassLoader后,由于异常抛出而不能把上下文类加载器切换回来。接着又开始加载hbase-database-v2.jar,v2的URLClassLoader先会把当前上下文类加载器(v1的URLClassLoader)当做父加载器,在将自己设置为上下文类加载器后继续实例化HBase对象,此时会委托v1的URLClassLoader去加载HBase类,那么还是找到了那个抛出异常的类,当前上下文类加载器又切不回来。最后类加载器链变成这样:v2 URLClassLoader -> v1 URLClassLoader -> AppClassLoader,这样即使HBase v2版本已经没有问题了,但是由于双亲委托机制还是会找到错误的v1版本,HBase类始终初始化出错。
正确的代码如下:
Thread.currentThread().setContextClassLoader(this.urlClassLoader);
try{
Database database = (Database) clz.newInstance();
} finally {
//最终将上下文类加载器切换回来
Thread.currentThread().setContextClassLoader(oldClassLoader);
}
经验 这个问题的排查比较费劲的,排除v1单独加载v2没有问题,但是先加载v1再加载v2肯定出错,按理说v1与v2类加载器是隔离的,如果总是出现相互影响,那么可能就是隔离没做好。带着这个猜测调试到抛异常的地方,查看HBase类的ClassLoader发现一直是v1版本的,肯定是类加载器错乱了,而代码中唯一调整类加载的地方就是切换上下文类加载器,顺着这个思路才找到了问题。