JVM 类加载机制

10/14/2022 Jvm

摘要

JDK:1.8.0_202

# 一:概述

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制

# 1.1 生命周期

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历下图的七个阶段。

  • 加载(Loading):查找并加载类文件的二进制数据;
  • 连接:将已经读入内存的类的二进制数据合并到JVM运行时环境中去,包含如下几个步骤:
    1. 验证(Verification):确保被加载类的正确性;
    2. 准备(Preparation):为类的静态变量分配内存,并初始化它们;
    3. 解析(Resolution):把常量池中的符号引用转换为直接引用。
  • 初始化(Initialization):为类的静态变量赋初始值

解析不一定在初始化之前,也可能在之后,这是为了支持 Java 语言的运行时绑定特性(也称动态绑定或晚期绑定)。

# 1.2 被动引用

对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行:

  1. 遇到 new、getstatic、putstatic或invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    1. 使用new关键字实例化对象的时候;
    2. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候;
    3. 调用一个类型的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

上面六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。下面举三个例子来说明何为被动引用:

例子一:

package top.qform.passive;

class SuperClass {

    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {

    static {
        System.out.println("SubClass init!");
    }
}


public class NotInitialization {

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

}
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

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证阶段,这点取决于虚拟机的具体实现。对于HotSpot虚拟机来说,可通过 -XX:+TraceClassLoading 参数观察到此操作是会导致子类加载的。

例子二:

修改例子一中 System.out.println(SubClass.value); 代码为 SuperClass[] sca = new SuperClass[10];

没有任何输出,说明并没有触发类SuperClass的初始化阶段。但是这段代码里面触发了另一个名为 "[Ltop.qform.passive.SuperClass" 的类的初始化阶段,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令 newarray 触发。

可以使用javap验证一下

例子三:

class ConstClass {

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

    public static final String HELLO_WORLD = "hello world";
}

public class NotInitialization1 {

    public static void main(String[] args) {
        System.out.println(ConstClass.HELLO_WORLD);
    }
    
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class文件后就已不存在任何联系了。

# 二:类加载过程

下面会详细说明Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的具体动作。

# 2.1 加载

在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过类的全限定名来获取该类的二进制字节流;
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在堆上创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构,并向外提供了访问方法区内数据结构的接口。

加载类的方式:

  1. 最常见的方式:本地文件系统中加载,从jar等归档文件中加载;
  2. 动态的方式:将Java源文件动态编译成class;
  3. 其他方式:网络下载,从专有数据库中加载等等。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

# 2.2 验证

这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

Class文件并不一定只能由Java源码编译而来,部分对于Java代码无法做到的事情在字节码层面上可以实现的,至少语义上是可以表达出来的。Java虚拟机如果不检查输入的字节流,很可能会载入了有错误或有恶意企图的字节码流。

验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

  1. 文件格式验证(第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,只有通过了这个阶段的验证之后,才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的)
    • 是否以魔数0xCAFEBABE开头;
    • 主、次版本号是否在当前Java虚拟机接受范围之内;
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
    • ... ...
  2. 元数据验证(第二阶段是对字节码描述的信息进行语义分析)
    • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);
    • 这个类的父类是否继承了不允许被继承的类(被final修饰的类);
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
    • ... ...
  3. 字节码验证(第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为)
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于 "在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中" 这样的情况;
    • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
    • ... ...
  4. 符号引用验证(可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源)
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类;
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段;
    • ... ...

如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

# 2.3 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候 "类变量在方法区" 就完全是一种对逻辑概念的表述了。

易混淆一:

准备阶段进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

易混淆二:

这时候类变量初始值 "通常情况" 下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的 putstatic 指令是程序被编译后,存放于类构造器 <clinit>() 方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行

易混淆三:

如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:

public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

# 2.4 解析

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

《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行 ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic 这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

更多相关,可以查看 JVM 类加载—解析阶段

# 2.5 初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。初始化阶段就是执行类构造器 <clinit>() 方法的过程。

<clinit>() 细节

<clinit>() 是Javac编译器的自动生成物,下面是它的一些细节;

  • <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问;
public class Test {
    static {
        i = 0;  //  给变量赋值可以正常编译通过,会优先与 i=1 执行
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;	// 如果把这个语句放在static之前则编译通过
}
1
2
3
4
5
6
7
  • <clinit>() 方法与类的构造函数(即在虚拟机视角中的实例构造器 <init>() 方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。因此在Java虚拟机中第一个被执行的 <clinit>() 方法的类型肯定是 java.lang.Object
  • 由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作;
  • <clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit>()方法。
  • Java虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕 <clinit>() 方法。如果在一个类的 <clinit>() 方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。如下代码
public class Test {
    static class DeadLoopClass {
        static {
            // 如果不加上这个if语句,编译器将提示“Initializer does not complete normally” 并拒绝编译
            if (true) {
                System.out.println(Thread.currentThread() + "init DeadLoopClass");
                while (true) {
                }
            }
        }
    }

    public static void main(String[] args) {
        Runnable script = () -> {
            System.out.println(Thread.currentThread() + "start");
            DeadLoopClass dlc = new DeadLoopClass();
            System.out.println(Thread.currentThread() + " run over");
        };

        new Thread(script).start();
        new Thread(script).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 三:类加载器

Java虚拟机设计团队有意把类加载阶段中的 "通过一个类的全限定名来获取描述该类的二进制字节流" 这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为 类加载器(Class Loader)

# 3.1 类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否 "相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

这里所指的"相等",包括代表类的Class对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。下面代码演示了不同的类加载器对 instanceof 关键字运算的结果的影响。

public class ClassLoaderTest {

    public static void main(String[] args) throws Exception {

        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };

        Object obj = myLoader.loadClass("top.qform.passive.ClassLoaderTest").newInstance();

        System.out.println(obj.getClass());
        System.out.println(obj instanceof top.qform.passive.ClassLoaderTest);
    }

}
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

输出结果为false的原因,Java虚拟机中同时存在了两个 ClassLoaderTest 类,一个是由虚拟机的应用程序类加载器所加载的,另外一个是由自定义的类加载器加载的,虽然它们都来自同一个Class文件,但在Java虚拟机中仍然是两个互相独立的类,做对象所属类型检查时的结果自然为false。

# 3.2 双亲委派模型

自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构。

  • 启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可,下面是 java.lang.Class.getClassLoader() 源码片段,其中的注释和代码实现都明确地说明了以null值来代表启动类加载器的约定规则。
/**
 * 返回类的类加载器。一些实现可能使用 null 来表示启动类类加载器。如果此类由启动类加载器加载,则此方法将在此类实现中返回 null
 *
 * 如果存在安全管理器,并且调用者的类加载器不为 null,并且调用者的类加载器与请求其类加载器的类的类加载器不同或其父类,则此方法调用安全管理器的 checkPermission 方法 具有 RuntimePermission("getClassLoader") 权限,以确保可以访问该类的类加载器
 *
 * 如果此对象表示原始类型或 void,则返回 null
 *
 * @return  加载此对象表示的类或接口的类加载器。
 * @throws SecurityException
 *    如果存在安全管理器并且其 checkPermission 方法拒绝访问该类的类加载器。
 * @see java.lang.ClassLoader
 * @see SecurityManager#checkPermission
 * @see java.lang.RuntimePermission
 */
@CallerSensitive
public ClassLoader getClassLoader() {
	ClassLoader cl = getClassLoader0();
	if (cl == null)
		return null;
	SecurityManager sm = System.getSecurityManager();
	if (sm != null) {
		ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
	}
	return cl;
}
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
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载 <JAVA_HOME>\lib\ext 目录中,或被 java.ext.dirs 系统变量所指定的路径中所有的类库。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
  • 应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是ClassLoader类中的 getSystemClassLoader() 方法的返回值,也称为 "系统类加载器"。负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

查看相关加载器核心类库

import java.net.URL;
import java.net.URLClassLoader;

public class Test1 {

    public static void main(String[] args) {
        // 获取 启动类加载器 所加载的全部 URL 数组
        URL[] urls1 = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for (URL url : urls1) {
            System.out.println(url.toExternalForm());
        }

        // ----------------------------------------
        // 获取 扩展类加载器 所加载的全部 URL 数组
        System.out.println("======================================");
        URL[] urls2 = ((URLClassLoader) ClassLoader.getSystemClassLoader().getParent()).getURLs();
        for (URL url : urls2) {
            System.out.println(url);
        }

        // ----------------------------------------
        // 获取 应用程序类加载器 所加载的全部 URL 数组
        System.out.println("======================================");
        URL[] urls3 = ((URLClassLoader) ClassLoader.getSystemClassLoader()).getURLs();
        for (URL url : urls3) {
            System.out.println(url);
        }
    }

}
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

JDK 9之前的Java应用都是由这三种类加载器互相配合来完成加载的,还可以加入自定义的类加载器来进行拓展,这些类加载器之间的协作关系 "通常" 会如下图所示。

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

写个测试代码,验证上面的协作关系:

public class ClassLoaderStudy {

    public static void main(String[] args)  {
        String str = "Hello Class Loader";
        System.out.println("str class loader==" + str.getClass().getClassLoader());

        Class<CurveDB> driver = CurveDB.class;
        System.out.println("driver class loader=" + driver.getClassLoader());
        System.out.println("driver parent class loader=="
                + driver.getClassLoader().getParent());

        ClassLoaderStudy t = new ClassLoaderStudy();
        System.out.println("t class loader==" + t.getClass().getClassLoader());
        System.out.println("t parent class loader==" + t.getClass().getClassLoader().getParent());
        System.out.println("t parent.parent class loader==" + t.getClass().getClassLoader().getParent().getParent());
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户也编写了一个名为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中就会出现多个不同的 Object 类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,全部集中在 java.lang.ClassLoader.loadClass() 方法之中,代码如下。

/**
 * 加载具有指定二进制名称的类。 此方法的默认实现按以下顺序搜索类:
 *
 * 	1. 调用 findLoadedClass(String) 来检查类是否已经加载。
 *	2. 在父类加载器上调用 loadClass 方法。 如果 parent 为 null,则使用虚拟机内置的类加载器。
 *	3. 调用 findClass(String) 方法来查找类。
 *
 * 如果使用上述步骤找到了类,并且解析标志为真,则此方法将在生成的 Class 对象上调用 resolveClass(Class) 方法。
 *
 * 鼓励 ClassLoader 的子类重写 findClass(String),而不是这个方法。
 *
 * 除非被重写,否则此方法会在整个类加载过程中同步 getClassLoadingLock 方法的结果。
 *
 * @param  name
 *         类的二进制名称
 *
 * @param  resolve
 *         如果为真,则解析该类
 *
 * @return  生成的 Class 对象
 *
 * @throws  ClassNotFoundException
 *          如果找不到类
 */
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 {
					c = findBootstrapClassOrNull(name);
				}
			} catch (ClassNotFoundException e) {
				// 如果父类加载器抛出ClassNotFoundException
				// 说明父类加载器无法完成加载请求
			}

			if (c == null) {
				// 在父类加载器无法加载时
				// 再调用本身的findClass方法来进行类加载
				long t1 = System.nanoTime();
				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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

概括起来就是,先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载。

class Tester {
    static {
        System.out.println("Tester 类的静态初始化块...");
    }
}

public class ClassLoaderTest2 {

    public static void main(String[] args) throws ClassNotFoundException {
        ClassLoader cl = ClassLoader.getSystemClassLoader();
        // 下面语句仅仅加载 Tester 类
        cl.loadClass("top.qform.passive.Tester");
        System.out.println("系统加载Tester类");
        // 下面语句才会初始化 Tester 类
        Class.forName("top.qform.passive.Tester");
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

当使用 ClassLoader 类的 loadClass() 方法来加载某个类时,该方法只是加载该类,并不会执行该类的初始化。使用 ClassforName() 静态方法才会导致强制初始化该类。

# 3.3 自定义类加载器

下面程序开发了一个自定义的ClassLoader,该ClassLoader通过重写 findClass() 方法来实现自定义的类加载机制。这个ClassLoader可以在加载类之前先编译该类的源文件,从而实现运行Java之前先编译该程序的目标,这样即可通过该ClassLoader直接运行Java源文件。

public class CompileClassLoader extends ClassLoader {

    // 读取一个文件的内容
    private byte[] getBytes(String filename) throws IOException {
        File file = new File(filename);
        long len = file.length();
        byte[] raw = new byte[(int) len];
        try (FileInputStream fin = new FileInputStream(file)) {
            // 一次读取 Class 文件的全部二进制数据
            int r = fin.read(raw);
            if (r != len) {
                throw new IOException("无法读取全部文件:" + r + " != " + len);
            }
            return raw;
        }
    }

    // 定义编译指定 Java 文件的方法
    private boolean compile(String javaFile) throws IOException {
        System.out.println("CompileClassLoader:正在编译" + javaFile + "...");
        // 调用系统的 javac 命令
        Process p = Runtime.getRuntime().exec("javac " + javaFile);
        try {
            // 其他线程都等待这个线程完成
            p.waitFor();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 获取 javac 线程的退出值
        int ret = p.exitValue();
        // 返回编译是否成功
        return ret == 0;
    }

    // 重写 ClassLoader 的 findClass 方法
    @Override
    public Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = null;
        // 将包路径中的点 (.) 替换成斜线 (/)
        String fileStub = name.replace(".", "/");
        String javaFilename = fileStub + ".java";
        String classFilename = fileStub + ".class";
        File javaFile = new File(javaFilename);
        File classFile = new File(classFilename);

        // 当指定 Java 源文件,且 Class 文件不存在,或者 Java 源文件的修改时间比 Class 文件的修改时间更晚时,重新编译
        if (javaFile.exists() && (!classFile.exists() || javaFile.lastModified() > classFile.lastModified())) {
            try {
                // 如果编译失败,或者该 Class 文件不存在
                if (!compile(javaFilename) || !classFile.exists()) {
                    throw new ClassNotFoundException("ClassNotFoundException:" + javaFilename);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        // 如果 Class 文件存在,系统负责将该文件转换成 Class 对象
        if (classFile.exists()) {
            try {
                // 将 Class 文件的二进制数据读入数组
                byte[] raw = getBytes(classFilename);
                // 调用 ClassLoader 的 defineClass 方法将二进制数据转换成 Class 对象
                clazz = defineClass(name, raw, 0, raw.length);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        // 如果 clazz 为null,表明加载失败,则抛出异常
        if (clazz == null) {
            throw new ClassNotFoundException(name);
        }

        return clazz;
    }

    // 定义一个主方法
    public static void main(String[] args) throws Exception {
        // 如果运行该程序时没有参数,即没有目标类
        if (args.length < 1) {
            System.out.println("缺少目标类,请按如下格式运行 Java 源文件:");
            System.out.println("java CompileClassLoader ClassName");
        }
        // 第一个参数是需要运行的类
        String progClass = args[0];
        // 剩下的参数将作为运行目标类时的参数
        // 将这些参数赋值到一个新数组中
        String[] progArgs = new String[args.length - 1];
        System.arraycopy(args, 1, progArgs, 0, progArgs.length);
        CompileClassLoader ccl = new CompileClassLoader();
        // 加载需要运行的类
        Class<?> clazz = ccl.loadClass(progClass);
        // 获取需要运行的类的主方法
        Method main = clazz.getMethod("main", String[].class);
        Object[] argsArray = {progArgs};
        main.invoke(null, argsArray);
    }
}
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99

接下来可以随意提供一个简单的主类,该主类无须编译就可以使用上面的 CompileClassLoader 来运行它。

public class Hello {

    public static void main(String[] args) {
        for (String arg : args) {
            System.out.println("parameter:" + arg);
        }
    }

}
1
2
3
4
5
6
7
8
9

在本类的 findClass() 方法中先检查需要加载类的Class文件是否存在,如果不存在则先编译源文件,再调用 ClassLoader的defineClass() 方法来加载这个Class文件,并生成相应的Class对象。

使用自定义的类加载器,可以实现如下常见功能。

    • 执行代码前自动验证数字签名;
    • 根据用户提供的密码解密代码,从而可以实现代码混淆器来避免反编译*.class文件;
    • 根据用户需求来动态地加载类;
    • 根据应用需求把其他数据以字节码的形式加载到应用中。

# 3.4 破坏双亲委派模型

  • 双亲模型有个问题:父加载器无法向下识别子加载器加载的资源;
  • 为了解决这个问题,引入了线程上下文类加载器,可以通过 Thread 的 setContextClassLoader() 进行设置;
  • 另外一种典型情况就是实现热替换,比如OSGI的模块化热部署,它的类加载器就不再是严格按照双亲委派模型,很多可能就在平级的类加载器中执行了。

# 四:模块化系统

Java模块化系统(Java Platform Module System,JPMS)在 JDK 9 中引入。JDK 9的模块不像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:

  • 依赖其他模块的列表;
  • 导出的包列表,即其他模块可以使用的列表;
  • 开放的包列表,即其他模块可反射访问模块的列表;
  • 使用的服务列表;
  • 提供服务的实现列表。

可配置的封装隔离机制首先要解决JDK 9之前基于类路径(ClassPath)来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接时才会报出运行的异常。而在JDK 9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。

可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型的可访问性问题。JDK 9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类加载过程中完成的。

# 4.1 模块的兼容性

为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9提出了与 "类路径"(ClassPath)相对应的 "模块路径"(ModulePath)的概念。简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。只要是放在类路径上的JAR文件,无论其中是否包含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文件,它也仍然会被当作一个模块来对待。

模块化系统将按照以下规则来保证使用传统类路径依赖的Java程序可以不经修改地直接运行在JDK 9及以后的Java版本上,即使这些版本的JDK已经使用模块来封装了Java SE的标准类库,模块化系统的这套规则也仍然保证了传统程序可以访问到所有标准类库模块中导出的包。

  • JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。

  • 模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。

  • JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块(Automatic Module)。尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。

以上3条规则保证了即使Java应用依然使用传统的类路径,升级到JDK 9对应用来说几乎不会有任何感觉,项目也不需要专门为了升级JDK版本而去把传统JAR包升级成模块。

在JDK 9时加入Class文件格式的Module属性,里面有 module_version_index 这样的字段,用户可以在编译时使用 javac--module-version 来指定模块版本,在Java类库API中也存在 java.lang.module.ModuleDescriptor.Version 这样的接口可以在运行时获取到模块的版本号。这一切迹象都证明了Java模块化系统对版本号的支持本可以不局限在编译期。

# 4.2 模块化下的类加载器

但是为了模块化系统的顺利施行,模块化下的类加载器发生了一些应该被注意到变动,主要包括以下几个方面。

  1. 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。
详细说明

这其实是一个很顺理成章的变动,既然整个JDK都基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留 <JAVA_HOME>\lib\ext 目录,此前使用这个目录或者 java.ext.dirs 系统变量来扩展JDK功能的机制已经没有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。类似地,在新版的JDK中也取消了 <JAVA_HOME>\jre 目录,因为随时可以组合构建出程序运行所需的JRE来,譬如假设我们只使用java.base模块中的类型,那么随时可以通过以下命令打包出一个"JRE": jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre

  1. 平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader,如果有程序直接依赖了这种继承关系,或者依赖了 URLClassLoader 类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在 BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。两者的前后变化如下图所示:

JDK 9之前的类加载器继承架构

JDK 9及以后的类加载器继承架构

图二虽然有 BootClassLoader 存在,启动类加载器现在是在Java虚拟机内部和Java类库共同协作实现的类加载器,尽管有了 BootClassLoader 这样的Java类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如 Object.class.getClassLoader())中仍然会返回null来代替,而不会得到 BootClassLoader 的实例。

  1. JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载

在JDK 9以后的三层类加载器的架构如下图所示:

在Java模块化系统明确规定了三个类加载器负责各自加载的模块,即前面第三点所说的归属关系,如下所示。

  • 启动类加载器负责加载的模块:
java.base                        java.security.sasl
java.datatransfer                java.xml
java.desktop                     jdk.httpserver
java.instrument                  jdk.internal.vm.ci
java.logging                     jdk.management
java.management                  jdk.management.agent
java.management.rmi              jdk.naming.rmi
java.naming                      jdk.net
java.prefs                       jdk.sctp
java.rmi                         jdk.unsupported
1
2
3
4
5
6
7
8
9
10
  • 平台类加载器负责加载的模块:
java.activation*                jdk.accessibility
java.compiler*                  jdk.charsets
java.corba*                     jdk.crypto.cryptoki
java.scripting                  jdk.crypto.ec
java.se                         jdk.dynalink
java.se.ee                      jdk.incubator.httpclient
java.security.jgss              jdk.internal.vm.compiler*
java.smartcardio                jdk.jsobject
java.sql                        jdk.localedata
java.sql.rowset                 jdk.naming.dns
java.transaction*               jdk.scripting.nashorn
java.xml.bind*                  jdk.security.auth
java.xml.crypto                 jdk.security.jgss
java.xml.ws*                    jdk.xml.dom
java.xml.ws.annotation*         jdk.zipfs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 应用程序类加载器负责加载的模块:
jdk.aot                         jdk.jdeps
jdk.attach                      jdk.jdi
jdk.compiler                    jdk.jdwp.agent
jdk.editpad                     jdk.jlink
jdk.hotspot.agent               jdk.jshell
jdk.internal.ed                 jdk.jstatd
jdk.internal.jvmstat            jdk.pack
jdk.internal.le                 jdk.policytool
jdk.internal.opt                jdk.rmic
jdk.jartool                     jdk.scripting.nashorn.shell
jdk.javadoc                     jdk.xml.bind*
jdk.jcmd                        jdk.xml.ws*
jdk.jconsole
1
2
3
4
5
6
7
8
9
10
11
12
13

# 五:参考文献

  • 《深入理解Java虚拟机 - 周志明》
最后更新: 10/22/2022, 10:41:05 AM