两种实现Java类隔离加载的方法

阿里妹导读:Java 开发中,如果不同的 jar 包依赖了某些通用 jar 包的版本不一样,运行时就会因为加载的类跟预期不符合导致报错。如何避免这种情况呢?本文通过分析 jar 包产生冲突的原因及类隔离的实现原理,分享两种实现自定义类加载器的方法。

一  什么是类隔离技术

只要你 Java 代码写的足够多,就一定会出现这种情况:系统新引入了一个中间件的 jar 包,编译的时候一切正常,一运行就报错:java.lang.NoSuchMethodError,然后就哼哧哼哧的开始找解决方法,最后在几百个依赖包里面找的眼睛都快瞎了才找到冲突的 jar,把问题解决之后就开始吐槽中间件为啥搞那么多不同版本的 jar,写代码五分钟,排包排了一整天。

上面这种情况就是 Java 开发过程中常见的情况,原因也很简单,不同 jar 包依赖了某些通用 jar 包(如日志组件)的版本不一样,编译的时候没问题,到了运行时就会因为加载的类跟预期不符合导致报错。举个例子:A 和 B 分别依赖了 C 的 v1 和 v2 版本,v2 版本的 Log 类比 v1 版本新增了 error 方法,现在工程里面同时引入了 A、B 两个 jar 包,以及 C 的 v0.1、v0.2 版本,打包的时候 maven 只能选择一个 C 的版本,假设选择了 v1 版本。到了运行的时候,默认情况下一个项目的所有类都是用同一个类加载器加载的,所以不管你依赖了多少个版本的 C,最终只会有一个版本的 C 被加载到 JVM 中。当 B 要去访问 Log.error,就会发现 Log 压根就没有 error 方法,然后就抛异常java.lang.NoSuchMethodError。这就是类冲突的一个典型案例。

类冲突的问题如果版本是向下兼容的其实很好解决,把低版本的排除掉就完事了。但要是遇到版本不向下兼容的那就陷入了“救妈妈还是救女朋友”的两难处境了。

为了避免两难选择,有人就提出了类隔离技术来解决类冲突的问题。类隔离的原理也很简单,就是让每个模块使用独立的类加载器来加载,这样不同模块之间的依赖就不会互相影响。如下图所示,不同的模块用不同的类加载器加载。为什么这样做就能解决类冲突呢?这里用到了 Java 的一个机制:不同类加载器加载的类在 JVM 看来是两个不同的类,因为在 JVM 中一个类的唯一标识是 类加载器+类名。通过这种方式我们就能够同时加载 C 的两个不同版本的类,即使它类名是一样的。注意,这里类加载器指的是类加载器的实例,并不是一定要定义两个不同类加载器,例如图中的 PluginClassLoaderA 和 PluginClassLoaderB 可以是同一个类加载器的不同实例。

二  如何实现类隔离

前面我们提到类隔离就是让不同模块的 jar 包用不同的类加载器加载,要做到这一点,就需要让 JVM 能够使用自定义的类加载器加载我们写的类以及其关联的类。

那么如何实现呢?一个很简单的做法就是 JVM 提供一个全局类加载器的设置接口,这样我们直接替换全局类加载器就行了,但是这样无法解决多个自定义类加载器同时存在的问题。

实际上 JVM 提供了一种非常简单有效的方式,我把它称为类加载传导规则:JVM 会选择当前类的类加载器来加载所有该类的引用的类。例如我们定义了 TestA 和 TestB 两个类,TestA 会引用 TestB,只要我们使用自定义的类加载器加载 TestA,那么在运行时,当 TestA 调用到 TestB 的时候,TestB 也会被 JVM 使用 TestA 的类加载器加载。依此类推,只要是 TestA 及其引用类关联的所有 jar 包的类都会被自定义类加载器加载。通过这种方式,我们只要让模块的 main 方法类使用不同的类加载器加载,那么每个模块的都会使用 main 方法类的类加载器加载的,这样就能让多个模块分别使用不同类加载器。这也是 OSGi 和 SofaArk 能够实现类隔离的核心原理。

了解了类隔离的实现原理之后,我们从重写类加载器开始进行实操。要实现自己的类加载器,首先让自定义的类加载器继承 java.lang.ClassLoader,然后重写类加载的方法,这里我们有两个选择,一个是重写 findClass(String name),一个是重写 loadClass(String name)。那么到底应该选择哪个?这两者有什么区别?
下面我们分别尝试重写这两个方法来实现自定义类加载器。

1.重写 findClass

首先我们定义两个类,TestA 会打印自己的类加载器,然后调用 TestB 打印它的类加载器,我们预期是实现重写了 findClass 方法的类加载器 MyClassLoaderParentFirst 能够在加载了 TestA 之后,让 TestB 也自动由 MyClassLoaderParentFirst 来进行加载。

public class TestA {

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

  public void hello() {
    // https://jinglingwang.cn/archives/class-isolation-loading
    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 方法,这个方法先根据文件路径加载 class 文件,然后调用 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");
  }

  // 重写了 findClass 方法  by:jinglingwang.cn
  @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[]{args});
  }

执行的结果如下:

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

执行的结果并没有如我们期待,TestA 确实是 MyClassLoaderParentFirst 加载的,但是 TestB 还是 AppClassLoader 加载的。这是为什么呢?

要回答这个问题,首先是要了解一个类加载的规则:JVM 在触发类加载时调用的是 ClassLoader.loadClass 方法。这个方法的实现了双亲委派:

  • 委托给父加载器查询
  • 如果父加载器查询不到,就调用 findClass 方法进行加载

明白了这个规则之后,执行的结果的原因就找到了:JVM 确实使用了MyClassLoaderParentFirst 来加载 TestB,但是因为双亲委派的机制,TestB 被委托给了 MyClassLoaderParentFirst 的父加载器 AppClassLoader 进行加载。

你可能还好奇,为什么 MyClassLoaderParentFirst 的父加载器是 AppClassLoader?因为我们定义的 main 方法类默认情况下都是由 JDK 自带的 AppClassLoader 加载的,根据类加载传导规则,main 类引用的 MyClassLoaderParentFirst 也是由加载了 main 类的AppClassLoader 来加载。由于 MyClassLoaderParentFirst 的父类是 ClassLoader,ClassLoader 的默认构造方法会自动设置父加载器的值为 AppClassLoader。

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

2.重写 loadClass

由于重写 findClass 方法会受到双亲委派机制的影响导致 TestB 被 AppClassLoader 加载,不符合类隔离的目标,所以我们只能重写 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 {
      //by:jinglingwang.cn 这里要使用 JDK 的类加载器加载 java.lang 包里面的类
      result = jdkClassLoader.loadClass(name);
    } catch (Exception e) {
      //忽略 by:jinglingwang.cn
    }
    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) { //省略 }

}

这里注意一点,我们重写了 loadClass 方法也就是意味着所有类包括 java.lang 包里面的类都会通过 MyClassLoaderCustom 进行加载,但类隔离的目标不包括这部分 JDK 自带的类,所以我们用 ExtClassLoader 来加载 JDK 的类,相关的代码就是:result = jdkClassLoader.loadClass(name);

测试代码如下:

public class MyTest {

  public static void main(String[] args) throws Exception {
    //这里取AppClassLoader的父加载器也就是ExtClassLoader作为MyClassLoaderCustom的jdkClassLoader
    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[]{args});
  }
}

执行结果如下:

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

可以看到,通过重写了 loadClass 方法,我们成功的让 TestB 也使用MyClassLoaderCustom 加载到了 JVM 中。

三  总结

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

以上就是两种实现Java类隔离加载的方法的详细内容,更多关于Java类隔离加载的资料请关注我们其它相关文章!

(0)

相关推荐

  • Java基于自定义类加载器实现热部署过程解析

    热部署: 热部署就是在不重启应用的情况下,当类的定义即字节码文件修改后,能够替换该Class创建的对象.一般情况下,类的加载都是由系统自带的类加载器完成,且对于同一个全限定名的java类,只能被加载一次,而且无法被卸载.可以使用自定义的 ClassLoader 替换系统的加载器,创建一个新的 ClassLoader,再用它加载 Class,得到的 Class 对象就是新的(因为不是同一个类加载器),再用该 Class 对象创建一个实例,从而实现动态更新.如:修改 JSP 文件即生效,就是利用自定

  • Java类加载机制实现步骤解析

    一.类的加载过程 JVM将类的加载分为3个步骤: 1.装载(Load) 2.链接(Link) 3.初始化(Initialize) 其中 链接(Link)又分3个步骤,如下图所示: 1) 装载:查找并加载类的二进制数据(查找和导入Class文件) 加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情: 1.通过一个类的全限定名来获取其定义的二进制字节流. 2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构. 3.在Java堆中生成一个代表这个类的java.lang.C

  • Intellij IDEA命令行执行java无法加载主类解决方案

    思路一:环境配置中,CLASSPATH配置的最前面加入".;","."表示当前目录中搜索 思路二 1.命令行进入到.java所在目录 2.通过 javac d . [java文件名(带.java后缀)] 编译java文件 3.通过 java [package后的路径名].[java文件名(不带.java后缀)] 以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们.

  • 解决IDEA和CMD中java命令提示错误: 找不到或无法加载主类的问题

    一 概述 CMD D:\Project\Computer-Science-And-Technology\writeExam\farben\src\com\GC>java CommandLineParameter        错误: 找不到或无法加载主类 CommandLineParameter IDEA D:\Project\Computer-Science-And-Technology\writeExam\farben\src\com\GC>java CommandLineParamete

  • jvm之java类加载机制和类加载器(ClassLoader)的用法

    当程序主动使用某个类时,如果该类还未被加载到内存中,则JVM会通过加载.连接.初始化3个步骤来对该类进行初始化.如果没有意外,JVM将会连续完成3个步骤,所以有时也把这个3个步骤统称为类加载或类初始化. 一.类加载过程 1.加载 加载指的是将类的class文件读入到内存,并为之创建一个java.lang.Class对象,也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象. 类的加载由类加载器完成,类加载器通常由JVM提供,这些类加载器也是前面所有程序运行的基础

  • 源码解析Java类加载器

    参考内容: 深入理解Java虚拟机(JVM高级特性与最佳实践) --周志明老师 尚硅谷深入理解JVM教学视频--宋红康老师 我们都知道Java的类加载器结构为下图所示(JDK8及之前,JDK9进行了模块化): 关于三层类加载器.双亲委派机制,本文不再板书,读者可自行百度. 那么在JDK的源码中,三层结构的具体实现是怎么样的呢? Bootstrap ClassLoader(引导类加载器) 引导类加载器是由C++实现的,并非Java代码实现,所以在Java代码中是无法获取到该类加载器的. 一般大家都

  • Java 类在 Tomcat 中是如何加载的(过程分析)

    说到本篇的Tomcat类加载机制,不得不说翻译学习Tomcat的初衷. 之前实习的时候学习JavaMelody的源码,但是它是一个Maven的项目,与我们自己的Web项目整合后无法直接断点调试. 后来同事指导,说是直接把Java类复制到src下就可以了.很纳闷....为什么会优先加载src下的Java文件(编译出的class),而不是jar包中的class呢? 现在了解Tomcat的类加载机制,原来一切是这么的简单. 一.类加载 在JVM中并不是一次性把所有的文件都加载到,而是一步一步的,按照需

  • Java 找不到或无法加载主类的修复方法

    有时,当我们运行Java程序时,我们可能会看到"找不到或无法加载主类".原因很容易猜测:JVM找不到主类并给出了这个错误.但是为什么不能呢? 在本文中,我们将讨论找不到主类的可能原因.另外,我们将看看如何修复它们. 示例程序 我们将从HelloWorld程序开始: public class HelloWorld { public static void main(String[] args) { System.out.println("Hello world..!!!&quo

  • Java类加载机制实现流程及原理详解

    前言 我们知道,Java项目编译后会生成许许多多的class文件,class文件保存着类的描述信息.虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验.转化解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制. 类的生命周期 类从被加载到虚拟机内存中开始,到卸载出内存位置,他的整个生命周期包括: 加载验证准备解析初始化使用卸载 这七个阶段.画个图就是下面这样: 其中,类加载的过程包括了加载.验证.准备.解析.初始化这五个阶段.其中加载.验证.准备.初始

  • Java类加载器层次结构原理解析

    类加载器的层次结构: 引导类加载器(bootstrap class loader) 用来加载java的核心库(JAVA_HOME/jre/lib/rt.jar,或sun.boot.class.path路径下的内容),是用原生代码来实现的(C实现的),并不继承自java.lang.ClassLoader. 加载扩展类和应用程序类加载器,并指定它们的父类加载器. 扩展类加载器(extensions class loader) 用来加载java的扩展库(JAVA_HOME/jre/lib/ext/*.

随机推荐