转载

深入理解 Java 虚拟机 ~ 类的加载过程

本文主要内容:

  • 类的加载时机(主动引用、被动引用)
  • 类的加载过程(加载、验证、准备、解析、初始化)
  • 类加载器(ClassLoader)
    • 数组的类加载器
    • 双亲委派机制
    • 类加载器如何如此设计?
    • 自定义类加载器
    • 类加载器死锁问题

前言

我们已经在 深入理解 Java 虚拟机 ~ class字节码剖析 一文中详细介绍了 class 字节码文件的组成和字节码指令集。接下来就可以介绍 JVM 虚拟机如何去执行 class 字节码。Java 是一门面向对象的语言,我们的代码都是在 类(Class) 当中,所以在介绍虚拟机如何执行 class 字节码之前,我们需要先搞清 3 个问题?

  • 什么时候 JVM 虚拟机才会加载一个类?
  • 类的加载过程是什么?
  • 什么是类加载器?

类的加载时机

我们在开发的时候肯定会编写很多的类,那么 JVM 究竟在什么时候才会去加载这些类呢?

“加载” 这个动作实际上是类生命周期的某一个阶段。一个类从被加载到虚拟机内存到卸载出内存,它的生命周期为:

加载、验证、准备、解析、初始化、使用、卸载 7个阶段。其中验证、准备、解析这 3 个阶段统称为 “连接” ,如下图所示:

深入理解 Java 虚拟机 ~ 类的加载过程

需要的注意的是,其中加载、验证、准备、初始化和卸载这 5 个阶段顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。

实际上,Java虚拟机规范中对什么时候开始 加载阶段 并没有强制的约束,但是对于初始化阶段则有严格的规定。有且只有 5 种情况必须立即对类进行 初始化 ,如果一个类被初始化了,那么它的 加载 、验证、准备阶段在此之前就完成了。

  • 遇到 new(创建对象)、getstatic(读取类的静态变量)、putstatic(设置类的静态变量值)、invokestatic(调用静态方法) 这 4 个字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化。
  • 反射一个类的时候,如果该类没有进行过初始化,则需要先触发初始化。
  • 当初始化一个类时,如果其父类还没有初始化过,需先触发其父类的初始化。
  • 当启动虚拟机时,用户需要指定一个入口类,虚拟机会先初始化这个类。
  • 当使用 JDK1.7 的动态语言支持时,如果 MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄对应的类还没有初始化过,需要先触发其初始化。

这 5 种场景中对类的引用称之为主动引用。除此之外所有的引用都不会触发初始化,将这种情况的引用称之为被动引用。下面举几个被动引用的例子:

案例一:使用父类的静态变量

class Parent {
    static {
        System.out.println("Parent init");
    }

    public static int count = 1;
}

class Son extends Parent {
    static {
        System.out.println("Son init");
    }
}

public static void main(String[] args) {
    System.out.println(Son.count);
}

复制代码

此时只会调用 Parent 的静态代码块,虽然 Son 不会被初始化,但是 Son 会被加载。我们可以通过 -XX:+TraceClassLoading 来监控类的加载情况:

深入理解 Java 虚拟机 ~ 类的加载过程

可见先加载了 Parent,然后在加载 Son,最后初始化了 Parent

案例二:通过数组引用类

public static void main(String[] args) {
    Son[] parents = new Son[10];
}
复制代码

并不会执行 Son 的静态代码块,只会加载 Parent 和 Son :

深入理解 Java 虚拟机 ~ 类的加载过程

但是会触发 [Lclass_load.Son] 类的初始化操作,数组类是由虚拟机自动生成的,创建动作通过 newarray 指令触发。

案例三:引用常量

public class ConstClass {
    static {
        System.out.println("ConstClass init");
    }

    public static final String HELLO_WORLD = "HelloWorld";

}

public static void main(String[] args) {
    System.out.println(ConstClass.HELLO_WORLD);
}

复制代码

这个时候也不会初始化 ConstClass 这个类。不仅不会初始化,连加载的操作都没有。因为常量在编译极端会存入调用类的常量池中,这样就和常量定义的类没有什么关系了。所以不会加载、初始化 ConstClass 类。

上面提到初始化一个类的时候需要先初始化父类,但是接口在初始化的时候,并不要求其父接口全部初始化完毕,只有真正用到父接口的时候(如引用接口中定义的常量)才会初始化。

虽然 Java 虚拟机规范没有对类的加载时机没有强制的约束,但是从上面的案例来看,一般用到了某个类都会加载该类(如果没有加载的话),除非引用的是该类中的常量。

类的加载过程

上面我们介绍了类的加载、初始化时机。下面我们开始介绍加载、验证、准备、解析、初始化这 5 个阶段执行哪些操作。

加载

在加载阶段,虚拟机做了以下 3 件事情:

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

字节流的来源并没有做严格的规定,只要是合法的字节码文件流即可。

类的加载阶段即可使用系统提供的 类加载器 来加载,也可以由自定义的类加载器来加载。

需要注意的是, 数组类 不是通过类加载器创建,它是通过 Java 虚拟机直接创建的。但是数组类型和类加载器仍有密切的关系。因为数据的元素的类型(Component type)可以是复杂类型,也可以使用基本类型。如果Component Type是复杂类型,那么数组的类加载器为 Component Type 的类加载器。如果 Component Type 是基本类型,那么数组的类加载器为 Bootstrap ClassLoader。关于类加载器后面会做详细介绍。

验证

验证这个阶段主要是验证Class文件的字节流包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段主要会完成 4 个检验动作:

  • 文件格式验证

    这一阶段主要验证字节流是否符合 class 文件的规范,并且能够被当前版本执行。

    例如,我们将模式 class 字节码文件的 magic 改成 cafe baee ,然后 java 命令运行该 class 文件提示错误:

    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 3405691630 in class file class_bytecode/Client
    复制代码

    在比如将 class 版本(34)改成高过当前版本的值(35),会提示:

    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.UnsupportedClassVersionError: class_bytecode/Client has been compiled by a more recent versi-
    on of the Java Runtime (class file version 53.0), this version of the Java Runtime only recognizes class file versions up to 52.0
    复制代码

    实际上验证的工作非常多,我这里只是举了 2 个例子,有需要的可以下载 JDK8 的 Hotspot 源码查看里面的验证流程: src/share/vm/classfile/classFileParser.cpp 。通过了所有的验证工作后,字节流才会进入内存的 方法区 进行存储,后面的 3 个验证阶段全部是基于方法区的存储结构进行的,不会再直接分析字节流。

  • 元数据验证

    这一阶段的验证主要是对字节码描述信息进行语义分析,保证描述信息符合 Java 语言规范。例如:

    • 这个类是否有父类

      if (super_class_index == 0) {
          check_property(_class_name == vmSymbols::java_lang_Object(),
                     "Invalid superclass index %u in class file %s",
                     super_class_index,
                     CHECK_NULL);
      }
      复制代码
    • 这个类是否继承了 final 的类

      // Make sure super class is not final
      if (super_klass->is_final()) {
          THROW_MSG_(vmSymbols::java_lang_VerifyError(), "Cannot inherit from final class", nullHandle);
      }
      复制代码

    这些语义分析同样可以在源码里找到: src/share/vm/classfile/classFileParser.cpp

  • 字节码验证

    字节码校验将会对 类的方法 进行校验分析,保证方法在运行时不会做出危害虚拟机安全的事情。例如执行方法的时候会有操作数栈,如果操作数栈里的元素类型和相关指令要求的类型不一致则会报错。例如下面的代码:

    public static void main(String[]args){
        long b = 10;
        System.out.println(b);
    }
    复制代码

    通过 Sublime 将 long 对应的常量池中的条目的 tag 从 5 改成 6,也就是改成了 double 类型了。将 double 赋值给 long 肯定会报错(VerifyError):

    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.VerifyError: Bad type on operand stack
    Exception Details:
      Location:
        class_bytecode/BytecodeVerify.main([Ljava/lang/String;)V @6: lstore_2
      Reason:
        Type double (current frame, stack[0]) is not assignable to long
    ...
    复制代码
  • 符号引用验证

    符号引用验证法正在虚拟机将符号引用转化为直接引用的时候。关于符号引用和直接引用将在后面详解介绍。

    这个阶段主要是验证类中引用到的常量池中的数据是否合法。例如:符号引用中通过全限定符能否早对应的类,在指定的类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段等等。符号引用的目的是确保解析动作能够正常执行。例如我们将上面的例子:

    public static void main(String[]args){
        long b = 10;
        System.out.println(b);
    }
    复制代码

    在常量池中将 println 的符号引用改成 printll ,运行程序将会得到错误:

    Exception in thread "main" java.lang.NoSuchMethodError: java.io.PrintStream.printll(J)V
        at class_bytecode.BytecodeVerify.main(BytecodeVerify.java:6)
    
    复制代码

通过上面的验证步骤来看,先从 class 字节码文件的结构开始验证,然后验证是否符合语言规范,接着验证类里的方法是否合法,最后验证符号引用是否合法,从而保证后面的解析能够顺序进行。可见这 4 个验证过程是一个从浅入深的过程。

准备

准备阶段是为类变量(静态变量)分配内存并设置变量初始值的阶段,这些变量使用的内存都将在 方法区 中进行分配。例如:

public class PrepareStatus {
    public static int count = 10;
}
复制代码

在准备阶段 value 变量的初始值是 0 而不是 10,因为在准备阶段没有执行任何方法。那么类变量 count 是什么时候赋值为 10 的呢?

类变量会在静态代码块中赋值,静态代码块对应的就是 方法。将 PrepareStatus 反编译:

// 静态变量
  public static int count;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC

  // 静态代码块 <clinit>
  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        10
         // 设置变量的值
         2: putstatic     #2                  // Field count:I
         5: return
      LineNumberTable:
        line 4: 0
复制代码

解析

解析阶段是虚拟机将常量池内的 符号引用 替换为 直接引用 的过程。

  • 符号引用

    符号引用(Symbolic Reference)以一组符合来描述引用的的目标,符号可以是任何形式的字面量。 符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。

  • 直接引用

    直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。 直接引用是和虚拟机实现的内存布局相关的。

查看 bytecodeInterpreter.cpp 文件的时候,发现通过 -XX:+TraceClassResolution 可以跟踪类的解析情况。

虚拟机规范没有规定解析阶段放生的具体的时间,只要求了 anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new, putfield, putstatic 这些指令执行的时候需要执行解析操作。

对于同一个符号引用解析多次是很常见的,除了 invokedynamic 指令外,虚拟机对第一次的解析结果缓存起来(在运行时常量池中记录直接引用,并把常量标记为已解析状态),从而避免解析动作重复执行。

上面这句话是 《深入理解Java虚拟机 - JVM 高级特性与最佳时间(第二版)》 这本书上的论述,但是我在下载的 Java1.8 Hotspot 中的源码中发现,除了 invokedynamic 会避免重复解析,上面的其他很多指令也有类似的逻辑。我们先来看下 invokedynamic 如:

// 文件位置:hotspot/src/share/vm/interpreter/bytecodeInterpreter.cpp

CASE(_invokedynamic): {

        // 判断 invokedynamic 指令是可以可用
        if (!EnableInvokeDynamic) {
                  handle_exception);
          ShouldNotReachHere();
        }

        u4 index = Bytes::get_native_u4(pc+1);
        ConstantPoolCacheEntry* cache = cp->constant_pool()->invokedynamic_cp_cache_entry_at(index);

        // 判断常量池中的 entry 是否已经解析
        if (! cache->is_resolved((Bytecodes::Code) opcode)) {
        
          // 如果没有解析,则执行解析动作
          CALL_VM(InterpreterRuntime::resolve_invokedynamic(THREAD),
                  handle_exception);
                  
          // 将解析过的 entry 赋值给 cache
          cache = cp->constant_pool()->invokedynamic_cp_cache_entry_at(index);
        }

        // 省略其他代码...
      }
复制代码

除了 invokedynamic ,还有 putfield、putstatic 指令也会判断是否已经解析过:

CASE(_putfield):
CASE(_putstatic):
{
  u2 index = Bytes::get_native_u2(pc+1);
  // 获取常量池中的 entry
  ConstantPoolCacheEntry* cache = cp->entry_at(index);
  // 判断 entry 是否被解析过
  if (!cache->is_resolved((Bytecodes::Code)opcode)) {
    CALL_VM(InterpreterRuntime::resolve_get_put(THREAD, (Bytecodes::Code)opcode),
            handle_exception);
    // 将解析过的 entry 赋值给 cache
    cache = cp->entry_at(index);
  }

  // 省略其他代码...  
}
复制代码

《深入理解Java虚拟机 - JVM 高级特性与最佳时间(第二版)》 这本书是基于 JDK 1.7,可能是因为版本的问题,然后我查看了JDK1.7 Hotspot 的 putfield、putstatic 也会判断是否已经解析:

CASE(_putfield):
CASE(_putstatic):
{
  u2 index = Bytes::get_native_u2(pc+1);
  // 获取常量池中的 entry
  ConstantPoolCacheEntry* cache = cp->entry_at(index);
  // 判断 entry 是否被解析过
  if (!cache->is_resolved((Bytecodes::Code)opcode)) {
    CALL_VM(InterpreterRuntime::resolve_get_put(THREAD, (Bytecodes::Code)opcode),
            handle_exception);
    // 将解析过的 entry 赋值给 cache
    cache = cp->entry_at(index);
  }

  // 省略其他代码...  
}
复制代码

类和接口的解析

假设在类 D 中,将未解析过的符号 N 解析为一个类或接口 C ,需要以下步骤:

  • 1)如果 C 不是数组类型,那么虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载类 C,在加载的过程中,由于元数据验证、字节码验证的需要,可能会触发其他类的加载操作,如果整个过程出现任何异常,整个解析过程则宣告失败。
  • 2)如果 C 是一个数组,并且数组的元素类型是对象,那么会按照 1)的流程去加载这个元素的类型。
  • 3)如果上面的步骤没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类和接口了。

字段的解析

字段首先是属于一个类,所以会去找常量池中字段对应的 class_index ,如果解析类或接口的过程都失败了,则字段解析宣告结束。如果成功,将类或接口用 C 表示,需要经过下面步骤找到字段:

  • 1)如果 C 本身就包含了简单名称和字段描述都与目标字段相匹配,则返回这个字段的直接引用。
  • 2)如果 C 实现了接口,则按照从下往上递归搜索各个接口和它的父接口,如果找到则直接返回。
  • 3)如果 C 不是 java.lang.Object,会按照继从下往上递归搜搜父类,如果找到直接返回。
  • 4)经过以上步骤都没有找到则抛出 java.lang.NoSuchFieldException 异常。

类中的方法解析

首先根据方法的 class_index 去解析看对应的类或接口,如果类或接口解析失败,则整个解析过程失败,如果解析成功,用 C 表示,需要经过下面步骤:

  • 1)如果 C 是接口,则抛出异常:java.lang.ImcompatibleClassChangeError 异常
  • 2)在 C 中查找是否有简单名称和描述符都与 目标匹配的方法。如果有则返回,查找结束。
  • 3)在 C 的父类中查找,如果有则返回直接引用,查找结束。
  • 4)在 C 实现的接口查找,如果存在说明 C 是一个抽象类,查找结束,抛出异常 java.lang.AbstractMethodError 异常。
  • 5)经过异常步骤都没有找到,则抛出异常 java.lang.NoSuchMethodError 异常。

接口中的方法解析

首先根据方法的 class_index 去解析看对应的类或接口,如果类或接口解析失败,则整个解析过程失败,如果解析成功,用 C 表示,需要经过下面步骤:

  • 1)如果 C 不是接口,则抛出异常:java.lang.ImcompatibleClassChangeError 异常
  • 2)在 C 中查找是否有简单名称和描述符都与 目标匹配的方法。如果有则返回,查找结束。
  • 3)在接口 C 的符接口中递归查找,直到 java.lang.Object(包含)查看是否能找到匹配的方法,如果有,返回方法的直接引用,查找结束。
  • 4)经过异常步骤都没有找到,则抛出异常 java.lang.NoSuchMethodError 异常。

初始化

初始化阶段是 类加载 过程的最后一步,初始化阶段,才真正才是执行类中定义的代码。

上面在介绍准备阶段的时候,我们提到准备阶段会为类变量赋过初始值,在静态代码块中为类变量赋值开发者设置的值。

静态代码块编译后变成一个叫 的方法。初始化阶段就是执行这个 方法的。我们知道 对象的构造方法 编译后为

下面开始介绍下 方法:

  • <clinit> 方法是由编译器自动收集所有类变量的赋值动作,如果开发者定义了自己的静态代码块,则会合并用户编写的静态代码块。编译器收集类变量的时候是根据类变量的定义先后顺序决定的,静态代码块总只能方法到定义在静态语句块之前的变量,定义在其之后的变量,静态代码块只能赋值,不能访问,例如:

    public class InitState {
        static {
            i = 10;                // 可以赋值
            System.out.println(i); //illegal forward reference
        }
        static int i = 0;
    }
    复制代码
  • <clinit> 和 对象构造器 <init> 不同,在对象的构造器中会调用父类的构造器,而 <clinit> 不会调用父类的 <clinit> 方法,虚拟机会保证父类的 <clinit> 方法要比子类先执行。

  • 虚拟机会保证一个类的 <clinit> 方法在多线程的环境下会被正确的加锁、同步。所以多个线程同时去初始化一个类,只会有一个线程去执行类的 <clinit> 方法,其他的线程在阻塞等待。

类加载器

上面介绍完了类加载的整个过程,但是在 加载阶段 还有一个重要的概念没有介绍,那就是类加载器。

加载阶段 通过类的全限定名来获取此类的二进制字节流。获取二进制字节流的动作就是 类加载器 来做的。

这个字节流可是一个 class 字节文件 ,也可以是 一个 jar 文件 ,这些文件可以是本地,也可以是来自网络,也可以是虚拟机动态生成的,只要符合虚拟机规范即可。

Java 是面向对象语言,那么 Java 中的类也是一类事物,也需要有一个东西来描述,在 Java 中有一个类叫 Class,就是用来描述所有的类。

要使用某个类,首先要获取这个类对应的 Class 对象,类加载器(ClassLoader)就是用来加载类的二进制字节流的,然后产出 Class 对象,从而 JVM 才能使用这个类。

所以 Class 对象是 class 字节码文件在 JVM 层面的化身,有了这个 Class 对象,就可以 new 出这个类的实例对象,调用方法等等。

在每个 Class 对象中都有一个方法叫做 getClassLoader() 用来获取该 Class 是由哪个 ClassLoader 加载的。

Java 提供了 3 个类加载器,他们分别是:

  • BootstrapClassLoader

    用于加载 JRE/lib/rt.jar 里的 class,JDK系统的类库基本上都在这里

  • ExtClassLoader

    用于加载 JRE/lib/ext/* 文件夹下所有的 class

  • AppClassLoader

    用于加载 CLASSPATH 目录下所有的 class,也就是开发者编写的类

其中 BootstrapClassLoader 是 C++ 编写的,ExtClassLoader 和 AppClassLoader 是 Java 编写的,它们都继承自 java.lang.ClassLoader 这个类。

数组的类加载器

我们都知道数组也是一个引用类型,但是我们找不到数组这个类定义在哪里。通常情况下,我们使用的类要那么是 JDK 提供的,要么是开发者编写的,或者第三类库提供的,但是数组这个复杂类型我们找不到它的定义。

其实数组的 class 是有 Java 虚拟机动态帮我们生成的,这个类继承了 Object ,实现了 Serializable 接口。

有了数组的 class 字节流,那么是哪个类加载器来加载呢?

根据 java.lang.ClassLoader 的注释文档:

* Class objects for array classes are not created by class
* loaders, but are created automatically as required by the Java runtime.
* The class loader for an array class, as returned by 
* Class.getClassLoader() is the same as the class loader for its element
* type; if the element type is a primitive type, then the array class has no
* class loader.
复制代码

意思就是说:数组的 Class 对象不是由 ClassLoader 创建的,而是 Java 运行时根据需要自动创建的。数组 class 的 ClassLoader 就是数组元素的 ClassLoader,如果数组元素类型是基本类型,那么这个数组就没有 ClassLoader

上面介绍类加载器的时候提到,类加载器就是加载字节码文件创建 Class 对象的。既然数组类的 Class 对象不是 ClassLoader 创建的,那为什么还要为数组的 Class 设置 ClassLoader 呢?因为数组也是一个类,这个类里也有可能用到了其他类,如果数组类的 Class 没有 ClassLoader,那么没办法加载它引用到的其他类。

注意:Class 对象不一定是 ClassLoader 创建的,例如数组的 Class 对象。

java.lang.ClassLoader 的注释文档提到:数组 class 的 ClassLoader 就是数组元素的 ClassLoader。例如:

// MyClass 使我们自己定义的类
MyClass[] arr = new MyClass[1];
System.out.println(arr.getClass().getClassLoader());

// 输出结果:
sun.misc.Launcher$AppClassLoader@18b4aac2

复制代码

因为我们定义的 MyClass 的 ClassLoader 是 AppClassLoader,所以数组 class 的 ClassLoader 就是 AppClassLoader

java.lang.ClassLoader 的注释文档提到:如果数组元素的基本数据类型,那么数组 class 就没有 ClassLoader。例如:

int[] arrInt = new int[1];
System.out.println(arrInt.getClass().getClassLoader());

// 输出结果:
null
复制代码

那么输出 null 就一定没有 ClassLoader?

Object[] arr = new Object[1];
System.out.println(arr.getClass().getClassLoader());

// 输出结果:
null
复制代码

其实 Object 的 ClassLoader 是 BootstrapClassLoader,所以 Object[] 的 ClassLoader 也是 BootstrapClassLoader。因为 BootstrapClassLoader 是 C++ 编写的,所以 Java 方法获取它,返回的是 null,

那么基本数据数组,如 int[] 的 ClassLoader 是不是也是 BootstrapClassLoader,根据 Hotspot 的源码可以看到:

// 代码位置:hotspot/agent/src/share/classes/sun/jvm/hotspot/jdi/ArrayTypeImpl

public ClassLoaderReference classLoader() {
    if (ref() instanceof TypeArrayKlass) {
        // primitive array klasses are loaded by bootstrap loader
        return null;
    } else {
        Klass bottomKlass = ((ObjArrayKlass)ref()).getBottomKlass();
        if (bottomKlass instanceof TypeArrayKlass) {
            // multidimensional primitive array klasses are loaded by bootstrap loader
            return null;
        } else {
            // class loader of any other obj array klass is same as the loader
            // that loaded the bottom InstanceKlass
            Instance xx = (Instance)(((InstanceKlass) bottomKlass).getClassLoader());
            return vm.classLoaderMirror(xx);
        }
    }
}
复制代码

根据源码注释来看呢,基本数据类型的数组,它的 ClassLoader 是 BootstrapClassLoader,通过 getClassLoader() 方法获取,返回 null

双亲委派机制

上面我们介绍了 Java 提供的 3 个类加载器以及它们的功能。在类加载的时候实际上执行的是一种委托机制,业界一般称之为:双亲委派机制

什么是双亲委派机制呢?就是加载一个类的时候把这个加载任务交给父加载器,父加载器收到这个请求后,也把这个请求交给自己的父加载器,以此类推。所以任何一个类加载操作一开始都会到最顶层的类加载器。如果最顶层的类加载无法去加载,那么这个加载任务再向下逐级传递。如果都无法无加载,则提示找不到类。

下面是 Java 提供的 3 个类加载器的父子关系:

深入理解 Java 虚拟机 ~ 类的加载过程

我们可以通过一个简单的程序打印出他们的父子关系:

public class Test {
    public static void main(String[] args) {
        // 自己编写的 Test 类,类加载器是 AppClassLoader
        ClassLoader loader = Test.class.getClassLoader();
        while (loader != null) {
            // 打印当前的类加载器
            System.out.println(loader);
            // 获取父加载器
            loader = loader.getParent();
        }
        System.out.println(loader);
    }
}

// 输出结果:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null

复制代码

java.lang.ClassLoader 有一个 parent 属性就是表示父加载器的。AppClassLoader 的父加载器是 ExtClassLoader,ExtClassLoader 的父加载器是 Bootstrap ClassLoader,但是 ExtClassLoader 的 getParent() 方法返回 null,这是因为 BootstrapClassLoader 是 C++ 编写。BootstrapClassLoader 加载器是最顶层的类加载器。

双亲委派制针对 JDK 为我们提供的 3 个类加载器的,如果下面有我们自己定义的类加载器,那就不是双亲委派了,而是 N 亲委派了。

关于类加载的双亲委派机制,我们还可以看看 java.lang.ClassLoader 源码:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 检查加载的类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        // 如果没有被加载过
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 如果有父加载器
                if (parent != null) {
                    // 交给父加载器去加载
                    c = parent.loadClass(name, false);
                } else {
                    // 如果 parent = null,说明当前的类加载器是 Bootstrap ClassLoader
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                long t1 = System.nanoTime();
                // 如果父类找不到,则调用自己的 findClass 去加载
                c = findClass(name);
                // 省略其他代码...
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

复制代码

可见类加载的委托机制实际上是一个递归调用。 loadClass() 方法触发了这个这个递归, loadClass() 如果没有找到类,那么 loadClass() 方法里面会调用 findClass() 来进行类加载,所以真正的类加载操作要么在 loadClass() 方法里,要么在 findClass() 方法里。

类加载器如何如此设计?

有读者可能会问:不就是加载类嘛,为什么要高搞这么多层次的类加载器?一个类加载器加载所有的类不可以吗?

Java 将类加载器设计成递归委托机制,有很多好处。比如安全性上:

如果自定义了很多类加载器,前提是都是符合委托机制的,那么加载 JDK 系统的类库时,都会优先使用 Bootstrap ClassLoader 来加载。对于加载 Object 类,那么内存中只会有一份 Object 的 Class 对象。因为对于同一个类,不同的类加载器去加载,他们的 Class 是不相等的。例如:

public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {

    // 自定义的 ClassLoader
    Class klass1 = new MyClassLoader().loadClass("SimpleClass");

    //AppClassLoader
    Class klass2 = ClassLoader.getSystemClassLoader().loadClass("class_bytecode.SimpleClass");

    //判断 Class 对象是否相等
    System.out.println(klass1 == klass2);

    // instanceof
    System.out.println(klass1.newInstance() instanceof class_bytecode.SimpleClass); // false
    // 因为都是由 AppClassLoader 加载的
    System.out.println(klass2.newInstance() instanceof class_bytecode.SimpleClass); // true
}
复制代码

类加载器的委托机制,保证了系统提供的类库由系统的类加载器去加载。

其实 Java 也在类加载器上做了很多安全的工作,比如对于我们想访问 JDK 类库中的 protected 方法,通常我们可以在项目中新建一个和这个类库相同的包名,比如 java.lang ,Java 已经在类加载器这一层做了限制,加载的时候会抛出异常,比如下面的类的包名为就是 java.lang:

package java.lang;

public class Test {
    public static void main(String[]args){
        System.out.println("------");
    }
}

复制代码

运行的时候出错:

java.lang.SecurityException: Prohibited package name: java.lang
	at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
	at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
	at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
	at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
复制代码

我们稍微分析下 Java 是在哪里做了限制:

// ClassLoader.java

private ProtectionDomain preDefineClass(String name,
                                        ProtectionDomain pd)
{
    if (!checkName(name))
        throw new NoClassDefFoundError("IllegalName: " + name);

    // Note:  Checking logic in java.lang.invoke.MemberName.checkForTypeAlias
    // relies on the fact that spoofing is impossible if a class has a name
    // of the form "java.*"
    if ((name != null) && name.startsWith("java.")) {
        throw new SecurityException
            ("Prohibited package name: " +
             name.substring(0, name.lastIndexOf('.')));
    }
    if (pd == null) {
        pd = defaultDomain;
    }

    if (name != null) checkCerts(name, pd.getCodeSource());

    return pd;
}

复制代码

可以看出,原来只要 name 是以 java 开头就会提示错误。我们再来看下是谁调用了 preDefineClass 方法:

// ClassLoader.java

protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                     ProtectionDomain protectionDomain)
    throws ClassFormatError
{
    protectionDomain = preDefineClass(name, protectionDomain);
    String source = defineClassSourceLocation(protectionDomain);
    Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
    postDefineClass(c, protectionDomain);
    return c;
}

复制代码

defineClass 方法用于将字节流转成 Class 对象,在该方法里会调用 preDefineClass 方法校验类的包名。

需要注意的是,类加载器的委托机制只是 Java 建议的机制,也有很多框架不是基于双亲委派机制的,所以不要提到类加载器就一定是双亲委派机制。类加载器是 Java 用于加载 Class 字节码的一种技术规范,至于类加载器之间是不是用委托机制,并不是强制的,可以自由发挥。例如 Tomcat 的热加载技术、OSGi技术 都没遵循双亲委派机制。

自定义类加载器

我们知道了类加载就是加载class字节码流,然后产生 Class 对象的。我们只要指定了字节码文件不就可以了,所以自定义类加载器很简单。

经过上面对 ClassLoader 的源码分析,我们可以在 loadClass 或 findClass 方法里将字节流转成 Class 对象。Java 官方建议我们通过重载 findClass 方法而不是 loadClass方法来自定义类加载器。下面的自定类加载将采用重载 findClass() 的方式。

类加载机制让开发者可以灵活的去制定加载类的逻辑,如可以将一个 class 文件按照某种加密规则进行加密,然后只有某种特定的类加载器才能正常的解密。下面我们来实现下:

首先我们准备一个简单的类:

package class_load;

public class CipherClass {
    public CipherClass() {
        System.out.println("CipherClass Object was created");
    }
}
复制代码

将 CipherClass 通过 javac 命令编译成 CipherClass.class 文件,然后按照下面的加密算法将 CipherClass.class 字节码进行加密:

/**
 * 加密方法,同时也是解密方法
 */
private static void cypher(InputStream ips, OutputStream ops) throws Exception {
    int b = -1;
    while ((b = ips.read()) != -1) {
        //1 就变成 0,0 就变成 1
        ops.write(b ^ 0xff);
    }
}
复制代码

然后我们自定义一个 ClassLoader,在里面可以对其进行解密,然后转成 Class 对象:

public class CipherClassLoader extends ClassLoader {

    private String classDir;
    
    public CipherClassLoader(String classDir) {
        this.classDir = classDir;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 定位加密后的 class 字节码文件
        String classFileName = classDir + "//" + name.substring(name.lastIndexOf('.') + 1) + ".class";
        try {
            FileInputStream fis = new FileInputStream(classFileName);
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            // 将加密的 class 字节码进行解密
            cypher(fis, bos);
            fis.close();
            byte[] bytes = bos.toByteArray();
            // 将正常的 class 字节流转成 Class 对象
            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

}

// 测试
public static void main(String[] args) throws Exception {
    String dir = "class file dir";
    Class clazz = new CipherClassLoader(dir).loadClass("class_load.CipherClass");
    clazz.newInstance();
}

// 输出:

CipherClass Object was created

复制代码

类加载器死锁问题

在 JDK1.7 之前,ClassLoader 是有可能出现死锁的,关于 ClassLoader 死锁的问题可以查看官方对该问题的描述(点击进入查看)

下面我们官方对死锁情况的复现:

Class Hierarchy:
  class A extends B
  class C extends D

ClassLoader Delegation Hierarchy:

Custom Classloader CL1:
  directly loads class A 
  delegates to custom ClassLoader CL2 for class B

Custom Classloader CL2:
  directly loads class C
  delegates to custom ClassLoader CL1 for class D

Thread 1:
  Use CL1 to load class A (locks CL1)
    defineClass A triggers
      loadClass B (try to lock CL2)

Thread 2:
  Use CL2 to load class C (locks CL2)
    defineClass C triggers
      loadClass D (try to lock CL1)
复制代码

本来打算在上面改成中文注释的,但是上的描述已经非常简洁明了,所以就不画蛇添足了。

在对死锁情况介绍之前,先来看下JDK1.6 ClassLoader:

protected synchronized Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        // First, check if the class has already been loaded
        Class c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
                // If still not found, then invoke findClass in order
                // to find the class.
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

复制代码

可以看到 synchronized 是放在方法上,是整个方法同步,那么 ClassLoader 对象就是同步方法的锁(lock)对象。

下面可以描述死锁的产生情况了,有两个线程:

线程1:CL1 去 loadClass(A) 获取到了 CL1 对象锁,因为 A 继承了类 B,defineClass(A) 会触发 loadClass(B),尝试获取 CL2 对象锁;

线程2:CL2 去 loadClass(C) 获取到了 CL2 对象锁,因为 C 继承了类 D,defineClass(C) 会触发 loadClass(D),尝试获取 CL1 对象锁

线程1 尝试获取 CL2 对象锁的时候,CL2 对象锁已经被线程2拿到了,那么线程1等待线程2释放 CL2 对象锁。

线程2 尝试获取 CL1 对像锁的时候,CL1 对像锁已经被线程1拿到了,那么线程2等待线程1释放 CL1 对像锁。

然后两个线程一直在互相等中...从而产生了死锁现象。

如果你是通过重载 findClass 方法来自定类加载器的,那么将不会有死锁问题(因为 findClass 不是同步方法),那么也就没有破坏双亲委派机制,这也是官方建议的机制。

如果是通过重载 loadClass 方法来实现自定义类加载器就有可能出现死锁的。

那有的人会说那我通过重载 findClass 来实现自定义类加载器不就可以避免了么?是的。

但是有的时候又不得不通过重载 loadClass 方法来实现自定义类加载器,比如我们实现的类加载器不想遵循双亲委派机制(官方称之为 acyclic delegation),那么只能重载 loadClass 了,前面分析 loadClass 方法源码就知道了,是这个方法执行递归操作(双亲委派的逻辑)。

从中可以看出,如果你仅仅是想自定义个类加载器而已,但是不会改变双亲委派机制,那么重载 findClass 方法即可。

如果万不得已要通过重载 loadClass 来实现,在 JDK1.7 中可以在定义类加载器中的静态代码块中添加如下代码来避免死锁的出现:

static {
    ClassLoader.registerAsParallelCapable();
}
复制代码

其实 JDK 为我们提供的类加载器,如 AppClassLoader 默认就加上了:

static class AppClassLoader extends URLClassLoader {

    // 省略其他代码..

    static {
        ClassLoader.registerAsParallelCapable();
    }
}
复制代码

小结

本文介绍了一开始的类的加载时机,以及被动引用的几个案例。

然后详细介绍了类的加载过程:加载、验证、准备、解析、初始化,在此过程,通过修改 class 字节码文件方式演示违反验证阶段会产生什么错误,通过查看 JVM 源码的方式理清每个阶段具体工作。

最后重点介绍了和我们开发息息相关的类加载器,介绍了类加载器的作用以及数组的类加载器

通过分析源码的方式介绍了类加载器的双亲委派机制,然后自定义一个解密的类加载器。

最后介绍类加载器的死锁问题,分析了为何会产生死锁,分析了通过 findClass 和 loadClass 实现自定义类加载器的不同。最后介绍了如何解决死锁问题。

至此,我们就把从一个 class 字节码文件加载到内存,然后变成 Class 对象的过程介绍完了。将 class 字节码加载到内存,变成 Class 对象,要继续分析的话,需要先介绍 JVM 的内存结构,敬请期待。

Reference

  • 《《深入理解Java虚拟机 - JVM 高级特性与最佳时间(第二版)》》
  • docs.oracle.com/javase/spec…
  • cr.openjdk.java.net/~mr/jigsaw/…
  • docs.oracle.com/javase/7/do…
原文  https://juejin.im/post/5d98acece51d45781f73baf4
正文到此结束
Loading...