ClassLoader踩坑实践——尝试失败【TODO】

原文是:ClassLoader踩坑实践

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


我的实现

由于作者里面的代码不是很详细,所以我脑补了一下。针对不完善的地方,猜测如下:

public class App {
    public static void main(String[] args)
            throws Exception {
        //具体实现类的jar包路径
        String mysqlJarPath = "/Users/username/.m2/repository/com/username/mysql_test/1.0-SNAPSHOT/mysql_test-1.0-SNAPSHOT.jar";
        String hbaseJarPath = "/Users/username/.m2/repository/com/username/hbase_test/1.0-SNAPSHOT/hbase_test-1.0-SNAPSHOT.jar";

        String[] jarPaths = new String[]{mysqlJarPath, hbaseJarPath};
        //相应的配置文件名
        String[] configs = new String[]{"mysql.config","hbase.config"};
        //具体实现类
        String[] classNames = new String[]{"mysql.Mysql","hbase.Hbase"};
        for (int i=0; i<2; i++) {
            //指定本地jar包和配置文件路径
            DatabaseFactory databaseFactory = new DatabaseFactory(
                    jarPaths[i], configs[i]);
           
            //加载指定的实现类
            Database database = databaseFactory.create(classNames[i]);
            //初始化
            database.init();
            //查询
            System.out.println(database.query());
        }
    }
}
public class FileUtils {
    public static String readResourceAsString(String s, ClassLoader classLoader) throws IOException {
        URL url = classLoader.getResource(s);

        Properties properties = new Properties();
        properties.load(classLoader.getResourceAsStream(s));

        String configResult =  properties.toString();
        return configResult;
    }
}

项目的包结构是:

  • classloader_test 里面定义了 Database、DatabaseFactory、App类。然后将其发布到本地的maven仓库。
  • mysql_test、hbase_test项目。都引用了本地maven中的classloader_test依赖。这两个项目的resource下面,都有各自的配置文件mysql.config,hbase.config。

分别遇到以下问题
1、classloader_test项目,App.main执行的时候,是无法读取到hbase里的配置文件。如果在classloader_test项目的resource下面添加hbase.config文件,那么就可以读取到classloader_test项目的hbase.config文件。很奇怪。总结起来可能是因为hbase_test的classloader缘故。

2、怎么动态打入jar包中呢?为啥maven依赖引入的jar,是没有配置文件hbase.config的呢?我理解的是应该有啊。

App中已经引入了clabc-core的maven依赖以及指定了用户jar包的路径,但同时也不慎引入了用户实现类的maven依赖,而通过maven依赖引入的用户实现中是不带配置文件的(它是用户动态打入到jar包中的)。

3、classloader_test的Database中,添加了新的抽象方法。也没有报错java.lang.AbstractMethodError。为啥呢?【此时是在classloader_test的resource下放了一个hbase.config的情况】

4、针对原文作者的“使用ContextClassLoader”这部分。我没有搞懂,是怎么能够让hbase_test和mysql_test项目能够自己运行起来呢。而不是像我目前这样,无法让hbase_test运行起来。意思就是说,我无法复现作者的这种情况。

Guess you like

Origin blog.csdn.net/yangyangrenren/article/details/121311260