Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

ClassLoader做什么的?

顾名思义,它是用来加载Class的。它负责将Class的字节码形式转换成内存形式的Class对象。字节码可以来自于磁盘文件*.class,也可以是jar包里的*.class,也可以来自远程服务器提供的字节流,字节码的本质就是一个字节数组[]byte,它有特定的复杂的内部格式。

有很多字节码加密技术就是依靠定制ClassLoader来实现的。先使用工具对字节码文件进行加密,运行时使用定制的ClassLoader先解密文件内容再加载这些解密后的字节码。

每个Class对象的内部都有一个classLoader字段来标识自己是由哪个ClassLoader加载的。ClassLoader就像一个容器,里面装了很多已经加载的Class对象。

1
2
3
4
5
classClass<T> {
...
privatefinalClassLoaderclassLoader;
...
}

延迟加载

JVM运行并不是一次性加载所需要的全部类的,它是按需加载,也就是延迟加载。程序在运行的过程中会逐渐遇到很多不认识的新类,这时候就会调用ClassLoader来加载这些类。加载完成后就会将Class对象存在ClassLoader里面,下次就不需要重新加载了。

比如你在调用某个类的静态方法时,首先这个类肯定是需要被加载的,但是并不会触及这个类的实例字段,那么实例字段的类别Class就可以暂时不必去加载,但是它可能会加载静态字段相关的类别,因为静态方法会访问静态字段。而实例字段的类别需要等到你实例化对象的时候才可能会加载。

各司其职

JVM运行实例中会存在多个ClassLoader,不同的ClassLoader会从不同的地方加载字节码文件。它可以从不同的文件目录加载,也可以从不同的jar文件中加载,也可以从网络上不同的服务地址来加载。

JVM中内置了三个重要的ClassLoader,分别是BootstrapClassLoaderExtensionClassLoaderAppClassLoader

BootstrapClassLoader 负责加载JVM运行时核心类,这些类位于JAVA_HOME/lib/rt.jar文件中,我们常用内置库java.xxx.*都在里面,比如java.util.*java.io.*java.nio.*java.lang.*等等。这个ClassLoader比较特殊,它是由C代码实现的,我们将它称之为「根加载器」

ExtensionClassLoader 负责加载VM扩展类,比如swing系列、内置的js引擎、xml解析器等等,这些库名通常以javax开头,它们的jar包位于JAVA_HOME/lib/ext/*.jar中,有很多jar包。

AppClassLoader 才是直接面向我们用户的加载器,它会加载Classpath环境变量里定义的路径中的jar包和目录。我们自己编写的代码以及使用的第三方jar包通常都是由它来加载的。

那些位于网络上静态文件服务器提供的jar包和class文件,jdk内置了一个URLClassLoader,用户只需要传递规范的网络路径给构造器,就可以使用URLClassLoader来加载远程类库了。URLClassLoader不但可以加载远程类库,还可以加载本地路径的类库,取决于构造器中不同的地址形式。ExtensionClassLoaderAppClassLoader都是URLClassLoader的子类,它们都是从本地文件系统里加载类库。

AppClassLoader可以由ClassLoader类提供的静态方法getSystemClassLoader()得到,它就是我们所说的「系统类加载器」,我们用户平时编写的类代码通常都是由它加载的。当我们的main方法执行的时候,这第一个用户类的加载器就是AppClassLoader

ClassLoader传递性

程序在运行过程中,遇到了一个未知的类,它会选择哪个ClassLoader来加载它呢?虚拟机的策略是使用调用者Class对象的ClassLoader来加载当前未知的类。何为调用者Class对象?就是在遇到这个未知的类时,虚拟机肯定正在运行一个方法调用(静态方法或者实例方法),这个方法挂在哪个类上面,那这个类就是调用者Class对象。前面我们提到每个Class对象里面都有一个classLoader属性记录了当前的类是由谁来加载的。

因为ClassLoader的传递性,所有延迟加载的类都会由初始调用main方法的这个ClassLoader全全负责,它就是AppClassLoader

双亲委派

前面我们提到AppClassLoader只负责加载Classpath下面的类库,如果遇到没有加载的系统类库怎么办,AppClassLoader必须将系统类库的加载工作交给BootstrapClassLoaderExtensionClassLoader来做,这就是我们常说的「双亲委派」

AppClassLoader在加载一个未知的类名时,它并不是立即去搜寻Classpath,它会首先将这个类名称交给ExtensionClassLoader来加载,如果ExtensionClassLoader可以加载,那么AppClassLoader就不用麻烦了。否则它就会搜索Classpath。

而ExtensionClassLoader在加载一个未知的类名时,它也并不是立即搜寻ext路径,它会首先将类名称交给BootstrapClassLoader来加载,如果BootstrapClassLoader可以加载,那么ExtensionClassLoader也就不用麻烦了。否则它就会搜索ext路径下的jar包。

这三个ClassLoader之间形成了级联的父子关系,每个ClassLoader都很懒,尽量把工作交给父亲做,父亲干不了了自己才会干。每个ClassLoader对象内部都会有一个parent属性指向它的父加载器。

1
2
3
4
5
classClassLoader {
...
privatefinalClassLoaderparent;
...
}

值得注意的是图中的ExtensionClassLoader的parent指针画了虚线,这是因为它的parent的值是null,当parent字段是null时就表示它的父加载器是「根加载器」。如果某个Class对象的classLoader属性值是null,那么就表示这个类也是「根加载器」加载的。

Class.forName

当我们在使用jdbc驱动时,经常会使用Class.forName方法来动态加载驱动类。

Class.forName("com.mysql.cj.jdbc.Driver");

其原理是mysql驱动的Driver类里有一个静态代码块,它会在Driver类被加载的时候执行。这个静态代码块会将mysql驱动实例注册到全局的jdbc驱动管理器里。


class Driver {
  static {
    try {
       java.sql.DriverManager.registerDriver(new Driver());
    } catch (SQLException E) {
       throw new RuntimeException("Can't register driver!");
    }
  }
  ...
}

forName方法同样也是使用调用者Class对象的ClassLoader来加载目标类。不过forName还提供了多参数版本,可以指定使用哪个ClassLoader来加载
Class forName(String name, boolean initialize, ClassLoader cl)
通过这种形式的forName方法可以突破内置加载器的限制,通过使用自定类加载器允许我们自由加载其它任意来源的类库。根据ClassLoader的传递性,目标类库传递引用到的其它类库也将会使用自定义加载器加载。

自定义加载器

ClassLoader里面有三个重要的方法loadClass()findClass()defineClass()

loadClass()方法是加载目标类的入口,它首先会查找当前ClassLoader以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用findClass()让自定义加载器自己来加载目标类。ClassLoader的findClass()方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用defineClass()方法将字节码转换成Class对象。下面我使用伪代码表示一下基本过程


class ClassLoader {

  // 加载入口,定义了双亲委派规则
  Class loadClass(String name) {
    // 是否已经加载了
    Class t = this.findFromLoaded(name);
    if(t == null) {
      // 交给双亲
      t = this.parent.loadClass(name)
    }
    if(t == null) {
      // 双亲都不行,只能靠自己了
      t = this.findClass(name);
    }
    return t;
  }

  // 交给子类自己去实现
  Class findClass(String name) {
    throw ClassNotFoundException();
  }

  // 组装Class对象
  Class defineClass(byte[] code, String name) {
    return buildClassFromCode(code, name);
  }
}

class CustomClassLoader extends ClassLoader {

  Class findClass(String name) {
    // 寻找字节码
    byte[] code = findCodeFromSomewhere(name);
    // 组装Class对象
    return this.defineClass(code, name);
  }
}

自定义类加载器不易破坏双亲委派规则,不要轻易覆盖loadClass方法。否则可能会导致自定义加载器无法加载内置的核心类库。在使用自定义加载器时,要明确好它的父加载器是谁,将父加载器通过子类的构造器传入。如果父类加载器是null,那就表示父加载器是「根加载器」。


// ClassLoader 构造器
protected ClassLoader(String name, ClassLoader parent);

双亲委派规则可能会变成三亲委派,四亲委派,取决于你使用的父加载器是谁,它会一直递归委派到根加载器。

Class.forName vs ClassLoader.loadClass

这两个方法都可以用来加载目标类,它们之间有一个小小的区别,那就是 Class.forName() 方法可以获取原生类型的 Class,而 ClassLoader.loadClass() 则会报错。


Class x = Class.forName("[I");
System.out.println(x);

x = ClassLoader.getSystemClassLoader().loadClass("[I");
System.out.println(x);

---------------------
class [I

Exception in thread "main" java.lang.ClassNotFoundException: [I
...

钻石依赖

项目管理上有一个著名的概念叫着「钻石依赖」,是指软件依赖导致同一个软件包的两个版本需要共存而不能冲突。

我们平时使用的 maven 是这样解决钻石依赖的,它会从多个冲突的版本中选择一个来使用,如果不同的版本之间兼容性很糟糕,那么程序将无法正常编译运行。Maven 这种形式叫「扁平化」依赖管理。

使用 ClassLoader 可以解决钻石依赖问题。不同版本的软件包使用不同的 ClassLoader 来加载,位于不同 ClassLoader 中名称一样的类实际上是不同的类。下面让我们使用 URLClassLoader 来尝试一个简单的例子,它默认的父加载器是 AppClassLoader


$ cat ~/source/jcl/v1/Dep.java
public class Dep {
    public void print() {
        System.out.println("v1");
    }
}

$ cat ~/source/jcl/v2/Dep.java
public class Dep {
 public void print() {
  System.out.println("v1");
 }
}

$ cat ~/source/jcl/Test.java
public class Test {
    public static void main(String[] args) throws Exception {
        String v1dir = "file:///Users/MaijiaGod/source/jcl/v1/";
        String v2dir = "file:///Users/MaijiaGod/source/jcl/v2/";
        URLClassLoader v1 = new URLClassLoader(new URL[]{new URL(v1dir)});
        URLClassLoader v2 = new URLClassLoader(new URL[]{new URL(v2dir)});

        Class depv1Class = v1.loadClass("Dep");
        Object depv1 = depv1Class.getConstructor().newInstance();
        depv1Class.getMethod("print").invoke(depv1);

        Class depv2Class = v2.loadClass("Dep");
        Object depv2 = depv2Class.getConstructor().newInstance();
        depv2Class.getMethod("print").invoke(depv2);

        System.out.println(depv1Class.equals(depv2Class));
   }
}

在运行之前,我们需要对依赖的类库进行编译

1
2
3
4
5
6
7
8
9
10
$ cd ~/source/jcl/v1
$ javac Dep.java
$ cd ~/source/jcl/v2
$ javac Dep.java
$ cd ~/source/jcl
$ javac Test.java
$ java Test
v1
v2
false

在这个例子中如果两个 URLClassLoader 指向的路径是一样的,下面这个表达式还是 false,因为即使是同样的字节码用不同的 ClassLoader 加载出来的类都不能算同一个类

depv1Class.equals(depv2Class)

我们还可以让两个不同版本的 Dep 类实现同一个接口,这样可以避免使用反射的方式来调用 Dep 类里面的方法。


Classdepv1Class=v1.loadClass("Dep");
IPrintdepv1=(IPrint)depv1Class.getConstructor().newInstance();
depv1.print()

ClassLoader固然可以解决依赖冲突问题,不过它也限制了不同软件包的操作界面必须使用反射或接口的方式进行动态调用。Maven没有这种限制,它依赖于虚拟机的默认懒惰加载策略,运行过程中如果没有显示使用定制的ClassLoader,那么从头到尾都是在使用AppClassLoader,而不同版本的同名类必须使用不同的ClassLoader加载,所以Maven不能完美解决钻石依赖。
如果你想知道有没有开源的包管理工具可以解决钻石依赖的,我推荐你了解一下sofa-ark,它是蚂蚁金服开源的轻量级类隔离框架。

分工与合作

这里我们重新理解一下ClassLoader的意义,它相当于类的命名空间,起到了类隔离的作用。位于同一个ClassLoader里面的类名是唯一的,不同的ClassLoader可以持有同名的类。ClassLoader是类名称的容器,是类的沙箱。

不同的ClassLoader之间也会有合作,它们之间的合作是通过parent属性和双亲委派机制来完成的。parent具有更高的加载优先级。除此之外,parent还表达了一种共享关系,当多个子ClassLoader共享同一个parent时,那么这个parent里面包含的类可以认为是所有子ClassLoader共享的。这也是为什么BootstrapClassLoader被所有的类加载器视为祖先加载器,JVM核心类库自然应该被共享。

Thread.contextClassLoader

如果你稍微阅读过 Thread 的源代码,你会在它的实例字段中发现有一个字段非常特别


class Thread {
  ...
  private ClassLoader contextClassLoader;

  public ClassLoader getContextClassLoader() {
    return contextClassLoader;
  }

  public void setContextClassLoader(ClassLoader cl) {
    this.contextClassLoader = cl;
  }
  ...
}

contextClassLoader「线程上下文类加载器」,这究竟是什么东西?

首先 contextClassLoader 是那种需要显示使用的类加载器,如果你没有显示使用它,也就永远不会在任何地方用到它。你可以使用下面这种方式来显示使用它


Thread.currentThread().getContextClassLoader().loadClass(name);

这意味着如果你使用 forName(string name) 方法加载目标类,它不会自动使用 contextClassLoader。那些因为代码上的依赖关系而懒惰加载的类也不会自动使用 contextClassLoader来加载。

其次线程的contextClassLoader是从父线程那里继承过来的,所谓父线程就是创建了当前线程的线程。程序启动时的main线程的contextClassLoader就是AppClassLoader。这意味着如果没有人工去设置,那么所有的线程的contextClassLoader都是AppClassLoader。

那这个 contextClassLoader 究竟是做什么用的?我们要使用前面提到了类加载器分工与合作的原理来解释它的用途。

它可以做到跨线程共享类,只要它们共享同一个 contextClassLoader。父子线程之间会自动传递 contextClassLoader,所以共享起来将是自动化的。

如果不同的线程使用不同的 contextClassLoader,那么不同的线程使用的类就可以隔离开来。

如果我们对业务进行划分,不同的业务使用不同的线程池,线程池内部共享同一个 contextClassLoader,线程池之间使用不同的 contextClassLoader,就可以很好的起到隔离保护的作用,避免类版本冲突。

如果我们不去定制 contextClassLoader,那么所有的线程将会默认使用 AppClassLoader,所有的类都将会是共享的。

线程的 contextClassLoader 使用场合比较罕见,如果上面的逻辑晦涩难懂也不必过于计较。

JDK9 增加了模块功能之后对类加载器的结构设计做了一定程度的修改,不过类加载器的原理还是类似的,作为类的容器,它起到类隔离的作用,同时还需要依靠双亲委派机制来建立不同的类加载器之间的合作关系。