为什么需要类隔离
先来看下面这个例子。
假如一个项目,它使用的其中一个中间件1依赖库A,版本是2.0,另一个中间件2也依赖库A,版本是3.0,我们无法保证新版本是完全兼容旧版本的。若最终运行时选用2.0版本的A,很容易产生NoClassDefFoundError等问题。当然,若3.0版本向下兼容2.0版本,我们可以在项目中显式指定其依赖3.0版本的库A,这在项目初期是可行的。当我们的应用越来越复杂,依赖的中间件越来越多时,通过人工排查并指定一个版本的jar包几乎是不可能的。

因此,我们希望有一种技术能够做到将一个应用中的各个中间件相互独立开来,相互不受对方依赖版本的影响,同时我们的应用在使用这些中间件的时候可以正常的查找类信息。这不禁让我们想到了Tomcat的应用间的隔离,同一个Tomcat容器下,可以部署多个应用,而应用与应用之间甚至感知不到对方存在,这一技术就是通过Java的类加载器(ClassLoader)来实现的。
类隔离的原理
类隔离的原理也很简单,就是让每个模块使用独立的类加载器来加载,这样不同模块之间的依赖就不会互相影响。不同的模块用不同的类加载器加载,为什么这样做就能解决类冲突呢?这里用到了Java的一个机制:不同类加载器加载的类在JVM看来是两个不同的类,因为在JVM中一个类的唯一标识是:类加载器+类名。通过这种方式我们就能够同时加载C这个类的两个不同版本,即使它们类名都是C。注意,这里的类加载器指的是类加载器的实例,并不是一定要定义两个不同的类加载器,例如图中的PluginClassLoaderA和PluginClassLoaderB可以是同一个类加载器的不同实例。

类隔离就是让不同模块的jar包用不同的类加载器加载,要做到这一点,就需要让JVM能够使用自定义的类加载器加载我们写的类以及其关联的类。
实际上JVM提供了一种非常简单有效的方式,我把它称为类加载传导规则:JVM会选择当前类的类加载器来加载所有该类要引用的类。例如我们定义了TestA和TestB两个类,TestA中引用了TestB,只要我们使用自定义的类加载器加载TestA,那么在运行时,当 TestA调用到TestB的时候,JVM会使用加载了TestA的类加载器来加载TestB。依次类推,TestA引用的所有jar包里的类都会被自定义类加载器加载。通过这种方式,我们只要让各个模块的main方法类使用各自专属的类加载器加载,这样就能让各个模块的所有类都由不同的类加载器加载。这也是OSGi能够实现类隔离的核心原理。
自定义类加载器实现类隔离
了解了类隔离的实现原理之后,我们从自定义类加载器开始进行实操,自定义类加载器必须继承抽象类java.lang.ClassLoader,然后重写类加载的方法,这里有两个选择,重写findClass(String name)或者重写loadClass(String name),那么到底应该选择重写哪一个呢?以下是类java.lang.ClassLoader中的loadClass(String name)方法与findClass(String name)方法的源码。
/**
* Finds the class with the specified <a href="#name">binary name</a>.
* This method should be overridden by class loader implementations that
* follow the delegation model for loading classes, and will be invoked by
* the {@link #loadClass <tt>loadClass</tt>} method after checking the
* parent class loader for the requested class.
* 以下方法要被继承了java.lang.ClassLoader类的自定义的classLoader覆写
* findClass方法会在loadClass方法中在经过父加载器委托加载失败后会被调用
*
*/
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 检查类是否已经被加载过,防止重复加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 先层层向上交给父加载器来加载
if (parent != null) {
// 这里是递归调用,递归深度取决于类加载器继续的深度
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
// 父加载器未能加载成功,就只能自己加载
if (c == null) {
// If still not found, then invoke findClass in order to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
抽象类java.lang.ClassLoader中的loadClass方法中实现了双亲委派的核心逻辑,加载类时先检查指定类是否已经加载过,如果没有加载过,则层层递归调用父加载器的loadClass方法,委托父加载器来加载,如果父加载器都加载不了就调用findClass方法自己加载。
通过注释我们知道自定义类加载器是必须要覆写findClass方法的,是否要覆写loadClass方法则取决于是否想要打破双亲委派。如果不想打破双亲委派机制,自定义类加载器覆写findClass方法即可,如果要打破双亲委派机制,就要覆写整个loadClass方法。
下面分别尝试重写这两个方法来实现自定义类加载器。
重写findClass方法
首先我们定义两个类,TestA会打印自己的类加载器,然后调用 TestB打印它的类加载器,我们的预期是自定义重写了findClass方法的类加载器 MyClassLoader能够在加载了TestA之后,也去加载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());
}
}
然后写一个自定义类加载器MyClassLoader,重写findClass方法,这个方法先根据文件路径加载class文件,然后调用defineClass获取Class对象。
public class MyClassLoader extends ClassLoader{
private Map<String, String> classPathMap = new HashMap<>();
public MyClassLoader() {
classPathMap.put("com.geekbang.cs.classloader.TestA",
"/Users/xxxxx/IdeaProjects/geekbang-cs/out/production/com/geekbang/cs/classloader/TestA.class");
classPathMap.put("com.geekbang.cs.classloader.TestB",
"/Users/xxxxx/IdeaProjects/geekbang-cs/out/production/com/geekbang/cs/classloader/TestB.class");
}
/**
* 重写了 findClass 方法
*/
@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 {
// 这里用自定义类加载器来加载TestA
MyClassLoader myClassLoader = new MyClassLoader();
Class testAClass = myClassLoader.findClass("com.geekbang.cs.classloader.TestA");
Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);
mainMethod.invoke(null, new Object[] {args});
}
}
执行结果如下:
TestA: com.geekbang.cs.classloader.MyClassLoader@511d50c0
TestB: sun.misc.Launcher$AppClassLoader@18b4aac2
执行的结果并没有如我们预期的那样,TestA确实是由MyClassLoader加载的,但是TestB还是AppClassLoader加载的,这是为什么呢?其实只要认真看了前面java.lang.ClassLoader类的loadClass方法源码就知道,原因就在于双亲委派。TestA类中引用到了TestB,在加载TestB类时确实是MyClassLoader发起加载流程的,但是在loadClass方法中,是先委托给父加载器来加载,如果父加载器找不到TestB类,才会调用MyClassLoader覆写的findClass方法进行加载。而TestB就这样被委托给了MyClassLoader的父加载器AppClassLoader来加载了。

那么为什么MyClassLoader的父加载器是AppClassLoader呢?
因为我们定义的main方法类默认情况下都是由JDK自带的AppClassLoader加载的,根据类加载传导规则,main类引用的MyClassLoader类也得由加载了main类的AppClassLoader来加载(这里不要被绕晕)。由于MyClassLoader的父类是java.lang.ClassLoader,java.lang.ClassLoader的默认构造方法如下图所示,会自动设置父加载器的值为AppClassLoader。

从试验效果来看,要想用自定义类加载器去加载指定模块内的全部因,实现类隔离就必须打破双亲委派,自定义类加载器必须重写loadClass方法。
重写loadClass方法
定义一个重写了loadClass方法的类加载器MyClassLoaderCustom。这里注意一点,我们重写了loadClass方法也就是意味着TestA类用到的所有类包括java.lang包里面的类都会通过MyClassLoaderCustom进行加载,但类隔离的目标不包括这部分JDK自带的类,所以我们在代码中将ExtClassLoader传入,作为MyClassLoaderCustom类的一个成员,对于JDK自带的类就由ExtClassLoader来加载,并且它一定能加载成功,对于应用程序中自行定义的类,就可以由MyClassLoaderCustom自行加载。
public class MyClassLoaderCustom extends ClassLoader {
private ClassLoader jdkClassLoader;
private Map<String, String> classPathMap = new HashMap<>();
// 构造函数,将ExtClassLoader传入,作为jdkClassLoader
public MyClassLoaderCustom(ClassLoader jdkClassLoader) {
this.jdkClassLoader = jdkClassLoader;
classPathMap.put("com.geekbang.cs.classloader.TestA",
"/Users/xxxxx/IdeaProjects/geekbang-cs/out/production/com/geekbang/cs/classloader/TestA.class");
classPathMap.put("com.geekbang.cs.classloader.TestB",
"/Users/xxxxx/IdeaProjects/geekbang-cs/out/production/com/geekbang/cs/classloader/TestB.class");
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class result = null;
try {
//这里先使用jdkClassLoader来加载jdk自带的类
result = jdkClassLoader.loadClass(name);
} catch (Exception e) {
//忽略
}
if (result != null) {
return result;
}
// jdkClassLoader找不到类时,就自行加载
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[] {};
}
}
TestA类与TestB类的代码不变,测试代码如下:
public class MyTest {
public static void main(String[] args) throws Exception {
// 这里取AppClassLoader的父加载器也就是ExtClassLoader
// 作为MyClassLoaderCustom的成员jdkClassLoader
MyClassLoaderCustom myClassLoaderCustom = new MyClassLoaderCustom(Thread.currentThread().getContextClassLoader().getParent());
Class testA = myClassLoaderCustom.loadClass("com.geekbang.cs.classloader.TestA");
Method testAmainMethod = testA.getDeclaredMethod("main", String[].class);
testAmainMethod.invoke(null, new Object[] {args});
}
}
执行结果如下:
TestA: com.geekbang.cs.classloader.MyClassLoaderCustom@511d50c0
TestB: com.geekbang.cs.classloader.MyClassLoaderCustom@511d50c0
结果符合预期,可以看到,通过重写loadClass方法打破双亲委派,我们成功的让TestB类也使用MyClassLoaderCustom加载到了JVM中。
总结
类隔离技术是为了解决依赖冲突而诞生的,它通过自定义类加载器打破双亲委派机制,设置一套自己的类加载逻辑,然后利用类加载传导规则实现了不同模块的类隔离。