实现Java类隔离加载

2023年 3月 8日 39点热度 0人点赞

本文分析了JAR包冲突的原因和类隔离的实现原理,分享了两种实现自定义类加载器的方法。By Xiao Hansong (Xiaokai)

在Java开发过程中,如果不同的JAR包依赖了一些通用JAR包的不同版本,运行时可能会因为加载的类与预期的不一样而出错。我们怎样才能避免这种情况呢?本文分析了JAR包冲突的原因和类隔离的实现原理,分享了两种实现自定义类加载器的方法。

1.什么是类隔离?

如果您编写足够多的 Java 代码,这肯定会发生。系统引入了新的中间件JAR包。编译时一切正常,但一运行就报错:java.lang.NoSuchMethodError。然后,你开始寻找解决方案,在数百个依赖包中找到一个冲突的JAR包。解决问题后,你开始对中间件感到沮丧,因为它有这么多不同版本的 JAR 包。你只写了五分钟的代码,却花了一整天的时间来安排包裹。上面的情况在Java开发过程中很常见。原因很简单。不同的JAR包依赖于一些常用JAR包的不同版本,比如日志组件。所以编译的时候没有问题,但是运行时报错,因为加载的类与预期不符。例如,A 和 B 分别依赖于 C 的 V1 和 V2。与V1相比,V2的日志类增加了error方法。现在,项目还引入了两个JAR包A和B,以及C的V0.1和V0.2,打包时Maven只能选择C的一个版本,假设选择了V1。默认情况下,一个项目的所有类都使用相同的类加载器加载,因此无论您依赖多少个版本的 C,最终都只会将一个版本的 C 加载到 Java 虚拟机 (JVM) 中。当B试图访问log.error时,发现是没有日志的error方法。然后,它抛出一个异常:java.lang.NoSuchMethodError. 这是典型的阶级矛盾。如果版本向后兼容,类冲突问题就可以轻松解决。你只需要排除低版本。但是,如果版本不向后兼容,你就会进退两难。有人提出了类隔离技术来解决类冲突问题,避免困境。类隔离的原理也很简单。它使用独立的类加载器来加载每个模块,因此不同模块之间的依赖关系不会相互影响。如下图所示,不同的模块加载不同的类加载器。为什么这样可以解决阶级矛盾?这里使用了Java机制。由不同的类加载器加载的类在JVM中被认为是两个不同的类,因为一个类在JVM中的唯一标识是类加载器+类名。这样,我们就可以同时加载两个不同版本的 C 的类,即使它们的类名相同。笔记:类加载器是指类加载器的一个实例,没有必要定义两个不同的类加载器。例如图中,PluginClassLoaderAandPluginClassLoaderB可以是同一个类加载器的不同实例。

2. 实现类隔离

前面提到,类隔离允许不同模块的JAR包被不同的类加载器加载。为此,需要让 JVM 使用自定义类加载器来加载我们编写的类及其关联的类。我们怎样才能做到这一点?一个很简单的解决方案是JVM提供全局类加载器的设置接口,直接替换全局类加载器。但是,这并不能解决多个自定义类加载器同时存在的问题。JVM 提供了一种简单有效的方法。我称之为类加载传导规则:JVM会选择当前类的类加载器来加载该类的所有引用类。例如,我们定义了两个类,TestA 和 TestB。TestA 将引用 TestB。只要我们使用自定义的类加载器加载TestA,当TestA调用TestB时,TestB也会被JVM使用TestA的类加载器加载。然后,TestA 和与其引用类关联的所有 JAR 包类将由自定义类加载器加载。这样,只要我们让模块的main方法类使用不同的类加载器加载,那么每个模块都会使用main方法类的类加载器进行加载。这允许多个模块分别使用不同的类加载器。了解了类隔离的实现原理之后,我们就从重写类加载器开始吧。要实现我们的类加载器,首先要让自定义类加载器继承java.lang.ClassLoader,然后重写类加载方法。这里,我们有两种选择,一种是覆盖findClass(String name),一种是覆盖loadClass(String name)。那么,我们应该选择哪一个呢?两种选择有什么区别?接下来,我们将尝试重写这两个方法来实现自定义类加载器。

2.1 覆盖FindClass

首先,我们定义两个类。TestA 将打印其类加载器,然后调用 TestB 打印其类加载器。MyClassLoaderParentFirst我们期望重写方法的类加载器findClass在加载 TestA 后自动加载 TestB。

public class TestA {

    public static void main(String[] args) {
        TestA testA = new TestA();
        testA.hello();
    }

    public void hello() {
        System.out.println("TestA: " + this.getClass().getClassLoader());
        TestB testB = new TestB();
        testB.hello();
    }
}

public class TestB {

    public void hello() {
        System.out.println("TestB: " + this.getClass().getClassLoader());
    }
}

然后,我们需要重写findClass方法,根据文件路径加载类文件,然后调用defineClass获取Class对象。

public class MyClassLoaderParentFirst extends ClassLoader{

    private Map<String, String> classPathMap = new HashMap<>();

    public MyClassLoaderParentFirst() {
        classPathMap.put("com.java.loader.TestA", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestA.class");
        classPathMap.put("com.java.loader.TestB", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestB.class");
    }

    // The findClass method is overridden
    @Override
    public Class<? > findClass(String name) throws ClassNotFoundException {
        String classPath = classPathMap.get(name);
        File file = new File(classPath);
        if (! file.exists()) {
            throw new ClassNotFoundException();
        }
        byte[] classBytes = getClassData(file);
        if (classBytes == null || classBytes.length == 0) {
            throw new ClassNotFoundException();
        }
        return defineClass(classBytes, 0, classBytes.length);
    }

    private byte[] getClassData(File file) {
        try (InputStream ins = new FileInputStream(file); ByteArrayOutputStream baos = new
                ByteArrayOutputStream()) {
            byte[] buffer = new byte[4096];
            int bytesNumRead = 0;
            while ((bytesNumRead = ins.read(buffer)) ! = -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return new byte[] {};
    }
}

最后可以写main方法调用自定义类加载器加载TestA,然后通过反射调用TestA的main方法打印类加载器的信息。

public class MyTest {

    public static void main(String[] args) throws Exception {
        MyClassLoaderParentFirst myClassLoaderParentFirst = new MyClassLoaderParentFirst();
        Class testAClass = myClassLoaderParentFirst.findClass("com.java.loader.TestA");
        Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);
        mainMethod.invoke(null, new Object[]);
    }

执行结果如下:

TestA: com.java.loader.MyClassLoaderParentFirst@1d44bcfa
TestB: sun.misc.Launcher$AppClassLoader@18b4aac2

执行结果与预期不一样。TestA 由 加载MyClassLoaderParentFirst,但 TestB 仍由 加载AppClassLoader。为什么会这样?要回答这个问题,我们首先需要了解一个类加载规则:JVMClassLoader.loadClass在触发类加载时调用方法。该方法实现了双亲委托机制:

  • 委托父加载器进行查询
  • 如果父加载器查询不到,我们可以调用findClass方法加载它。

了解了这条规则后,我们找到执行结果的原因:JVM用来MyClassLoaderParentFirst加载TestB,但是由于双亲委托机制,将TestB委托给了 的AppClassLoader父加载器MyClassLoaderParentFirst加载。您可能还想知道MyClassLoaderParentFirst为什么AppClassLoader. 我们定义的main方法类默认由Java Development Kit (JDK)自带的类加载AppClassLoader。根据类加载传导规则,MyClassLoaderParentFirst被主类引用并被AppClassLoader加载主类的类加载。由于的父类MyClassLoaderParentFirst是ClassLoader,所以ClassLoader的默认构造方法自动将父加载器的值设置为AppClassLoader。

protected ClassLoader() {
    this(checkCreateClassLoader(), getSystemClassLoader());
}

2.2. 覆盖 LoadClass

重写findClass方法受双亲委派机制影响,导致AppClassLoader加载TestB要加载,不满足类隔离目标。因此,我们只能重写该loadClass方法来破坏双亲委托机制。代码如下所示:

public class MyClassLoaderCustom extends ClassLoader {

    private ClassLoader jdkClassLoader;

    private Map<String, String> classPathMap = new HashMap<>();

    public MyClassLoaderCustom(ClassLoader jdkClassLoader) {
        this.jdkClassLoader = jdkClassLoader;
        classPathMap.put("com.java.loader.TestA", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestA.class");
        classPathMap.put("com.java.loader.TestB", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestB.class");
    }

    @Override
    protected Class<? > loadClass(String name, boolean resolve) throws ClassNotFoundException {
        Class result = null;
        try {
            // Here we need to use the class loader of JDK to load the classes included in the java.lang package.
            result = jdkClassLoader.loadClass(name);
        } catch (Exception e) {
            // Ignore
        }
        if (result ! = null) {
            return result;
        }
        String classPath = classPathMap.get(name);
        File file = new File(classPath);
        if (! file.exists()) {
            throw new ClassNotFoundException();
        }

        byte[] classBytes = getClassData(file);
        if (classBytes == null || classBytes.length == 0) {
            throw new ClassNotFoundException();
        }
        return defineClass(classBytes, 0, classBytes.length);
    }

    private byte[] getClassData(File file) { // Omit }

}

注意:我们重写了该loadClass方法,这意味着所有的类(包括 中的类java.lang package)都将通过 加载MyClassLoaderCustom。但是类隔离的对象并不包括JDK自带的这些类,所以我们使用ExtClassLoaderJDK类来加载。相关代码是:result = jdkClassLoader.loadClass(name).测试代码如下:

public class MyTest {

    public static void main(String[] args) throws Exception {
        // Here we take the parent loader of AppClassLoader, that is, ExtClassLoader, which is taken as the jdkClassLoader of MyClassLoaderCustom.
        MyClassLoaderCustom myClassLoaderCustom = new MyClassLoaderCustom(Thread.currentThread().getContextClassLoader().getParent());
        Class testAClass = myClassLoaderCustom.loadClass("com.java.loader.TestA");
        Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);
        mainMethod.invoke(null, new Object[]);
    }
}

执行结果如下:

TestA: com.java.loader.MyClassLoaderCustom@1d44bcfa
TestB: com.java.loader.MyClassLoaderCustom@1d44bcfa

重写该loadClass方法后,我们使用MyClassLoaderCustom.

三、总结

类隔离技术就是为了解决依赖冲突而产生的。它通过自定义类加载器来破坏双亲委托机制,然后利用类加载传导规则实现不同模块的类隔离。

参考

探索 Java 类加载器(中文文章)

rainbow

这个人很懒,什么都没留下

文章评论