拿饭网对java的classLoader分析

  张一帆   2021年06月15日


  • 类加载机制

  虚拟机把描述类的数据从class文件加载到内存,并且进行校验、解析、初始化。最终形成可以直接使用的Class对象,这就是类加载机制。

  类加载并不是一次性把所有class文件都加载到JVM中的,而是按照需求来加载的。比如,JVM启动时,会通过不同的类的加载器加载不同的类。当用户在自己代码中,需要额外的类时,再通过加载机制加载到JVM中,并且存放一段时间,便于频繁使用。

  • 全盘委托,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示的使用另外一个类加载器加载。
  • 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。
  • 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,自由缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM的原因。

  • 类加载过程

  类从被加载到JVM内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,共7个阶段。其中验证、准备、解析3个部分统称为连接。这7个阶段的发生顺序如图:

类的生命周期

  其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

  下面详细介绍每个阶段所做的事情:


  • 加载

  加载时类加载过程的第一个阶段,在加载阶段,JVM需要完成以下三件事情:

  1. 通过一个类的全限定名来获取其定义的二进制字节流。
  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
  3. 在java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

  第一条中的二进制字节流并不只是单纯的从class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。

  相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

  加载完成以后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在java堆中也会创建一个java.lang.Class类的对象,这样便可以通过对象访问方法区中的这些数据。

  类加载器分为以下三类:

  1. 启动类加载器:Bootstrap ClassLoader,它负责加载放在JDK/jre/lib下,或者被-Xbootclasspath参数指定的路径中的,并且被虚拟机是别的类库。启动类加载器是无法被java程序直接启动的。它是C++实现的,是虚拟机的一部分。
  2. 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.launCher$ExtClassLoader实现,它负责加载JDK/jre/lib/ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  应用程序都是由这三种加载器相互配合进行加载的,如果有必要程序员还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做做到:

  1. 在执行非置信代码之前,自动验证数字签名。
  2. 动态地创建符合用户特定需要的定制化构建类。
  3. 从特定的场所取得java class,例如数据库中或者网络中。

双亲委派模型

  这种层次关系称为类加载器的双亲委派模型。他们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中代码。这不是一个强制性的约模型,而是java设计者们推荐给开发者的一种类的加载器实现方式。

  双亲委派的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它搜索范围中没有找到所需的类时,子加载器才会去尝试自己去完成加载。

为什么要使用这种模式呢?因为java中的类随着它的类加载器一起具备了一种带有优先级的层级关系。这样的好处是,避免了循环引用,而可以一直溯源到最父类。例如,java.lang.Object,他存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载类进行加载,因此Object类在程序的各种类加载器环境中都是能够保证是同一个类。同时,也防止了内存中出现同样的字节码。


  • 举例说明

  Dog dog = new Dog();

由new关键字创建一个类的实例。这个动作会导致常量池的解析,Dog类被隐式装在。如果当前ClassLoader无法找到Dog,则抛出NoClassDefFoundError

  try{
    Class clazz = Class.forName("Dog");
    Object dog = clazz.newInstance();
}catch (Exception e){
    System.out.println(e.getMessage());
}

通过反射加载类型并创建对象实例。如果无法找到Dog,则抛出ClassNotFoundException

  try{
    ClassLoader cl = new ClassLoader() {
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            return super.loadClass(name);
        }
    };
    Class clazz = cl.loadClass("Dog");
    Object dog = clazz.newInstance();
}catch (Exception e){
    System.out.println(e.getMessage());
}

通过反射加载类型并创建对象实例。如果无法找到Dog,则抛出ClassNotFoundException

上面三种有什么区别呢?分别用于什么情况呢?

1和2使用的类加载器是相同的,都是当前类加载器(this.getClass.getClassLoader)。3是用户指定的类加载器。如果需要在当前类路径以外寻找类,则只能用第3种方式。第3种方式加载的类与当前类分属不同的命名空间。当前类加载器命名空间对其不可见。当然,如果被加载类的超类对于当前类命名空间可见的话,则可以进行强制转型。第1种抛出error,第2,3种抛出Exception。


  • JDK9的双亲委派模式

JDK9为了模块化的支持,对双亲委派模式租了一些改动:

  1. 扩展类加载器被平台类加载器(Platform ClassLoader)取代,原来的rt.jar和tools.jar被拆分成数十个JMOD文件。
  2. 平台类加载器和应用程序类加载器都不再继承自java.netURLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader。

  3. 启动类加载器现在是在java虚拟机内部和java类库共同协作实现的类加载器(以前是C++实现)。为了与之前的代码保持兼容,所有在获取启动类加载器的场景中仍然会返回null来代替,而不会得到BootClassLoader的实例。
  4. 类加载的委派关系也发生了变动,当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责哪个模块的加载器完成加载。

jdk9双亲委派模型