【JVM】类的加载机制

虚拟机类加载机制指的是:JVM 将.class 文件(二进制字节流)加载到内存中,经过验证、准备、解析、初始化等一系列处理,最终转化为可以被 JVM 直接使用的运行时数据结构(存储在方法区),并在堆中生成对应的 java.lang.Class 对象(作为方法区该类数据的访问入口)的全过程。

类加载的时机

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

加载、验证、准备、初始化和卸载 这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始 ,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

注意:按部就班地"开始",而不是按部就班地"进行"或按部就班地"完成",强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

类加载过程第一个阶段"加载"的开始时间,《Java虚拟机规范》中并没有进行强制约束,可以交给虚拟机的具体实现来自由把握。但是初始化阶段严格规定了有且只有六种情况必须立即对类进行"初始化"(加载、验证、准备需要在此之前开始)

1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始

化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

  • 使用new关键字实例化对象的时候。
  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
  • 调用一个类型的静态方法的时候。

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关键字修饰的接口方法)时,如果有

这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

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

下面举三个例子来说明何为被动引用。

1.例1代码运行之后,只会输出"SuperClass init!",而不会输出"SubClass init!"。

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

至于是否要触发子类的加载和验证阶段并未明确规定,所以这点取决于虚拟机的具体实现。对于HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数观察到此操作是会导致子类加载的。

java 复制代码
package org.fenixsoft.classloading;
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
**/
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);
    }
}

输出:

2.例2复用了例1中的SuperClass,运行之后没有输出"SuperClass init!",说明并没有触发类org.fenixsoft.classloading.SuperClass的初始化阶段。但是这段代码里面触发了

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

这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。Java语言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问[1],而C/C++中则是直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出java.lang.ArrayIndexOutOfBoundsException异常,避免了直接造成非法内存访问。

java 复制代码
package org.fenixsoft.classloading;
/**
* 被动使用类字段演示二:
* 通过数组定义来引用类,不会触发此类的初始化
**/
public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

3.例3运行之后,也没有输出"ConstClass init!",因为虽然在Java源码中确实引用了

ConstClass类的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值"hello world"直接存储在NotInitialization类的常量池中,以后NotInitialization对常量

ConstClass.HELLOWORLD的引用,实际都被转化为NotInitialization类对自身常量池的引用了。也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class文件后就已不存在任何联系了。

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,

这点与类是一致的,上面的代码都是用静态语句块"static{}"来输出初始化信息的,而接口中不能使

用"static{}"语句块,但编译器仍然会为接口生成"<clinit>()"类构造器,用于初始化接口中所定义的

成员变量。

接口与类真正有所区别的是"有且仅有"需要触发初始化场景中的第三种:

当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

java 复制代码
package org.fenixsoft.classloading;
/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的
类的初始化
**/
public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}
/**
* 非主动使用类字段演示
**/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

输出:

类加载的过程

加载

"加载" 阶段是 "类加载" 过程的其中一个阶段。

加载阶段的 3 项核心任务:

  • 通过类的全限定名获取对应的二进制字节流;
  • 将该字节流的静态存储结构转化为方法区的运行时数据结构;
  • 在 Java 堆内存生成java.lang.Class对象,作为访问方法区中该类数据的外部接口。

《Java 虚拟机规范》对上述 3 项任务要求不具体,预留了较大灵活度,其中 "获取二进制字节流" 无明确来源限制,衍生出多种核心 Java 技术,典型获取示例包括:ZIP 压缩包(JAR/EAR/WAR 格式基础)、网络获取(Web Applet)、运行时动态生成(动态代理)、JSP 文件转换、数据库读取、加密文件解密(防反编译)。

非数组类型的加载阶段(尤其是获取二进制字节流的动作),是类加载各阶段中开发人员可控性最强的,可通过内置引导类加载器或自定义类加载器(重写findClass()或loadClass()方法)控制字节流获取方式,赋予应用动态性。

数组类由 JVM 直接在内存中动态构造,不由类加载器创建,但与类加载器密切相关,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。一个数组类创建过程遵循以下规则:

  • 组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类
    型区分开来)为引用类型时,递归加载该组件类型且数组类标识在对应类加载器名称空间;
  • 组件类型为非引用类型(例如int[]数组的组件类型为int)时,数组类关联引导类加载器;
  • 数组类可访问性与组件类型一致,非引用类型数组类默认public。

加载阶段结束后,二进制字节流按虚拟机定义格式存储在方法区(数据结构由虚拟机自定义,JVM规范未作规定),类的类型数据存入方法区后,堆中实例化java.lang.Class对象作为外部访问接口。

加载阶段与连接阶段的部分动作(如部分字节码格式验证)交叉进行,加载未完成连接可能已启动,但交叉动作仍属于连接阶段,且两个阶段的开始时间保持固定先后顺序

验证

验证是连接阶段的第一步,核心目的是确保 Class 文件字节流符合《Java 虚拟机规范》约束,避免恶意 / 错误字节码危害虚拟机安全。

Java本身是相对安全的编程语言,但加载阶段提过 Class 文件可通过非 Java 源码生成,字节码层面可能存在 Java 语言本身禁止的危险操作,如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,所以验证字节码是Java虚拟机保护自身的一项必要措施。

验证阶段直接决定虚拟机抗恶意代码攻击能力,工作量占类加载过程比重较大。从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证

1.文件格式验证

第一阶段:验证字节流是否符合 Class 文件格式规范(如魔数 0xCAFEBABE、版本号兼容、常量池合法等),确保输入的字节流能被正确地解析并存储到方法区。

这阶段的验证是基于二进制字节流 进行的,仅通过此阶段验证后,字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。

2.元数据验证

第二阶段:对字节码描述的信息进行语义分析,确保符合《Java 语言规范》。这个阶段可能包括的验证点如下:

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

第二阶段的主要目的是对类的元数据信息进行语义校验 ,保证不存在与《Java语言规范》定义相

悖的元数据信息。

3.字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定
程序语义是合法的、符合逻辑的

在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:

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

如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。通俗一点的解释是通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来准确判定一段程序是否存在Bug。

由于数据流分析和控制流分析的高度复杂性,Java虚拟机的设计团队为了避免过多的执行时间消耗在字节码验证阶段中,JDK 6 后引入 StackMapTable 属性优化校验效率(将类型推导转为类型检查),JDK 7 后对主版本号≥50 的 Class 文件强制使用该校验方式,不再支持退回旧模式(JDK 6 可通过 - XX:-UseSplitVerifier 关闭优化)。

4.符号引用验证

最后一个阶段的校验行为 发生在虚拟机将符号引用转化为直接引用的时候(解析阶段) 。符号引用验证可以看作是对类自身以外(常量池中的符号引用)的各类信息进行匹配性校验,通俗来说就是, 该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

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

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

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但不是必须执行的阶段 ,因为验证阶段只有通过或者不通过,通过验证后不影响程序运行。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分验证,缩短虚拟机类加载的时间。

准备

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

从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域:

  • JDK 7 及之前,HotSpot 用永久代实现方法区,符合上述逻辑;
  • JDK 8 及之后,类变量随Class对象一同存于 Java 堆,"类变量在方法区" 仅为逻辑概念表述。

注意

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

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

public static int value = 123;

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

下表列出了Java中所有基本数据类型的零值:

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

public static final int value = 123;

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

解析

解析阶段是Java虚拟机 将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?

  • 符号引用(Symbolic References):符号引用以一组符号(任何形式的字面量)来描述所引用的目标,只要使用时能无歧义地定位到目标即可。
    • 符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到虚拟机内存。
    • 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
    • 直接引用与虚拟机实现的内存布局直接相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。
    • 如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

触发时机

《Java 虚拟机规范》未规定解析具体时间,仅要求在执行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic 这17 个操作符号引用的字节码指令执行前,完成对应符号引用解析;虚拟机可选择类加载时提前解析,或使用前才解析。

关键规则与特殊指令:

解析时会对方法 / 字段的可访问性(public/protected/private/)进行检查。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令外,虚拟机可缓存第一次解析结果,如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。保证同一实体中后续解析结果一致(首次成功则后续必成功,首次失败则后续均抛出相同异常)。

而invokedynamic指令的目的本来就是用于动态语言支持,对应的引用称为"动态调用点限定符"。其解析为动态解析,仅在程序运行到该指令时才执行解析,结果不共享;其余指令为静态解析,可在加载完成后、执行代码前提前进行。

解析对象:主要针对 7 类符号引用(类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符)(对应CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8 种常量类型),下面核心讲解前 4 种:类或接口、字段、类方法、接口方法,后几种和动态语言支持密切相关。

1.类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:

1)C非数组类型:虚拟机把代表N的全限定名传递给D的类加载器去加载类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。加载过程中出现异常则解析失败。

2)C是数组类型(元素为对象):N的描述符是类似"[Ljava/lang/Integer"的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是"java.lang.Integer",接着由虚拟机生成代表该数组维度和元素的数组对象。

3)上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。无权限将抛出java.lang.IllegalAccessError异常。JDK 9 及以后需额外检查模块间访问权限(public类型不再意味着程序任何位置都有它的访问权限)。

2.字段解析

要解析一个未被解析过的字段符号引用,首先解析字段所属的类或接口的符号引用。在解析这个类或接口符号引用的过程中出现异常则字段符号引用解析失败。

如果解析成功完成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下对C要求进行后续字段的搜索:

按 "C 本身→C 的接口及父接口(继承从下往上递归搜索)→C 的父类(非java.lang.Object,继承关系从下往上递归搜索)" 顺序查找匹配字段(简单名称和字段描述符都与目标相匹配),找到则返回这个字段的直接引用,都失败则抛出java.lang.NoSuchFieldError。

如果查找过程成功返回了引用,将会对这个字段进行权限验证,不具备对字段的访问权限则抛出java.lang.IllegalAccessError异常。

以上解析规则能够确保Java虚拟机获得字段唯一的解析结果,但在实际情况中,Javac编译器往往会采取比上述规范更加严格一些的约束,譬如有一个同名字段同时出现在某个类的接口和父类当中,或者同时在自己或父类的多个接口中出现,按照解析规则仍是可以确定唯一的访问字段,但Javac编译器就可能直接拒绝其编译为Class文件,拒绝编译存在字段歧义的代码。

3.方法解析

方法解析的第一个步骤与字段解析一样,也是需要先解析方法所属的类或接口的符号引用,如果解析成功,我们用C表示这个类。

由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,若 C 是接口则直接抛出java.lang.IncompatibleClassChangeError。

接下来虚拟机将按 "C 本身→C 的父类→C 的接口及父接口" 顺序查找匹配方法(简单名称和描述符都与目标相匹配),在C或其父类中找到都返回方法的直接引用,查找结束;接口中匹配说明 C 是抽象类,抛出java.lang.AbstractMethodError,未找到则抛出java.lang.NoSuchMethodError。

最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,不具备对此方法的访问权限则抛出java.lang.IllegalAccessError异常。

4.接口方法解析

先解析方法所属接口 C,若 C 是类则直接抛出java.lang.IncompatibleClassChangeError。

接口方法也是需要先解析出方法所属的类或接口的符号引用,如果解析成功,用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

若 C 是类则直接抛出java.lang.IncompatibleClassChangeError。

按 "C 本身→C 的父接口(含java.lang.Object方法)" 顺序查找匹配方法(简单名称和描述符都与目标相匹配),如果有则返回这个方法的直接引用,查找结束。

由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中没有规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。

未找到匹配方法抛出java.lang.NoSuchMethodError;JDK 9 前无访问权限问题(Java接口中的所有方法都默认public),JDK 9 及以后因私有静态方法和模块化约束,可能因访问权限控制抛出java.lang.IllegalAccessError。

初始化

类的初始化阶段是类加载过程的最后一步,此前除加载阶段可通过自定义类加载器局部参与外,其余步骤均由 Java 虚拟机主导;此阶段虚拟机才真正执行类中的 Java 程序代码,将执行主导权移交给应用程序。
准备阶段已为类变量赋过初始零值,初始化阶段则按程序员编码逻辑初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()方法由 Javac 编译器自动生成,非程序员手动编写。
<clinit>()方法的关键特性:

  • 由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集顺序由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
java 复制代码
public class Test {
    static {
        i = 0; // 给变量复制可以正常编译通过
        System.out.print(i); // 这句编译器会提示"非法向前引用"
    }
    static int i = 1;
}
  • <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它无需显式调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已执行完毕。因此第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。
  • 由于父类的<clinit>()方法先执行,也就意味着父类中的静态语句块要优先于子类的变量赋值操作,如下代码,字段B的值将会是2而不是1。
java 复制代码
static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}
static class Sub extends Parent {
    public static int B = A;
}
public static void main(String[] args) {
    System.out.println(Sub.B);
}
  • <clinit>()方法对于类或接口来说非必需,若类中无静态语句块且无类变量赋值操作,编译器可不为该类生成<clinit>()方法。
  • 接口无静态语句块,但有变量初始化赋值,会生成<clinit>();但接口与类不同的是,无需先执行父接口<clinit>()(仅父接口变量被使用时,父接口才会初始化);接口实现类初始化时,也不执行接口的<clinit>()。
  • JVM 保证<clinit>()在多线程环境下被正确加锁同步,多个线程同时初始化一个类时,仅一个线程执行<clinit>(),其他线程阻塞等待,直到活动线程执行完毕<clinit>()方法。需要注意,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒后则不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会被初始化一次。
    • 如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个隐蔽的进程阻塞。下面代码演示了这种场景。
java 复制代码
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 = new Runnable() {
        public void run() {
            System.out.println(Thread.currentThread() + "start");
            DeadLoopClass dlc = new DeadLoopClass();
            System.out.println(Thread.currentThread() + " run over");
        }
    };
    Thread thread1 = new Thread(script);
    Thread thread2 = new Thread(script);
    thread1.start();
    thread2.start();
}

运行结果如下,一条线程在死循环以模拟长时间操作,另外一条线程在阻塞等待:

Thread[Thread-0,5,main]start

Thread[Thread-1,5,main]start

Thread[Thread-0,5,main]init DeadLoopClass

类加载器

Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名来获取描述该类的二进制字节

流"这个动作放到Java虚拟机外部 去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为"类加载器"(Class Loader)。

类与类加载器

类加载器虽然只用于实现类的加载动作,但在Java程序中起到的作用却远超类加载阶段。

任意一个类在 Java 虚拟机中的唯一性,由「加载它的类加载器 」和「类本身」共同确立,每个类加载器都拥有独立的类名称空间。

简单来说:比较两个类是否 "相等",仅在二者由同一个类加载器加载的前提下才有意义;即便两个类来源于同一个 Class 文件、被同一个 Java 虚拟机加载,只要类加载器不同,这两个类必定不相等。

上面说的"相等" 的涵盖范围包括代表类的Class对象的equals()、isAssignableFrom()、isInstance()方法返回结果,以及instanceof关键字的对象所属类型判定等。

如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,下面代码演示了不同的类加载器对instanceof关键字运算的结果的影响。

java 复制代码
/**
 * 类加载器与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("org.fenixsoft.classloading.ClassLoaderTest").newInstance();
        System.out.println(obj.getClass());
        System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
    }
}

运行结果:

class org.fenixsoft.classloading.ClassLoaderTest

false

通过自定义类加载器和虚拟机应用程序类加载器,加载同一个ClassLoaderTest类的 Class 文件,我们使用这个类加载器去加载了一个名为"org.fenixsoft.classloading.ClassLoaderTest"的类,并实例化了这个类的对象。

两行输出结果中,从第一行可以看到这个对象确实是类org.fenixsoft.classloading.ClassLoaderTest实例化出来的,但在第二行的输出中却发现这个对象与类org.fenixsoft.classloading.ClassLoaderTest做所属类型检查的时候返回了false。

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

双亲委派模型

类加载器的两种分类视角:

  • JVM 视角:仅存在两种类加载器,一是启动类加载器(C++ 实现,HotSpot 虚拟机自身一部分),二是其他所有类加载器(Java 语言实现,独立于虚拟机外部,均继承自抽象类java.lang.ClassLoader)。
  • 开发人员视角(JDK 8 及之前):采用三层类加载器 + 双亲委派的架构,核心是 3 个系统提供的类加载器,用户也可自定义类加载器拓展功能(如新增 Class 文件来源、实现类隔离 / 重载)。

JDK 8 及之前,绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载:

  • 启动类加载器:负责加载<JAVA_HOME>\lib目录或-Xbootclasspath参数指定路径中,Java 虚拟机可按文件名识别的类库(如 rt.jar、tools.jar);无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。下面代码展示的就是java.lang.ClassLoader.getClassLoader()方法的代码片段,其中的注释和代码实现都明确地说明了以null值来代表引导类加载器的约定规则。
java 复制代码
/**
 Returns the class loader for the class. Some implementations may use null to represent the bootstrap class loader. This method will return null in such implementations if this class was loaded by the bootstrap class loader.
 */
public ClassLoader getClassLoader() {
        ClassLoader cl = getClassLoader0();
        if (cl == null)
            return null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            ClassLoader ccl = ClassLoader.getCallerClassLoader();
            if (ccl != null && ccl != cl && !cl.isAncestor(ccl)) {
                sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);
            }
        }
        return cl;
}
  • 扩展类加载器:由sun.misc.Launcher$ExtClassLoader实现(Java 代码),加载<JAVA_HOME>\lib\ext目录或java.ext.dirs系统变量指定路径的类库,开发者可直接使用;是 Java 系统类库扩展机制,JDK 9 后被模块化扩展能力取代。
  • 应用程序类加载器(又称系统类加载器):由sun.misc.Launcher$AppClassLoader实现,可通过ClassLoader.getSystemClassLoader()获取;负责加载用户类路径(ClassPath)上的类库,无自定义类加载器时为程序默认类加载器,开发者可直接使用。

JDK 9之前的Java应用都是由上述三种类加载器互相配合来完成加载的,如果有必要,还可加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的Class文件来源,或者通过类加载器实现类的隔离、重载等功能。这些类加载器之间的协作关系"通常"会下图所示。

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

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器(父子关系通常以组合关系复用代码实现,而非继承),是 JDK 1.2 引入的推荐最佳实践,非强制性约束。

工作过程:

类加载器收到加载请求后,首先将请求委派给父类加载器处理,所有请求最终传至顶层启动类加载器;仅当父加载器无法完成请求(搜索范围中无所需类)时,子加载器才尝试自行加载。

使用双亲委派模型来组织类加载器之间的关系的好处

让 Java 类随着它的类加载器一起具备优先级层次关系,保证类的唯一性和程序稳定运作;例如java.lang.Object始终由启动类加载器加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类,避免出现多个不同 Object 类导致类型体系混乱(用户自定义同名类无法被加载运行)。
实现方式:

实现方式简单,用以实现双亲委派的代码只有短短十余行,核心代码在java.lang.ClassLoader的loadClass()方法中,如下代码所示。

java 复制代码
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;
}

上述代码逻辑为:先检查请求加载的类型是否已加载,未加载则调用父加载器loadClass()(父加载器为空则默认使用启动类加载器),若父加载器抛出ClassNotFoundException(加载失败),再调用自身findClass()方法自行加载。

破坏双亲委派模型

上文提到双亲委派模型是 Java 设计者推荐的类加载器实现最佳实践,非强制性约束,JDK 9 模块化出现前曾发生 3 次大规模 "被破坏"(此处 "破坏" 无贬义,多为解决特定问题的创新)。

三次 "破坏" :

  • 第一次破坏(JDK 1.2 前):

背景:双亲委派模型在 JDK 1.2 后引入,但java.lang.ClassLoader和类加载器概念早于模型存在,需兼容已有自定义类加载器(当时可直接重写loadClass()方法)。

解决方案:JDK 1.2 后新增findClass()方法,引导用户将自定义加载逻辑写在此方法中;loadClass()方法保留双亲委派核心逻辑,父加载失败后自动调用findClass(),既兼容旧代码又符合模型规则。

  • 第二次破坏(模型自身缺陷导致):

问题:基础类型(如 JNDI,由启动类加载器加载)需调用用户 ClassPath 下的 SPI 服务代码,但启动类加载器无法加载应用路径下的类。

解决方案:引入线程上下文类加载器(默认是应用程序类加载器,可通过Thread.setContextClassLoader()设置),允许父类加载器逆向请求子类加载器加载类,违背模型层次原则;JDK 6 新增java.util.ServiceLoader类,结合 META-INF/services 配置和责任链模式,优化 SPI 加载逻辑。

典型场景:JNDI、JDBC、JCE 等 SPI 机制均采用此方式实现。

  • 第三次破坏(追求动态性导致):

需求:实现代码热替换、模块热部署(生产系统无需重启即可更新功能)。

典型实现:OSGi(曾是业界事实上的 Java 模块化标准),其核心是自定义类加载器机制 ------ 每个模块(Bundle)拥有独立类加载器,更换模块时连同类加载器一起替换,实现热部署。

类加载逻辑:类加载器呈网状结构,而非模型推荐的树状结构;查找顺序以平级委派为主(仅java.*类和委派列表内的类遵循双亲委派),其余优先查找 Import 列表、当前 Bundle、Fragment Bundle、Dynamic Import列表的Bundle等平级资源,突破模型层次约束。

相关推荐
Sag_ever8 小时前
Java数组详解
java
张np8 小时前
java基础-ConcurrentHashMap
java·开发语言
一嘴一个橘子9 小时前
spring-aop 的 基础使用 - 4 - 环绕通知 @Around
java
小毅&Nora9 小时前
【Java线程安全实战】⑨ CompletableFuture的高级用法:从基础到高阶,结合虚拟线程
java·线程安全·虚拟线程
冰冰菜的扣jio9 小时前
Redis缓存中三大问题——穿透、击穿、雪崩
java·redis·缓存
小璐猪头9 小时前
专为 Spring Boot 设计的 Elasticsearch 日志收集 Starter
java
ps酷教程9 小时前
HttpPostRequestDecoder源码浅析
java·http·netty
闲人编程9 小时前
消息通知系统实现:构建高可用、可扩展的企业级通知服务
java·服务器·网络·python·消息队列·异步处理·分发器
栈与堆10 小时前
LeetCode-1-两数之和
java·数据结构·后端·python·算法·leetcode·rust