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依赖后,问题得以解决。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270