WHCSRL 技术网

深入理解Java虚拟机——类加载机制


类加载机制

类的生命周期

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)

在这里插入图片描述

类的加载过程

在这里插入图片描述

1、加载

类加载过程的第一步,Java虚拟机需要完成以下三件事情:

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

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证

3、准备

准备阶段是正式为类变量分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都应当在方法区中进行分配

  1. 这时候进行内存分配的仅包括类变量( 即静态变量,被 static 关键字修饰的变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。

  2. 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。

  3. 这里所设置的初始值"通常情况"下是数据类型默认的零值(如0、0.0、0L、null、false等),比如我们定义了public static int value = 123 ,那么 value 变量在准备阶段的初始值就是 0 而不是123(初始化阶段才会赋值)。

  4. 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显示初始化:比如给 value 变量加上了 final 关键字public static final int value = 123使其成为常量 ,那么准备阶段 value 的值就被赋值为 123。

4、解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

5、初始化

类的初始化阶段是类加载的最后一个步骤,此时 Java 虚拟机才真正开始执行类中编写的 Java 程序代码。

初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit> ()方法是编译之后自动生成的。

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{ }块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,即构造器方法中指令按语句在源文件中出现的顺序执行。
  • <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显示地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。
  • 对于<clinit>() 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit>() 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。

类的初始化时机

对于初始化阶段,虚拟机严格规范了有且只有5种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):

  1. 当遇到 newgetstaticputstaticinvokestatic 这4条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载器

类与类加载器

比较两个类是否 “相等” ,首先需要这两个类来源于同一个Class文件,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。

这里的 “相等” ,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。


类加载器分类

从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;
  • 其它所有的类加载器,使用 Java 实现,独立存在于虚拟机外部,全都继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度看,类加载器可以划分得更细致一些:

  • 启动类加载器(Bootstrap ClassLoader):此类加载器负责加载 %%%%JAVA_HOME%%%%/lib目录下的 jar 包和类或者被 -Xbootclasspath参数指定的路径中的所有类。(String等核心类库)
  • 扩展类加载器(Extension ClassLoader):主要负责加载 %%%%JRE_HOME%%%%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  • 应用程序类加载器(Application ClassLoader)由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

如果想要自定义加载器,需要继承 java.lang.ClassLoader类,且为了满足双亲委派机制,需要指定父加载器为拓展类加载器


双亲委派模型

下图展示了各种类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承关系(Inheritance)来实现的,而是通常使用组合关系(Composition)来复用父加载器的代码

工作过程

每一个类都有一个对应它的类加载器。系统中的 ClassLoader 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派给父类加载器的 loadClass() 处理,因此所有的加载请求最终都应该传送到最顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理这个加载请求时,子加载才会尝试自己去完成加载。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

当一个 .class 文件要被加载时,不考虑我们自定义类加载器类,首先会在AppClassLoader中检查是否加载过,如果有那就无需再加载;如果没有会交到父加载器,然后调用父加载器的loadClass方法。父加载器同样也会先检查自己是否已经加载过,如果没有再往上,直到到达BootstrapClassLoader之前,都是在检查是否加载过,并不会选择自己去加载。到了根加载器时,才会开始检查是否能够加载当前类,能加载就结束,使用当前的加载器;否则就通知子加载器进行加载;子加载器重复该步骤。如果到最底层还不能加载,就抛出异常ClassNotFoundException

总结:所有的加载请求都会传送到根加载器去加载,只有当父加载器无法加载时,子类加载器才会去加载


源码分析

核心方法为 java.lang.ClassLoader 的 loadClass()

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) {	// 父加载器不为空,调用父加载器loadClass()方法处理
                    c = parent.loadClass(name, false);
                } else {	// 父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父类加载器抛出ClassNotFoundException,说明父类加载器无法完成加载请求
            }
            if (c == null) {
                long t1 = System.nanoTime();
                // 在父类加载器无法加载时,再调用本身的findClass方法来进行类加载
                c = findClass(name);

                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

双亲委派机制的好处

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改(沙箱安全机制)。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

  • 避免类的重复加载
  • 保证Java核心类库的安全

参考资料

《深入理解Java虚拟机 第3版》——周志明

https://github.com/Snailclimb/JavaGuide

推荐阅读