本文分析了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 类加载器(中文文章)
文章评论