JVM学习-类加载机制

2024/1/15 5:02:24

本文主要是介绍JVM学习-类加载机制,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

文章原文:https://gaoyubo.cn/blogs/4b481fd7.html

一、类加载机制

在JVM学习-Class文件结构中,讲了Class文件存储格式的具体细节。虽然Class文件中描述了各种类信息,但要让这些信息在虚拟机中运行和使用,就需要加载到内存中。本章将重点介绍虚拟机的类加载机制,包括Class文件如何加载到内存、加载后的信息发生何种变化等方面的内容。

Java虚拟机通过将描述类的数据从Class文件加载到内存中,进行校验、转换解析和初始化,最终生成可以被虚拟机直接使用的Java类型。这一过程即为虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,Java语言中类型的加载、连接和初始化过程都在程序运行期间完成。尽管这种策略可能导致编译时的一些困难和类加载时的性能开销略微增加,但它为Java应用程序提供了极高的扩展性和灵活性。Java天生支持动态扩展的语言特性依赖于运行时的动态加载和动态连接

例如,编写一个面向接口的应用程序,可以在运行时指定其实际的实现类。用户可以通过Java预置的或自定义的类加载器,在运行时从网络或其他位置加载一个二进制流作为程序代码的一部分。这种动态组装应用的方式已广泛应用于Java程序,涵盖了从基础的Applet、JSP到相对复杂的OSGi技术。这一创新的方法使得Java语言能够适应多样化的应用需求。

二、类加载时机

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

image-20231117211841247

类加载过程包括加载、验证、准备、初始化和卸载这五个阶段。这些阶段的顺序是确定的,必须按部就班地开始,而解析阶段则不一定。解析阶段在某些情况下可以在初始化阶段之后再开始,以支持Java语言的运行时绑定特性(动态绑定或晚期绑定)。值得注意的是,这些阶段通常是互相交叉地混合进行的,在一个阶段执行的过程中可能调用、激活另一个阶段。

关于何时需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有强制约束,这点可以由虚拟机的具体实现*把握。然而,在初始化阶段,《Java虚拟机规范》明确规定了六种情况必须立即对类进行“初始化”(加载、验证、准备自然需要在此之前开始):

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

这六种会触发类型进行初始化的场景被称为主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用

被动引用

示例一

package algorithmAnalysis;
/** 
 * 被动使用类字段演示一:
 * 通过子类引用父类的静态字段,不会导致子类初始化 
 **/
public class SuperClass {
    static { 
        System.out.println("SuperClass init!");
    } 
    
    public static int value = 123;
}

public class SubClass extends SuperClass {
    static { 
        System.out.println("SubClass init!");
    }
}

/** 
 * 非主动使用类字段演示 
 **/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

上述代码中,运行后只会输出“SuperClass init!”而不会输出“SubClass init!”。

这是因为对于静态字段,只有直接定义这个字段的类才会被初始化。通过子类引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明确规定,因此这一点取决于虚拟机的具体实现。在HotSpot虚拟机中,可以通过添加参数-XX:+TraceClassLoading观察到这个操作会导致子类加载,输出结果如下。

image-20231117214120108

示例二

package algorithmAnalysis;
/**
 * 通过数组定义来引用类,不会触发此类的初始化
 **/
public class Test {
    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }
}

这段代码复用了示例一的SuperClass,运行之后发现没有输出“SuperClass init!”,说明并没有触发类algorithmAnalysis.SuperClass的初始化阶段。但是这段代码里面触发了另一个名为“[LalgorithmAnalysis.SuperClass”的类的初始化阶段。

image-20231117215346256

对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由
字节码指令newarray触发。

这个类代表了一个元素类型为algorithmAnalysis.SuperClass的一维数组,数组中应有的属性 和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。

Java语 言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问(准确地说,越界检查不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节 码指令中),而 C/C++中则是直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出java.lang.ArrayIndexOutOfBoundsException异常,避免了直接造成非法内存访问。

三、类加载过程

3.1加载

在加载阶段,Java虚拟机执行以下三个主要任务:

  1. 获取二进制字节流: 通过类的全限定名获取对应的二进制字节流,这是表示类的静态存储结构的基础。
  2. 转化为方法区数据结构: 将获取的字节流表示的静态存储结构转化为方法区的运行时数据结构。这包括对类的字段、方法、接口等信息的整理和组织。
  3. 生成Class对象: 在内存中创建一个java.lang.Class对象,用于在方法区中访问该类的各种数据。这个Class对象是对类的抽象,通过它可以获取类的各种信息。

《Java虚拟机规范》确实在对类加载的过程中给予了相当大的灵活性,没有强制指定二进制字节流必须从Class文件中获取,这为Java虚拟机的实现和应用带来了广泛的适用性和可扩展性。开发人员在这个灵活的舞台上发挥了巨大的创造力,导致了许多重要的Java技术的诞生。以下是一些典型的应用场景:

  • 从ZIP压缩包中读取: 这为日后JAR、EAR、WAR等格式的应用打下了基础,这些格式在Java应用中广泛使用,提供了一种方便的打包和分发方式。
  • 从网络中获取: Web Applet是一个典型的应用场景,它允许在Web浏览器中加载并执行Java小程序,通过网络获取字节流。
  • 运行时计算生成: 动态代理技术是一个重要的应用,它允许在运行时生成代理类的字节流,用于实现动态代理。
  • 由其他文件生成: JSP应用是一个例子,其中JSP文件会在运行时被编译成对应的Class文件,实现了动态生成和加载。
  • 从数据库中读取: 在一些中间件服务器中,程序代码可以安装到数据库中,通过加载时从数据库获取相应的字节流,实现了在集群间的分发。
  • 从加密文件中获取: 采用加载时解密Class文件的方式,可以作为一种保护措施,防止Class文件被反编译。

加载阶段相对于类加载过程的其他阶段具有更高的可控性,尤其是在非数组类型的加载阶段。在这个阶段,开发人员可以通过以下方式灵活控制:

  1. 选择类加载器: 开发人员可以选择使用Java虚拟机内置的引导类加载器或自定义的类加载器来完成加载阶段。通过自定义类加载器,可以根据需求控制字节流的获取方式,例如重写类加载器的findClass()loadClass()方法。
  2. 动态获取字节流: 在加载阶段,开发人员可以通过自定义类加载器来动态获取类的二进制字节流。这为应用程序提供了获取运行代码的动态性,开发人员可以根据自己的需求实现字节流的获取逻辑。

对于数组类的加载,虽然数组类本身是由Java虚拟机直接在内存中动态构造的,但与类加载器仍然存在密切关系。数组类的创建遵循以下规则:

  • 如果数组的组件类型是引用类型,递归采用加载过程加载组件类型,数组类将被标识在加载该组件类型的类加载器的类名称空间上。
  • 如果数组的组件类型不是引用类型,数组类将被标记为与引导类加载器关联。

此外,数组类的可访问性与其组件类型的可访问性一致。如果组件类型不是引用类型,数组类的可访问性默认为public,可被所有的类和接口访问。

加载阶段结束后,二进制字节流按虚拟机设定的格式存储在方法区中。在方法区中,类型数据会被实例化为一个java.lang.Class对象,这个对象作为程序访问方法区中类型数据的外部接口。

需要注意的是,加载阶段与连接阶段的一些动作是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。这两个阶段的开始时间保持着固定的先后顺序。

3.2验证

Java语言本身具有相对较高的安全性,相比于C/C++等语言来说更为安全。使用纯粹的Java代码通常无法执行一些危险操作,比如访问数组边界以外的数据、将对象转型为其未实现的类型、跳转到不存在的代码行等。在这些情况下,编译器会严格抛出异常并拒绝编译。

然而,需要注意的是,Class文件并不一定只能由Java源码编译而来。任何途径产生的Class文件,包括直接在二进制编辑器中编写0和1的方式,都是有效的。因此,验证字节码是Java虚拟机保护自身安全的必要措施。

验证阶段在整个类加载过程中具有重要意义,其严谨程度直接影响Java虚拟机是否能够抵御恶意代码攻击。验证阶段涵盖了文件格式验证、元数据验证、字节码验证和符号引用验证等四个主要方面。

  1. 文件格式验证: 验证Class文件是否符合Java虚拟机规定的文件格式标准。
  2. 元数据验证: 确保类的元数据信息符合规范,包括类的继承关系、字段和方法的声明等。
  3. 字节码验证: 对字节码进行验证,防止恶意代码通过字节码漏洞对系统进行攻击。
  4. 符号引用验证: 确保类在运行时能够正确链接到其他类,并且这些类存在并具有正确的权限。

验证阶段的工作量相当大,涉及到整个类加载过程的安全性和性能。因此,它是保障Java应用程序安全执行的关键环节。

文件格式验证

在验证阶段的第一阶段,主要任务是验证字节流是否符合Class文件格式的规范,并且能够被当前版本的虚拟机正确处理。以下是包含在这一阶段的验证点:

  • 魔数验证: 检查Class文件是否以魔数0xCAFEBABE开头,这是Java Class文件的标识。
  • 版本号验证: 确保主版本号和次版本号是否在当前Java虚拟机接受的范围之内。
  • 常量池验证: 检查常量池中的常量类型是否被当前虚拟机支持,包括检查常量的tag标志。
  • 索引值验证: 确保指向常量的各种索引值没有指向不存在的常量,而且索引值的类型符合常量的类型。
  • UTF-8编码验证: 对于CONSTANT_Utf8_info型的常量,检查其中的数据是否符合UTF-8编码规范。
  • 文件结构验证: 确保Class文件中各个部分以及文件本身没有被删除的或附加的其他信息,保持结构的完整性。

元数据验证

在验证阶段的第二阶段,主要任务是对字节码描述的信息进行语义分析,以确保其描述的信息符合《Java语言规范》的要求。以下是包含在这一阶段的验证点:

  • 父类验证: 确保每个类除了java.lang.Object之外都应该有父类。
  • 继承验证: 检查父类是否继承了不允许被继承的类,即被final修饰的类。
  • 接口实现验证: 如果一个类不是抽象类,确保它实现了其父类或接口中要求实现的所有方法。
  • 字段和方法验证: 检查类中的字段和方法是否与父类产生矛盾,例如覆盖了父类的final字段,或者出现不符合规则的方法重载(方法参数一致但返回值类型不同等)。

这些验证点旨在对类的元数据信息进行语义校验,以确保它们符合Java语言规范的定义。

字节码验证

在验证阶段的第三阶段,通过数据流分析和控制流分析,目标是确定程序语义是合法的、符合逻辑的。在进行方法体的校验分析时,主要考虑以下验证点:

  1. 操作数栈和指令代码协同工作: 确保任何时刻操作数栈的数据类型与指令代码序列配合工作,防止出现操作栈放置了一个数据类型,使用时按不同类型加载入本地变量表的情况。
  2. 跳转指令的合法性: 保证任何跳转指令都不会跳转到方法体以外的字节码指令。
  3. 类型转换的有效性: 确保方法体中的类型转换总是有效的,例如可以将子类对象赋值给父类数据类型,而将父类对象赋值给子类数据类型是危险和不合法的。

为了降低在字节码验证阶段中的执行时间开销,Java虚拟机设计团队采用了联合优化策略。该策略在JDK 6之后实施,主要包括在Javac编译器中增加了校验辅助措施,并通过引入名为StackMapTable的新属性来描述方法体的基本块状态。这一策略的核心思想是通过在编译期执行尽可能多的校验辅助措施,从而减轻字节码验证期间的负担。具体而言:

  • Javac编译器中的校验辅助措施: Javac编译器在编译期执行一系列校验辅助措施,以便在方法体的Code属性中引入StackMapTable属性。这样,编译器在校验阶段就能够提供关于基本块状态的信息,减轻虚拟机在字节码验证期间的工作。
  • StackMapTable属性的引入: StackMapTable属性是一项新的属性,用于描述方法体的基本块状态。这个属性记录了基本块开始时本地变量表和操作栈应有的状态。虚拟机在字节码验证期间只需检查StackMapTable属性中的记录是否合法,而无需推导这些状态的合法性,从而减少了验证的时间开销。

符号引用验证

在类加载的最后一个阶段,校验行为发生在虚拟机将符号引用转化为直接引用的时候,即发生在连接的第三阶段——解析阶段中。符号引用验证旨在匹配类自身以外的各类信息,确保类能够正常访问其依赖的外部类、方法、字段等资源。此阶段通常需要校验以下内容:

  1. 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  2. 指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  3. 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。

符号引用验证的主要目的是确保解析行为能够正常执行。如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,如java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError等。

验证阶段对于虚拟机的类加载机制是重要但非强制执行的阶段,因为验证阶段只有通过或不通过的差别。一旦通过了验证,其后对程序运行期没有任何影响。

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

3.3准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段

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

初始值通常情况下是数据类型的零值:

  • public static int value = 123;
    • 准备后为 0,value 的赋值指令 putstatic 会被放在 <clinit>() 方法中,<clinit>()方法会在初始化时执行,也就是说,value 变量只有在初始化后才等于 123。

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

  • public static final int value = 123;()
    • 准备后为 123,因为被 static final 赋值之后 value 就不能再修改了,所以在这里进行了赋值之后,之后不可能再出现赋值操作,所以可以直接在准备阶段就把 value 的值初始化好。

3.4解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,JVM学习-Class文件结构-符号引用

  • 在此之前,常量池中的引用是不一定存在的,解析过之后,可以保证常量池中的引用在内存中一定存在。
  • 什么是 “符号引用” 和 “直接引用” ?
    • 符号引用:以一组符号描述所引用的对象(如对象的全类名),引用的目标不一定存在于内存中。
    • 直接引用:直接指向被引用目标在内存中的位置的指针等,也就是说,引用的目标一定存在于内存中。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。

invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符 (Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。
相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。

类或接口的解析

假设当前代码所处的类为D,解析一个从未解析过的符号引用N为一个类或接口C的直接引用通常涉及以下三个步骤:

  1. 加载类C: 如果C不是一个数组类型,虚拟机将把代表N的全限定名传递给当前类D的类加载器,以加载这个类C。在加载的过程中,可能会触发元数据验证和字节码验证,也可能导致其他相关类的加载,例如加载C的父类或实现的接口。如果加载过程中出现异常,解析过程失败。
  2. 加载数组元素类型(如果C是数组类型): 如果C是一个数组类型,而且数组的元素类型是对象类型,那么N的描述符将是类似于"[Ljava/lang/Integer"的形式。虚拟机将按照第一步的规则加载数组元素类型。如果N的描述符是类似于"java.lang.Integer"的形式,虚拟机生成一个代表该数组维度和元素类型的数组对象。
  3. 符号引用验证和访问权限检查: 如果前两步没有异常,那么C在虚拟机中已经成为一个有效的类或接口。在解析完成前,需要进行符号引用验证,以确认当前类D是否具有对C的访问权限。如果访问权限验证失败,将抛出java.lang.IllegalAccessError异常。在JDK 9及之后的版本中,需要考虑模块化的因素,即访问权限验证还需检查模块之间的访问权限。具体来说,一个D要访问C,至少满足以下三条规则之一:
    • 被访问的类C是public的,并且与访问类D处于同一个模块。
    • 被访问的类C是public的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的模块进行访问。
    • 被访问的类C不是public的,但是它与访问类D处于同一个包中。

字段解析

要解析一个未被解析过的字段符号引用,通常需要按照以下步骤进行:

  1. 解析类或接口符号引用: 对字段表内class_index项(class_index)中索引的CONSTANT_Class_info符号引用进行解析,即解析字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现异常,导致字段符号引用解析失败。
  2. 后续字段搜索: 如果类或接口符号引用解析成功,用C表示这个字段所属的类或接口。按照《Java虚拟机规范》的规定,对C进行后续字段搜索:
    • 如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,返回这个字段的直接引用,搜索结束。
    • 否则,如果在C中实现了接口,按照继承关系从下往上递归搜索各个接口和它们的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,返回这个字段的直接引用,搜索结束。
    • 否则,如果C不是java.lang.Object,按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,返回这个字段的直接引用,搜索结束。
    • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
  3. 权限验证: 如果查找成功返回了引用,对这个字段进行权限验证。如果发现不具备对字段的访问权限,抛出java.lang.IllegalAccessError异常。

解析规则确保Java虚拟机能够获得字段的唯一解析结果。在实际情况中,Javac编译器可能会采取比规范更加严格的约束,例如,当一个同名字段同时出现在某个类的接口和父类中,或者同时在自己或父类的多个接口中出现时,Javac编译器可能会拒绝编译为Class文件。

方法解析

方法解析的步骤与字段解析相似,通常包括以下步骤:

  1. 解析类或接口符号引用: 首先,需要解析方法表的class_index项中索引的方法所属的类或接口的符号引用。使用C表示这个类。如果解析成功,继续后续的方法搜索。
  2. 接口检查: 如果在类的方法表中发现class_index中索引的C是个接口,直接抛出java.lang.IncompatibleClassChangeError异常。
  3. 后续方法搜索: 根据以下步骤进行后续的方法搜索:
    • 在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,搜索结束。
    • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,搜索结束。
    • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,查找结束,抛出java.lang.AbstractMethodError异常。
    • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError
  4. 权限验证: 如果查找过程成功返回了直接引用,对这个方法进行权限验证。如果发现不具备对此方法的访问权限,抛出java.lang.IllegalAccessError异常。

接口方法解析

解析接口方法和解析类的方法在主要逻辑上是相似的,但由于接口和类在Java中有一些不同的特性,导致在解析过程中存在一些细微的差异:

  1. 类型检查: 在解析接口方法时,需要进行接口类型检查。如果接口方法表中发现所属的类(class_index中索引的C)是个类而不是接口,会直接抛出java.lang.IncompatibleClassChangeError异常。这是因为接口方法必须属于接口,而不能是类的方法。
  2. 搜索范围: 解析类的方法时,只需要在类本身及其父类中查找匹配的方法。而解析接口方法时,需要在接口本身及其所有父接口中递归查找。这是因为Java接口支持多重继承,一个接口可以继承多个父接口的方法。
  3. 多重继承处理: 对于可能存在多个父接口中有相匹配的方法的情况,解析接口方法时可以从中选择一个并返回。这一点与解析类的方法不同,因为类只有一个直接的父类,不存在多重继承的情况。

在JDK 9之前,Java接口中的所有方法默认都是public的,且不存在模块化的访问约束,因此接口方法的符号解析不会抛出java.lang.IllegalAccessError异常。然而,从JDK 9开始,引入了接口的静态私有方法以及模块化的访问约束,因此在JDK 9及以后的版本中,接口方法的访问可能会因为访问权限控制而抛出java.lang.IllegalAccessError异常。

3.5初始化

在Java虚拟机的类加载过程中,初始化阶段是加载过程的最后一个步骤。在初始化阶段,Java虚拟机执行类构造器<clinit>()方法,该方法是由编译器自动生成的,用于执行类中的所有类变量的赋值动作和静态语句块中的语句

以下是关于初始化阶段和<clinit>()方法的一些重要信息:

  • <clinit>()方法的生成: <clinit>()方法由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句生成。编译器根据源文件中语句的顺序来确定收集的顺序。
  • 静态语句块的顺序: 在静态语句块中,只能访问到定义在静态语句块之前的变量。静态语句块中可以赋值但不能访问定义在其后的变量。
  • <clinit>()与<init>()的区别: <clinit>()方法与类的构造函数(实例构造器<init>()方法)不同。它不需要显式调用父类构造器,并且Java虚拟机保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。第一个被执行的<clinit>()方法的类型是java.lang.Object
  • 接口中的<clinit>(): 接口中不能使用静态语句块,但仍然会有变量初始化的赋值操作,因此接口也会生成<clinit>()方法。与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。
  • 多线程初始化: Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中正确地加锁同步。如果多个线程同时初始化一个类,只会有其中一个线程执行该类的<clinit>()方法,其他线程需要阻塞等待。
  • 初始化阶段触发时机: 初始化阶段的触发时机包括对类的主动使用,如创建类的实例、调用类的静态方法、访问类或接口的静态字段等。只有在对类进行主动使用时,初始化阶段才会被触发。
  • 线程安全性: 在多线程环境中,如果多个线程同时尝试初始化同一个类,Java虚拟机会确保只有一个线程执行该类的<clinit>()方法,其他线程需要等待。

四、类加载器

Java虚拟机设计团队采用创新的方式将类加载阶段中获取类的二进制字节流的动作放到Java虚拟机外部实现,这个实现被称为"类加载器"(Class Loader)。这设计的初衷是为了让应用程序自己决定如何获取所需的类,为Java语言带来了灵活性和可扩展性。

4.1类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

如何判断两个类 “相等”?

  • “相等” 的要求
    • 同一个 .class 文件
    • 被同一个虚拟机加载
    • 被同一个类加载器加载
  • 判断 “相等” 的方法
    • instanceof 关键字
    • Class 对象中的方法:
      • equals()
      • isInstance()
      • isAssignableFrom()

4.2类加载器分类

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

  • 启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分
  • 其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

Java开发人员的角度来看,类加载器就应当划分得更细致一些。三层类加载器、双亲委派的类加载架构:

  • 启动类加载器(Bootstrap):
    • 是Java虚拟机能够识别的,按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载
    • <JAVA_HOME>/lib
    • -Xbootclasspath 参数指定的路径
  • 扩展类加载器(Extension)
    • <JAVA_HOME>/lib/ext
    • java.ext.dirs 系统变量指定的路径
  • 应用程序类加载器(Application)/系统类加载器
    • 负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有
      自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
    • -classpath 参数

4.3双亲委派模型

image-20231120155838087

图7中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。

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

工作过程

  • 当前类加载器收到类加载的请求后,先不自己尝试加载类,而是先将请求委派给父类加载器

    因此,所有的类加载请求,都会先被传送到启动类加载器

  • 只有当父类加载器加载失败时,当前类加载器才会尝试自己去自己负责的区域加载

实现

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 首先,检查请求的类是否已经被加载过了
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出ClassNotFoundException
            // 说明父类加载器无法完成加载请求
        }
        
        if (c == null) {
            // 在父类加载器无法加载时
            // 再调用本身的findClass方法来进行类加载
            c = findClass(name);
        }
    }

    if (resolve) {
        resolveClass(c);
    }

    return c;
}
  • 检查该类是否已经被加载
  • 将类加载请求委派给父类
    • 如果父类加载器为 null,默认使用启动类加载器
    • parent.loadClass(name, false)
  • 当父类加载器加载失败时
    • catch ClassNotFoundException 但不做任何处理
    • 调用自己的 findClass() 去加载
      • 我们在实现自己的类加载器时只需要 extends ClassLoader,然后重写 findClass() 方法而不是 loadClass() 方法,这样就不用重写 loadClass() 中的双亲委派机制了

优点

  • 避免重复加载: 双亲委派机制通过委派给父类加载器来尝试加载类,可以避免同样的类被多次加载。如果一个类已经被一个类加载器加载,那么其父加载器会首先被询问是否能够加载这个类,从而避免了重复加载,提高了类加载的效率。
  • 安全性: 双亲委派机制可以提高类加载的安全性。由于类加载是从上往下委派的,父加载器加载的类能够保证在整个加载层次结构中是唯一的,这有助于防止恶意类的加载和替代。
  • 保护核心类库: 双亲委派机制确保核心类库(如java.langjava.util等)由启动类加载器加载,防止用户自定义类替代核心类库,从而保护了Java运行环境的稳定性和一致性。
  • 模块化: 双亲委派机制有助于实现模块化。通过层级结构和委派机制,类加载器可以根据不同的需求划分加载的范围,形成模块化的结构。

破坏双亲委派机制

在某些情况下,开发者可能会有意或无意地破坏双亲委派机制。以下是一些可能导致双亲委派机制破坏的情况:

  1. 自定义类加载器: 开发者可以通过自定义类加载器来加载类,而自定义类加载器可以选择性地打破双亲委派机制。例如,覆盖 loadClass 方法时,可以选择不调用父类加载器的 loadClass 方法,从而实现自定义的加载逻辑。
  2. 线程上下文类加载器: Java中的线程上下文类加载器(Context Class Loader)可以通过 Thread.setContextClassLoader 方法进行设置。在一些框架和应用场景中,开发者可能会为线程设置上下文类加载器,以便在特定的情况下改变类加载器的委派行为。
  3. Java Instrumentation API: Java提供了 Instrumentation API,允许开发者在类加载的过程中进行字节码的修改。通过在 premain 方法中使用 java.lang.instrument.ClassFileTransformer 接口,开发者可以修改类的字节码,从而破坏双亲委派机制。
  4. 模块化中平台类加载器优先委派给负责那个模块的加载器完成加载

五、Java模块化系统

在Java 9之前,Java应用程序是以JAR文件的形式组织的,其中包含了一堆类和资源。这种方式存在一些问题:

  • 可维护性差:JAR文件可以包含大量的类和资源,这使得应用程序的结构变得混乱,难以维护。
  • 可重用性差:在多个应用程序之间共享代码和资源比较困难。
  • 安全性问题:所有的类都在同一个类路径中,这可能导致意外的访问和依赖关系。

Java模块化解决了这些问题。模块是一种新的编程单元,它可以包含类、资源和其他模块的依赖关系。模块化的代码更容易维护,更容易重用,同时也提供了更好的安全性。

模块化的基本概念

  1. 模块(Module)
    一个模块是一个可重用的单元,它包含了一组相关的类和资源。每个模块都有一个名字,并可以声明自己的依赖关系。
  2. 模块声明(Module Declaration)
    一个模块声明是一个包含在module-info.java文件中的文件,它定义了一个模块的名称、依赖关系和其他特性。
  3. 模块路径(Module Path)
    模块路径是一组目录和JAR文件,其中包含了模块的JMOD文件和module-info.class文件。模块路径用于告诉JVM哪些模块可用。
  4. 模块化 JAR 文件(Modular JAR File)
    模块化JAR文件是一种特殊类型的JAR文件,它包含了一个模块的类和资源,以及module-info.class文件。
  5. 自动模块(Automatic Module)
    如果一个JAR文件没有module-info.class文件,它被称为自动模块。自动模块的名称基于JAR文件的文件名,并且具有一些默认的依赖关系。
  6. 依赖性(Dependency)
    一个模块可以声明对其他模块的依赖关系,以便在编译时和运行时使用其他模块的类和资源。

模块化下的类加载器

为了保证兼容性,JDK 9并没有从根本上动摇从JDK 1.2以来运行了二十年之久的三层类加载器架构以及双亲委派模型。但是为了模块化系统的顺利施行,模块化下的类加载器仍然发生了一些变化。

  1. 扩展类加载器的替代: 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。这是因为JDK 9基于模块化构建,整个Java类库已经天然满足可扩展的需求,因此不再需要维护<JAVA_HOME>\lib\ext目录。之前通过这个目录来加载扩展类库的扩展类加载器也就不再需要,完成了它的历史使命。
  2. 取消<JAVA_HOME>\jre目录: 在新版的JDK中取消了<JAVA_HOME>\jre目录。这是因为现在可以根据需要组合构建出程序运行所需的JRE。例如,如果只需要使用java.base模块中的类型,可以通过jlink命令轻松地打包出一个只包含所需模块的“JRE”。

最后,JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了 变动。

当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能 够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的破坏

启动类加载器负责加载的模块

image-20231123141434132

平台类加载器负责加载的模块

image-20231123141457583

应用程序类加载器负责加载的模块

image-20231123141516050

这篇关于JVM学习-类加载机制的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程