ClassLoader踩坑实践

版权声明:本文为博主原创文章,未经博主允许不得转载。

摘要

最近在项目中需要实现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依赖后,问题得以解决。

图1

左图是我们预想的加载方式,右图是实际的加载方式

经验 在排查类或配置文件找不到时,首先检查类或配置文件是否存在,而这个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类始终初始化出错。

图2

正确的代码如下:

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版本的,肯定是类加载器错乱了,而代码中唯一调整类加载的地方就是切换上下文类加载器,顺着这个思路才找到了问题。

你可能感兴趣的:(ClassLoader踩坑实践)