深入浅出 JVM 之类加载-类加载流程

深入浅出 JVM 之类加载-类加载流程

文章目录

  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。

系统环境:

  • JDK 1.8

参考地址:

深入浅出 JVM 系列文章

一、类加载机制概念

在 Java 中,类加载机制指的是 Java 虚拟机将描述类的数据从 Class 文件加载到内存,并对这些数据进行校验、转换、解析和初始化的过程。最终,这些数据将形成可以被虚拟机直接使用的 Java 类型。类加载机制是 Java 程序运行的基础,它确保了类文件可以被正确地解释和执行。

二、类加载执行流程

根据 《Java 虚拟机规范》 中的规定,类加载可以分为七个阶段,分别为 加载 (Loading)验证 (Verification)准备 (Preparation)解析 (Resolution)初始化 (Initialization)使用 (Using)卸载 (Unloading),其中 验证准备解析 三个阶段整体又称为 链接 (Linking)

三、类加载的时机

在类加载的七个阶段中,加载验证准备初始化卸载 这五个阶段的顺序是确定的,一般情况下在加载类时这五个阶段会按照顺序开始。不过在类加载七个阶段中的 解析阶段 则不一定会按顺序开始,因为在 Java 中存在动态绑定机制 (也称为晚期绑定),所以 解析阶段 有可能会在执行完 初始化阶段 之后再开始。

注: 上面描述中所说的都是 "按照顺序开始",而不是 "按照顺序执行" 或者 "按照顺序完成",之所以这么说是因为这些阶段执行过程都是相互交叉的混合进行,可能会在一个阶段执行的过程中调用或者激活另一个阶段。所以各个阶段只是按照这个顺序开始,而不会等一个阶段完全执行完成后才进行下一个阶段,各阶段并不会严格按照此顺序结束。

其实在《Java 虚拟机规范》中,并没有强制约束类加载的第一个阶段 加载阶段 什么时候开始,因此在不同的虚拟机的实现中有可能会有不同的加载逻辑。不过在《Java 虚拟机规范》中却严格规定了什么时候进行 初始化阶段,规定中要求只要遇到以下六种情况,就必须立即对类进行 初始化阶段 (加载、验证、准备阶段需要在此阶段之前开始):

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

四、过程-加载阶段 (Loading)

4.1 类加载阶段作用

加载阶段主要是使用 "类加载器" 将本地或者远程网络中的字节码文件,通过读字节流的方式加载到 Java 虚拟机内存中。在加载阶段中 Java 虚拟机主要完成以下三件事情:

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

4.2 常用的类加载器

其中常用的类加载器有三种,分别是:

类加载器 描述
引导类加载器
BootstrapClassLoader
引导类加载器是使用 C++ 语言实现的,用于加载 Java 中的核心类库的,一般会加载 JAVA_HOME 目录下的 /jre/lib 文件夹下的 jar 和配置。
扩展类加载器
ExtClassLoader
扩展类加载器主要负责加载 Java 的扩展类库,一般会加载 JAVA_HOME 目录下的 /jre/lib/ext 文件夹下的 jar。
应用类加载器
AppClassLoader
应用类加载器是应用程序中默认的类加载器,可以加载 CLASSPATH 变量指定目录下的 jar,并且一般情况下,我们编写的 Java 应用的类,都是使用该类加载器完成加载的。

4.3 数组类的加载

数组类的创建过程有一些特殊之处,因为数组类本身并不是由类加载器负责创建的,而是由 Java 虚拟机在运行时根据需要直接在内存中创建的。但是数组的元素类型 (ElementType) 仍然需要依靠类加载器来完成加载。

4.4 读取字节码的方式

Java 虚拟机可以通过多种方式读取字节码文件数据流,比如:

  • 通过网络进行加载字节码字节流;
  • 事先存放在数据库中的类的二进制数据;
  • 在运行时生成一段 class 二进制数据;
  • 读入 jarzip 压缩数据包中的类文件;
  • 通过文件系统读入一个 .class 后缀文件;

4.5 存储类的位置

加载阶段执行结束后,Java 虚拟机读取的字节码二进制字节流就会转换为特定的格式存储在方法区中了,方法区中的数据存储格式完全由不同的虚拟机的实现自行定义,在《Java虚拟机规范》中未规定此区域的具体数据结构。

类数据成功存入方法区中后,就会在 Java 堆内存中实例化一个 java.lang.Class 实例对象,这个对象将作为程序访问方法区中类型数据的外部接口。不过需要注意的是,如果读取的二进制数据流不符合字节码规范,就会抛出 ClassFormatError 错误。

在 JDK 8 之前版本中,加载的类数据会存入永久代,而在 JDK 8 及以后版本中会存入元空间

五、过程-链接阶段 (Linking)

5.1 验证阶段 (Verification)

验证阶段的主要目的是对字节码字节流进行校验,判断其内容是否符合当前虚拟机的规范,以确保被加载的代码运行后不会对虚拟机造成损害。不过在不同的虚拟机的实现中的验证方式也有所不同,但是大多数虚拟机大致都会对 文件格式元数据字节码符号引用 几项内容进行校验。

(1) 文件格式验证

文件格式验证主要是对 字节流格式 进行校验,判断其是否符合字节码文件格式规范,并且还要判断其是否可以运行在当前版本的虚拟机中。比如:

序号 描述
   1    验证是否以 0XCAFEBABE 开头
2 验证主、次版本号,是否包含在当前虚拟机支持的版本范围内
3 验证字节码常量池中的常量类型,是否都被虚拟机所支持
4 验证指向常量的各种索引值,是否有指向不存在的常量或不符合类型的常量
5 验证 CONSTANT_Utf8_info 类型常量中,是否有不符合 UTF-8 编码的数据
6 验证字节码文件中各个部分及文件本身,是否有被删除或附加的其他信息

文件格式验证的主要目的其实就是为了保证加载的字节码可以被正确地解析并存储在方法区内。

(2) 元数据验证

元数据验证主要是对 字节码 中的 元数据信息 进行语法校验,避免存在不符合 Java 语法规范的元数据信息。比如:

序号 描述
   1    验证当前类的父类是否继承了不允许被继承的类,比如被 final 修饰的类
2 验证当前类是否有父类,一般情况下除了 java.lang.Object 外,所有的类都应当有父类
3 验证如果当前类不是抽象类,则当前类是否实现了其父类或接口之中要求实现的所有方法
4 验证当前类中的字段或方法是否与父类有冲突,比如当前类覆盖了父类的 final 字段,或者当前类实现的方法参数都一致,但返回值的类型却不同,导致不符合方法重载规则等情况

(3) 字节码验证

字节码验证主要是对 数据流控制流 进行分析,以确保其语法合规且符合逻辑。比如:

序号 描述
   1    保证任何跳转指令都不会跳转到方法体以外的字节码指令上
2 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似于在操作栈放置了一个 int 类型的数据,使用时却按 long 类型加载到本地变量表中这样的情况
3 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与其毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的

(4) 符号引用验证

符号引用验证主要对 字节码常量池常量 的各种 符号引用 进行校验,确保当前类引用到的其它类或者方法是真实存在且有权限访问的。如果符号引用中关联的类无法在系统中查找到,就会抛出 NoClassDefFoundError 错误,如果符号引用中关联的方法无法找到,则会抛出 NoSuchMethodError 错误。

序号 描述
   1    验证是否可以通过符号引用中记录的全限定名,可以在系统中查找到真实对应的类
2 验证是否可以通过符号引用中记录的属于某个类的字段描述符及简单名称,可以在该类中找到对应的方法和字段
3 验证是否有权限访问符号引用中记录的类、字段或方法。比如被 private 修饰的方法可能就没有权限访问

5.2 准备阶段 (Preparation)

准备阶段主要是用于对类或接口中的 "静态变量" 分配内存空间,以及对变量设置默认的初始值。

这里需要强调一下的是,准备阶段和初始化阶段,这两个阶段都是用于对静态变量设置值,概念上容易混淆,所以这里需要特别说明一下,准备阶段只是对静态变量设置初始默认值,而真正赋值操作是在初始化阶段完成的。例如,下面示例代码在执行时:

1public class A {
2    static int test = 999; 
3}
  • 准备阶段会对变量 test 设置默认值 0
  • 初始化阶段会对变量 test 赋予初始值 999

常用类型的默认初始值如下表所示:

数据类型 默认值
byte 0
int 0
short 0
char \u0000
long 0L
float 0.0f
double 0.0d
boolean false
reference(引用类型) null

注意:

  • 类变量指的是被 static 修饰的变量,主要包括类变量常量
  • 准备阶段不会对实例变量设置默认值,实例变量是会随着对象一起分配到中。
  • 方法区是一个逻辑概念:
    • 在 JDK 7 以及之前的版本中,Hotspot 虚拟机使用永久代来实现方法区,类变量存储于永久代中;
    • 在 JDK 8 及以后的版本中去除了永久代,而是使用操作系统内存和堆内存来实现方法区,而类变量则存储于堆内存中;
  • 准备阶段不会对 final static 修饰的常量分配内存空间,因为被 final 修饰的变量在编译时已经分配完成 (编译器优化)。比如 public static final int NUM = 1 这个常量,编译时 javac 会为常量值生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性进行设置,赋值为 1

5.3 解析阶段 (Resolution)

解析阶段主要是用于将 字节码常量池 中的 符号引用 替换为 直接引用 的过程。

  • 符号引用 (Symbolic References): 符号引用就是用于描述引用目标的一组符号,它可以是任何形式的字面量 (只要符合 Java 虚拟机规范)。
  • 直接引用 (Direct References): 直接引用可以是直接指向目标的指针、相对偏移量,或者是一个能间接定位到目标的句柄。

注意:

  • 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经在内存中,可能还没被加载。
  • 直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在。
  • 同一个符号引用可能会被多次解析,一般情况下虚拟机会缓存第一次解析的结果 (除 invokedynamic 指令以外),譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。
  • invokedynamic 指令用于支持动态语言,它对应的引用称为动态调用点限定符,这里动态的含义是指必须等到程序实际运行到这条指令时,才进行解析动作,所以每次执行该指令时必须不会使用缓存,而是重新执行解析动作将符合引用替换位直接引用。

六、过程-初始化阶段 (Initialization)

6.1 初始化阶段作用

初始化阶段主要是执行 类构造器 方法 <clinit>(),该方法不需要定义,代码在经过 Javac 编译器编译时,会自动收集类中的所有 类变量 的赋值动作和 静态代码块 中的语句,对这些代码进行合并,形成类构造器 <clinit>()

在执行类构造器 <clinit>() 时,会对类中的 类变量静态代码块 进行初始化赋值操作,如果该类存在父类,则会先执行父类中的类构造器 <clinit>(),对父类中的 类变量静态代码块 进行初始化。

口诀: 由父及子,静态先行。

6.2 初始化阶段时机

  • 访问某个类中的静态方法;
  • 对某个类中的静态变量赋值;
  • 访问某个类或接口的中的静态变量;
  • 初始化某个类的子类,则其父类也会被初始化;
  • 使用 new 创建对象,比如 new Object();
  • 使用反射创建对象,比如 Class.forName("…")
  • Java 虚拟机启动时被标明为启动类的类,直接使用 java.exe 命令来运行某个主类。

6.3 初始化时的顺序

(1) 当初始化的类存在父类时先初始化父类

验证当初始化子类,是否会先初始化父类,示例如下:

父类:

 1public class InitializationTest {
 2
 3    public static int number;
 4
 5    static {
 6        System.out.println(number);
 7        System.out.println("父类 static{} 初始化");
 8    }
 9
10}

子类:

 1public class SubInitialization extends InitializationTest {
 2
 3    static{
 4        // number 属于父类的属性,这里要能执行成功,说明父类已经加载
 5        number = 100;
 6        System.out.println("子类 static{} 初始化");
 7    }
 8
 9    public static void main(String[] args) {
10        System.out.println(number);
11    }
12
13}

执行时输出如下:

10
2父类 static{} 初始化
3子类 static{} 初始化
4100

根据以上输出内容顺序,可以分析出初始化顺序为:

  • 父类的 类变量 先初始化;
  • 父类的 静态代码块 初始化;
  • 子类的 类变量 初始化;
  • 子类的 静态代码块 初始化;

(2) 类中的静态变量和静态代码块初始化顺序

验证静态变量和静态代码块的初始化顺序,示例如下:

 1public class ClassInitTest {
 2
 3    static {
 4        number = 20;
 5        System.out.println(number);  //报错,非法的前向引用
 6    }
 7
 8    private static int number = 10;
 9
10}

上面代码在编译期间就会报错,这是因为静态变量的顺序在静态代码块的下方,而初始化执行的时候会自上而下,在上面示例代码里面,会先初始化静态代码块,然后再初始化静态变量,所以会编译报错。

七、过程-使用阶段 (Using)

加载的类在经过 加载链接初始化 三个阶段之后,加载的类就可以被使用了。开发人员可以在应用程序中,访问和调用类的静态类成员信息。比如,调用类的静态变量、静态方法,或者使用 new 关键字为其创建对象实例等。

八、过程-卸载阶段 (Unloading)

当代表一个类的 Class 对象不再被引用,那么 Class 对象的生命周期就结束了,对应的在方法区中的数据也会被卸载。不过有以下几点需要注意:

  • (1) 由 引导类加载器 所加载的类,在整个运行期间是都不会被卸载的,因为在整个 JVM 生命周期中,JVM 始终会引用这些类;
  • (2) 由 应用类加载器扩展类加载器 所加载的类,在运行期间几乎不会被卸载,因为这俩种类加载器加载的类型实例,基本上在整个运行期间总会直接或者间接的被访问的到,其状态变为不可达的可能性极小;
  • (3) 由 开发者自定义的类加载器实例 加载的类,只有在简单的上下文环境中才有可能被卸载,而且一般情况下还要借助虚拟机的垃圾收集功能才可以实现,因此在复杂的场景中,被加载的类的类型在运行期间也是几乎不太可能被卸载的;

根据以上几点,一个已经被加载的类几乎不会被卸载,而且卸载的时间也是不确定的,所以开发者在开发代码时,不应根据类卸载来实现特定功能。

---END---


  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。