深入分析Java类的加载过程

Free Green Mountains and Body of Water Stock Photo

Photo by rizknas from Pexels:

Java是一种面向对象的编程语言,它的核心特性之一就是动态加载类。Java程序在运行时可以根据需要加载和卸载类,从而实现灵活的功能扩展和更新。那么,Java中类是如何从文件加载到内存中的Class对象呢?本文将从Java虚拟机的角度,详细介绍类的加载过程,包括类加载器、类文件格式、类加载阶段、类初始化过程等方面,以深入理解Java中类加载的本质和机制。

前言

在Java中,数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。所谓类的加载,就是将Java类的字节码文件加载到虚拟机中,并在运行期间动态生成Java类的实例。这个过程涉及到了Java虚拟机的类加载器、运行时数据区等多个方面,并且包含了很多的细节和技术问题。

为什么要了解Java中类的加载过程呢?有以下几个原因:

  • 类的加载过程是Java虚拟机实现动态语言特性的基础,它影响着Java程序的运行效率和安全性。
  • 类的加载过程涉及到了很多重要的概念和技术,如字节码、反射、热部署等,掌握这些知识可以提高Java开发者的编程能力和水平。
  • 类的加载过程也是Java面试中常见的考点,了解这些知识可以帮助Java开发者更好地应对面试。

另外,虽然Spring Boot 3.0开始已经强制使用Java 17,很多开发者在学习的时候也积极跟进,但是很多公司不会为了追求新特性和功能,冒着风险升级底层依赖和框架,所以本文内讲述的内容依旧基于Java8。

类加载器

类加载器(ClassLoader)是负责将类装载到内存中,并为其创建一个Class对象。Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。Class对象是访问类型元数据(元数据:描述数据信息) 的接口,也是实现反射(反射:在运行时动态获取类的信息和操作类的对象)的关键数据、入口。通过Class对象提供的接口,可以获得目标类所关联的.class文件中具体的数据结构:方法、字段等信息。

Java虚拟机定义了三种类加载器,分别为 Bootstrap ClassLoaderExtension ClassLoaderSystem ClassLoader,它们按照层次关系进行组织,而且每个类加载器都有自己独立的命名空间,保证了不同类加载器之间的隔离性。

ClassLoader in Java - Javatpoint

  • Bootstrap ClassLoader(启动类加载器):由C++编写,负责加载Java运行环境(JRE)核心库,例如java.lang包等。它是JVM的内置类加载器,在JVM启动时就会被初始化。
  • Extension ClassLoader(扩展类加载器):用来加载Java扩展库,位于JRE的/lib/ext目录下,或者通过java.ext.dirs系统变量指定的其他目录中。
  • System ClassLoader(系统类加载器):用来加载应用程序路径上的类,也称为应用程序类加载器。它是ClassLoader类的子类,通常是由Java应用程序创建的默认类加载器。

除了这三种内置的类加载器之外,还可以通过继承ClassLoader类并重写findClass()方法来自定义类加载器。自定义类加载器可以从指定的路径或者网络地址上加载字节码文件,也可以实现一些特殊的功能,如加密解密、热部署等。

类加载器的双亲委派模型

类加载器的双亲委派模型是指当一个类加载器需要加载一个类时,它首先会将这个任务委托给它的父类加载器去完成,如果父类加载器无法加载,则再由自己来尝试加载。这样就形成了一个从下到上的层次结构,保证了同一个命名空间中不会出现重复的类。

Types of Class Loader

双亲委派模型有以下几个优点:

  • 避免了重复加载同一个类,节省了内存空间和时间。
  • 保证了Java核心库的安全性和稳定性,防止了用户自定义的恶意代码覆盖或篡改Java核心库。
  • 促进了不同模块之间的协作和解耦,提高了代码的可复用性和可维护性。

双亲委派模型也有一些缺点:

  • 降低了灵活性和兼容性,可能导致一些合法且有用的代码无法被正确地执行。
  • 不适合一些需要隔离或动态更新的场景,如OSGi、Spring Boot等。

为了解决这些问题,可以使用线程上下文类加载器(Thread Context ClassLoader)来打破双亲委派模型。线程上下文类加载器是每个线程所持有的一个属性,它可以通过 Thread.currentThread().setContextClassLoader() 方法来设置,并通过Thread.currentThread().getContextClassLoader() 方法来获取。线程上下文类加载器可以让用户自己指定需要使用哪个类加载器来加载某个类或资源,从而实现更加灵活和动态的类加载机制。

类文件格式

Java源文件经过编译后会生成.class文件,也就是字节码文件。字节码文件是一种二进制文件,它包含了Java虚拟机可以执行的指令集和相关数据。字节码文件是Java实现跨平台特性的基础,它可以在不同平台上运行相同或不同类型的Java虚拟机。

类文件格式的结构

Java虚拟机规范定义了.class文件格式的结构由以下几个部分组成:

  • 魔数(Magic Number):占用4个字节,用来标识这是一个有效的.class文件,其固定值为0xCAFEBABE。
  • 次版本号(Minor Version):占用2个字节,用来表示.class文件的次版本号,一般为0。
  • 主版本号(Major Version):占用2个字节,用来表示.class文件的主版本号,与Java平台的版本对应,如52代表Java 8,55代表Java 11等。
  • 常量池计数器(Constant Pool Count):占用2个字节,用来表示常量池中常量的数量,其值为常量池大小加1。
  • 常量池(Constant Pool):占用不定长度的字节,用来存放各种常量信息,如类名、字段名、方法名、字面量等。常量池中每个常量都有一个标志位(tag),用来表示常量的类型���如1代表Utf8字符串,7代表类或接口符号引用等。
  • 访问标志(Access Flags):占用2个字节,用来表示类或接口的访问权限和属性,如public、final、abstract等。每个标志位都有一个固定的含义,如0x0001代表public,0x0010代表final等。
  • 类索引(This Class):占用2个字节,用来表示当前类在常量池中的索引值。
  • 父类索引(Super Class):占用2个字节,用来表示当前类的父类在常量池中的索引值。如果当前类是Object类,则父类索引为0。
  • 接口计数器(Interfaces Count):占用2个字节,用来表示当前类实现的接口数量。
  • 接口表(Interfaces):占用不定长度的字节,用来存放当前类实现的接口在常量池中的索引值。
  • 字段计数器(Fields Count):占用2个字节,用来表示当前类或接口声明的字段数量。
  • 字段表(Fields):占用不定长度的字节,用来存放当前类或接口声明的字段信息。每个字段信息包括访问标志、名称索引、描述符索引、属性计数器和属性表等。
  • 方法计数器(Methods Count):占用2个字节,用来表示当前类或接口声明的方法数量。
  • 方法表(Methods):占用不定长度的字节,用来存放当前类或接口声明的方法信息。每个方法信息包括访问标志、名称索引、描述符索引、属性计数器和属性表等。
  • 属性计数器(Attributes Count):占用2个字节,用来表示当前类或接口附加的属性数量。
  • 属性表(Attributes):占用不定长度的字节,用来存放当前类或接口附加的属性信息。每个属性信息包括名称索引、长度和具体内容等。

列举出的内容可参考Java8文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html

这里就不把文档里的东西都翻译了,学习的时候可以对照着官方文档尝试阅读其底层结构。

类加载阶段

从.class文件到加载到内存中的类,到类卸载出内存位置,它的整个生命周期包括如下七个阶段:

  • 加载(Loading):通过类加载器读取.class文件中的二进制字节流,并将其转换成Java虚拟机中的Class对象。
  • 连接(Linking)可再分为以下三步
    • 验证(Verification):对类的字节码进行格式、语义和字节码等方面的检查,以确保它是正确、安全且符合规范的。
    • 准备(Preparation):为类的静态变量分配内存,并将其初始化为默认值。
    • 解析(Resolution):将类的符号引用替换为直接引用,即确定类的实际位置。
  • 初始化(Initialization):执行类的静态初始化器和静态初始化块,对类的静态变量进行赋值操作。
  • 使用(Using):创建类的实例,调用类的方法,访问类的字段等。
  • 卸载(Unloading):回收类所占用的内存空间。

从程序中类的使用过程看,加载、验证、准备、解析、初始化五个步骤的执行过程,就是类的加载过程。使用和卸载两个过程,不属于类加载过程。

但是,这五个阶段并不是严格意义上的按顺序完成,在类加载的过程中,这些阶段会互相混合,交叉运行,最终完成类的加载和初始化。例如,在加载阶段,需要使用验证的能力去校验字节码正确性。在解析阶段,可能会触发某些类的初始化。在初始化阶段,可能会导致某些超类或接口的加载等。

graph LR A(开始) B(加载类文件) C(验证类文件) D(准备阶段) E(解析符号引用) F(初始化阶段) G(生成实例) H(结束) A --> B B --> C C --> D D --> E E --> F F --> G G --> H

下面分别介绍每个类加载阶段的具体作用和内容。

加载阶段

加载阶段是类加载过程的第一阶段,它主要完成以下三件事情:

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
  • 创建java.lang.Class对象,表示该类型。作为方法区这个类的各种数据的访问入口

获取二进制字节流

获取二进制字节流是指从不同的数据源中读取类文件或者动态生成类文件,并将其转换成字节数组形式。这个过程由Java虚拟机的类加载器(ClassLoader)来完成。

Java虚拟机规范并没有规定从哪里获取二进制字节流,也没有规定如何获取,只要求最终能够得到一个有效的字节数组即可。因此,在Java发展过程中,出现了很多不同类型和功能的类加载器,它们可以从不同的途径和方式来获取二进制字节流。例如:

  • 从本地文件系统中读取.class文件。
  • 从ZIP包中读取.class文件,例如JAR、WAR、EAR等格式。
  • 从网络中获取.class文件,例如Applet、RMI等技术。
  • 从数据库中读取.class文件。
  • 运行时动态生成.class文件,例如动态代理、JSP等技术。
  • 其他自定义方式。

就以JSP这种比较老的技术来举例,可能现在只有在一些技术栈很落后的公司或者大学内才会使用这种技术了。JSP的编译和类加载过程通常由JSP容器(如Tomcat)来完成。容器在首次访问JSP页面时会执行上述过程,将JSP转换为Java源代码并生成相应的类文件。

Jasper是Tomcat的JSP引擎,通过Jasper,Tomcat能够动态地将JSP文件转换为可执行的Java类,并在需要时进行编译和加载。这使得Tomcat能够实时响应JSP页面的变化,并提供动态生成的Web内容。

对具体实现感兴趣的同学可以参考Tomcat源码中的org.apache.jasper.compiler.Compiler#compile(boolean, boolean)org.apache.jasper.JspCompilationContext#compile

转化运行时数据结构

转化运行时数据结构是指将二进制字节流所代表的静态存储结构转化为方法区(JDK1.8后为元数据区)中存储的运行时数据结构。这个过程主要涉及到了方法区和常量池两个重要的组成部分。

方法区是Java虚拟机规范中定义的一种规范,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。方法区在JDK1.7及之前被称为永久代(PermGen),在JDK1.8及之后被称为元数据区(Metaspace)。不同版本和不同厂商的虚拟机实现方式可能会有所不同,但是都需要遵循方法区的规范。

常量池是每个Class文件中都包含的一个数据结构,它用于存储编译期生成的各种字面量和符号引用。常量池在Class文件中以表格形式存在,在运行时会被加载到方法区中,并且每个Class对象都有一个指向自己常量池在方法区中位置的引用。常量池中存储了很多重要的信息,例如:

  • 字面量:文本字符串、声明为final的常量值等。
  • 符号引用:类和接口的全限定名、字段名称和描述符、方法名称和描述符等。
  • 动态调用点:invoke dynamic 指令所需的引导方法等。

在转化运行时数据结构时,虚拟机会根据Class文件中存储的信息,在方法区中创建一个Class对象,并为其分配内存空间。然后,虚拟机会将Class文件中除了常量池之外的其他信息(如版本号、修饰符、字段表、方法表等)复制到Class对象中,并对其中一些信息进行必要的处理。例如:

  • 为每个字段分配一个唯一偏移地址。
  • 为每个方法生成一个唯一索引号。
  • 为每个接口生成一个唯一索引号。
  • 为每个内部类型生成一个唯一标识符。

同时,虚拟机还会将Class文件中存储的常量池表复制到方法区中,并对其中一些信息进行必要的处理。例如:

  • 将CONSTANT_Utf8_info型常量转换成String对象。
  • 将CONSTANT_Class_info型常量解析为Class对象的引用。
  • 将CONSTANT_Fieldref_info、CONSTANT_Methodref_info和CONSTANT_InterfaceMethodref_info型常量解析为字段、方法和接口方法的直接引用。
  • 将CONSTANT_String_info型常量解析为String对象的引用。
  • 将CONSTANT_MethodHandle_info型常量解析为方法句柄对象的引用。
  • 将CONSTANT_MethodType_info型常量解析为方法类型对象的引用。
  • 将CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info型常量解析为动态调用点对象的引用。

常量池的举例

为了更好地理解常量池的内容和格式,我们可以使用javap命令来查看一个简单类的常量池信息。假设我们有一个如下所示的Java类:

/**
 * @author fengxiao
 * @created 2023/6/10
 */
public class ConstantPoolExample {
    public static final int NUM = 100;
    public static final String STR = "Hello";

    public void print() {
        System.out.println(NUM + STR);
    }
}

使用javac编译、javap查看类信息:

 javac .\ConstantPoolExample.java
 javap -v ConstantPoolExample.class

控制台打印结果:

PS D:\Project\laboratory\src\main\java\com\landscape\jvm> javap -v ConstantPoolExample.class
Classfile /D:/Project/laboratory/src/main/java/com/landscape/jvm/ConstantPoolExample.class
  Last modified 2023年6月10日; size 535 bytes
  SHA-256 checksum ad75ffa74eb21ea42042d9fdd0130914be741a65b98a55b1f044d43eadb6a1aa
  Compiled from "ConstantPoolExample.java"
public class com.landscape.jvm.ConstantPoolExample
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #13                         // com/landscape/jvm/ConstantPoolExample
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 2, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."`<init>`":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "`<init>`":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               `<init>`
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = Class              #14            // com/landscape/jvm/ConstantPoolExample
  #14 = Utf8               com/landscape/jvm/ConstantPoolExample
  #15 = String             #16            // 100Hello
  #16 = Utf8               100Hello
  #17 = Methodref          #18.#19        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #18 = Class              #20            // java/io/PrintStream
  #19 = NameAndType        #21:#22        // println:(Ljava/lang/String;)V
  #20 = Utf8               java/io/PrintStream
  #21 = Utf8               println
  #22 = Utf8               (Ljava/lang/String;)V
  #23 = Utf8               NUM
  #24 = Utf8               I
  #25 = Utf8               ConstantValue
  #26 = Integer            100
  #27 = Utf8               STR
  #28 = Utf8               Ljava/lang/String;
  #29 = String             #30            // Hello
  #30 = Utf8               Hello
  #31 = Utf8               Code
  #32 = Utf8               LineNumberTable
  #33 = Utf8               print
  #34 = Utf8               SourceFile
  #35 = Utf8               ConstantPoolExample.java
{
  public static final int NUM;
    descriptor: I
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 100

  public static final java.lang.String STR;
         4: return
      LineNumberTable:
        line 7: 0

  public void print();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #15                 // String 100Hello
         5: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8
}
SourceFile: "ConstantPoolExample.java"

从上面的输出中,我们可以看到常量池表中有多种类型的常量,例如:

  • CONSTANT_Methodref_info:表示方法的符号引用,例如#1表示java.lang.Object类的<init>方法。
  • CONSTANT_Fieldref_info:表示字段的符号引用,例如#7表示java.lang.System类的out字段。
  • CONSTANT_String_info:表示字符串字面量,例如#29表示"Hello"字符串。
  • CONSTANT_Class_info:表示类或接口的符号引用,例如#2表示java.lang.Object类。
  • CONSTANT_Utf8_info:表示UTF-8编码的字符串,例如#5表示"<init>"字符串。
  • CONSTANT_NameAndType_info:表示字段或方法的名称和描述符,例如#14表示"<init>😦)V"。
  • CONSTANT_Integer_info:表示整数常量,例如#26表示100。

我们还可以看到类文件中的其他信息,例如访问标志、字段表、方法表、属性表等,都是以常量池中的索引来引用的。例如:

  • NUM字段的ConstantValue属性值为#23,表示其初始值为100。
  • print方法的Code属性中的字节码指令invokevirtual#5表示调用java.io.PrintStream类的println方法。

连接(Linking)

连接是类加载过程的第二个阶段,也是将类的二进制数据合并到虚拟机运行时环境中的过程2。连接阶段主要包含验证、准备、解析三个步骤,下面我们逐一进行介绍。

验证(Verification)

验证是连接操作的第一步,它的目的是保证加载的字节码是合法、合理并符合规范的。验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查:

  • 文件格式验证:是否以魔数0xCAFEBABE开头,主版本和副版本号是否在当前Java虚拟机的支持范围内,数据中每一个项是否都拥有正确的长度等;
  • 元数据验证:是否所有的类都有父类的存在(在Java里,除了Object外,其他类都应该有父类),是否一些被定义为final的方法或者类被重写或继承了,非抽象类是否实现了所有抽象方法或者接口方法,是否存在不兼容的方法(比如方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度;abstract情况下的方法,就不能是final的了)等;
  • 字节码验证:是否会跳转到一条不存在的指令,函数的调用是否传递了正确类型的参数,变量的赋值是不是给了正确的数据类型等;
  • 符号引用验证:是否所有引用的类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用的类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError。

如果在这个阶段无法通过检查,虚拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。因为100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。

举个具体的例子,有如下代码:

/**
 * @author fengxiao
 * @created 2023/6/11
 */
public class VerificationErrorExample {
    public static void main(String[] args) {
        System.out.println("Verification Example");

        // 定义一个数组
        int[] numbers = new int[-1];
        System.out.println(numbers.length);
    }
}

在这个示例中,我们尝试定义一个长度为负数的数组。在编译时,这段代码不会报错,但在类加载的验证阶段,Java虚拟机会进行字节码验证,并发现这段代码不符合规范。因为数组的长度不能为负数,所以在验证阶段会抛出VerifyError异常。

当运行上述代码时,会得到类似以下的异常堆栈跟踪信息:

Verification Example
Exception in thread "main" java.lang.NegativeArraySizeException: -1
	at com.landscape.jvm.VerificationErrorExample.main(VerificationErrorExample.java:12)

准备(Preparation)

准备阶段(Preparation),为类的静态变量分配内存,并将其初始化为默认值。当一个类验证通过时,虚拟机就会进入准备阶段。在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值。Java虚拟机为各类型变量默认的初始值如表所示:

类型 默认初始值
byte (byte)0
short (short)0
int 0
long 0L
float 0.0f
double 0.0
char \u0000
boolean false
reference null

这里不包含基本数据类型的字段用static final修饰的情况,因为final在编译的时候就会分配了,准备阶段会显式赋值。注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行。

解析(Resolution)

解析阶段虚拟机会将类、接口、字段和方法的符号引用转为直接引用。符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当println()方法被调用时,系统需要明确知道该方法的位置。

以方法为例,Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。

所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。

不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpot VM中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行。

初始化阶段

初始化阶段(Initialization)是类加载过程的最后一阶段,它主要完成以下两件事情:

  • 执行类构造器<clinit>()方法,对类的静态变量进行赋值操作。
  • 执行类的主动引用,触发类的初始化。

类构造器``()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {}块)中的语句合并产生的,它们按照源代码中出现的顺序依次执行,静态初始化器和静态初始化块在类加载过程中只会执行一次。

<clinit>()方法与实例构造器<init>()方法不同,它不需要显示地调用父类构造器,虚拟机会保证在子类<clinit>()方法执行之前,父类<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法肯定是java.lang.Object。

public class Test {
    static {
        i = 0; // 给变量赋值可以正常编译通过
        System.out.print(i); // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}

上面代码中,在静态语句块中给变量i赋值可以正常编译通过,但是在静态语句块中输出变量i就会提示“非法向前引用”。这是因为变量i在静态语句块之后才定义并赋值,所以在静态语句块中对变量i的访问相当于“向前引用”。

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态变量,或者静态变量都是在编译期间确定的常量(被final修饰的基本数据类型或String类型),那么编译器可以不为这个类生成<clinit>()方法。

我们可以在Idea的插件jclasslib中方便的查看使用javap命令反编译的内容,首先是存在静态属性或者静态代码块的内容如下:

image-20230611193057189

而如何我们将静态属性和静态代码块全部注释掉,则会发现重新反编译后不存在<clinit>方法了:

image-20230611193152945

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量被使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

类的主动引用是指直接使用该类或接口的情况,有以下六种情况:

  • 创建类的实例,如new、反射、克隆、反序列化等。
  • 调用类的静态方法,如main方法。
  • 访问类或接口的静态变量,或者对该静态变量赋值。
  • 使用反射方式强制创建某个类或接口对应的java.lang.Class对象。
  • 初始化一个类的子类,会先触发父类的初始化。
  • Java虚拟机启动时被标明为启动类的类(包含main方法的那个类)。
  • 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle 实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

除了以上几种情况,其他使用Java类或接口的方式都被看作是被动引用,不会触发该类或接口的初始化。例如:

  • 通过子类引用父类的静态变量,不会导致子类初始化。
  • 通过数组定义来引用类,不会触发该类的初始化。
  • 通过常量引用来引用类,不会触发该类的初始化(常量在编译阶段会存入调用类的常量池中)。

初始化阶段是执行Java代码(字节码)的过程,因此可能会出现异常或错误。如果一个类在初始化过程中失败了,那么后续对该类的访问都会抛出NoClassDefFoundError异常。

在初始化完成后,类将被完全加载并且可以被使用。

类的卸载

类的卸载需要满足以下三个条件:

  • 该类的所有实例对象都已经被垃圾回收,也就是说堆中不存在该类的任何实例对象。
  • 该类没有在任何地方被引用,即没有任何强、软、弱或虚引用指向该类。
  • 该类的类加载器的实例已经被垃圾回收。

如果以上三个条件都满足,那么该类就有可能被卸载。但是,并不是一定会被卸载,因为虚拟机会根据自身的情况来决定是否执行垃圾回收和类卸载。如果虚拟机内存充足,那么可能不会触发垃圾回收和类卸载。如果想要强制触发垃圾回收和类卸载,可以使用System.gc()方法或者-XX:+TraceClassUnloading参数来查看类卸载的过程。

类的卸载示例

由Java虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。这是因为Java虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的Class对象,因此这些Class对象始终是可触及的。Java虚拟机自带的类加载器包括根类加载器、扩展类加载器和系统类加载器。

由用户自定义的类加载器所加载的类是可以被卸载的。下面我们通过一个简单的示例来演示一下用户自定义的类加载器如何实现类的卸载。

首先,我们定义一个Sample类,它只有一个构造方法和一个sayHello方法:

public class Sample {
    public Sample() {
        System.out.println("Sample is loaded by " + this.getClass().getClassLoader());
    }

    public void sayHello() {
        System.out.println("Hello!");
    }
}

复制

然后,我们定义一个MyClassLoader类,它继承了ClassLoader,并重写了findClass方法来实现自定义的类加载逻辑:

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class MyClassLoader extends ClassLoader {

    private String classPath;

    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] data = loadByte(name);
            return defineClass(name, data, 0, data.length);
        } catch (IOException e) {
            e.printStackTrace();
            throw new ClassNotFoundException();
        }
    }

    private byte[] loadByte(String name) throws IOException {
        name = name.replaceAll("\\.", "/");
        FileInputStream fis = new FileInputStream(new File(classPath + name + ".class"));
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        int b = 0;
        while ((b = fis.read()) != -1) {
            baos.write(b);
        }
        fis.close();
        return baos.toByteArray();
    }
}

最后,我们编写一个测试类,来使用MyClassLoader加载Sample类,并调用其sayHello方法。然后,我们将MyClassLoader和Sample的引用置为null,并调用System.gc()方法,观察是否会触发类的卸载:

public class Test {

    public static void main(String[] args) throws Exception {
        MyClassLoader myClassLoader = new MyClassLoader("D:/test/");
        Class<?> clazz = myClassLoader.loadClass("Sample");
        Object obj = clazz.newInstance();
        obj.getClass().getMethod("sayHello").invoke(obj);
        obj = null;
        clazz = null;
        myClassLoader = null;
        System.gc();
    }
}

为了查看类的卸载过程,我们需要在运行时添加-XX:+TraceClassUnloading参数。运行结果如下:

Sample is loaded by MyClassLoader@2a139a55
Hello!
[Unloading class Sample 0x0000000800061028]
[Unloading class MyClassLoader 0x0000000800060b28]

可以看到,当MyClassLoader和Sample的引用都被置为null后,垃圾回收器回收了它们,并触发了类的卸载。这说明,由用户自定义的类加载器所加载的类是可以被卸载的。

posted @ 2023-06-11 19:38  夜色微光  阅读(220)  评论(0编辑  收藏  举报