企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
![](https://img.kancloud.cn/cc/93/cc9362c6a21586e7e014b7df2641bb2e_407x319.png) [TOC] ## 1 概述 类加载机制:JVM将描述类的数据从 class 文件中加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型 ## 2 类加载的时机 - 类加载的生命周期分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载 - 验证、准备、解析三个阶段都处于连接(Linking)阶段 - 加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的 有且只有六种情况必须立即对类进行“初始化” 1. 遇到 `new`、`getstatic`、`putstatic`、`invokestatic` 这四个字节码指令时,如果类型没有进行过初始化,则需要触发其初始化 - 使用 `new` 关键字实例化对象的时候 - 读取或设置一个类型的静态字段(被 `final` 修饰、已在编译期把结果放入常量池的静态字段除外)的时候 - 调用一个类型的静态方法的时候 2. 使用 `java.lang.reflect` 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要触发其初始化 3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化 4. 当虚拟机启动时,会初始化包含 `main()` 方法的主类 5. `JDK 7` 加入动态语言支持后,如果一个 `java.lang.invoke.MethodHandles` 实例最后的解析结果为 `REF_getStatic`、`REF_putStatic`、`REF_invokeStatic`、`REF_newInvokeSpecial` 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化 6. `JDK 8` 加入默认方法后,当一个接口定义了被 `default` 修饰的接口方法时,如果这个接口的实现类发生了初始化,则该接口需要在其之前被初始化 > **有且仅有**以上6种情况会进行类的初始化,以下是一个关于不进行类初始化的例子 ```java public class ClinitTest { public static void main(String[] args) { // 1. 对常量的引用,不会触发类的初始化,无输出 int v1 = SubClass.SUPER_CONSTANTS; // 2. 通过数组引用类,不会触发类的初始化,无输出 SuperClass[] arr = new SuperClass[10]; // 3. 通过子类引用父类字段,只会对父类进行初始化,输出 // System.out.println(SubClass.superField); // 4. SubClass.value 未被引用时,不会调用 SubClass#getValue 方法,无输出 // 5. 引用 SubClass 的常量不会触发 SubClass,但是 value 需要通过 SubClass 的 static 方法获取, // 所以将触发 SubClass 的初始化,子类初始化会先触发父类的初始化,所以会先初始化 SuperClass (打印Super,再打印Sub) // 接着调用 getValue 方法,打印 getValue int v2 = SubClass.value; } } /** * 父类 */ class SuperClass { static { System.out.println("Super"); } /** 常量 */ public static final int SUPER_CONSTANTS = 1; /** static 变量 */ public static int superField = 2; } /** * 子类 */ class SubClass extends SuperClass { static { System.out.println("Sub"); } public static final int value = getValue(); private static int getValue() { System.out.println("getValue"); return 5; } } ``` ## 3 类加载的过程 ### 3.1 加载(Loading) 在加载阶段,JVM需要完成: 1. 通过一个类的全限定名来获取定义此类的二进制字节流 2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构 3. 在内存中生成一个代表这个类的 `java.lang.Class` 对象,作为方法区这个类的各种数据的访问入口 获取二进制字节流,可通过多种方式: - 通过zip包,如jar、ear、war格式等 - 从网络获取,如Web Applet - 运行时计算生成,如动态代理,`java.lang.reflect.Proxy` 类中的 `byte[] proxyClassFile = ProxyGenerator.generateProxyClass(proxyName, interfaces, accessFlags);` - 从其他文件生成,例如将JSP文件生成class文件 - 从数据库读取,例如SAP Netweaver - 从加密文件中获取,例如防class文件被反编译的保护措施,通过加载时解密class文件来保障程序的运行逻辑不被窥探 数组类型不通过类加载器创建,通过JVM直接在内存中动态构造出来,一个数组类(简称C)创建过程遵循以下规则: - 一个数组的组件类型(Component Type)指的是数组去掉一个维度的类型 - 如果数组的组件类型是引用类型,则递归地去加载这个组件类型,数组C将被标识在类加载器的类名称空间上 - 如果数组的组件类型不是引用类型(例如int[]的组件类型是int),则Java虚拟机将把数组C标记为与引导类加载器关联 - 数组类的可访问性与组件类型的可访问性一致,如果组件类型不是引用类型,则其数组类的可访问性默认为public,例如可在任何(Java 9引入模块化之前)类或接口使用int[] ### 3.2 验证(Verification) 如果虚拟机验证到输入的字节流不符合class文件格式的约束,就应当抛出一个 `java.lang.VerifyError` 异常或者子类异常,验证阶段大致会完成以下四个阶段的验证: 1. 文件格式验证 2. 元数据验证 3. 字节码验证 4. 符号引用验证 **文件格式验证**主要验证:字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理,可能包含: - 是否以 `0xcafebabe` 开头 - 主、次版本号是否在当前JVM接受范围内 - 常量池的常量是否有不被支持的类型(tag标记) - 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量 - `CONSTANT_Utf8_info` 类型的常量是否有不符合UTF-8编码的数据 - class文件中各个部分即文件本身是否有被删除、或者附加的其他信息 - …… **元数据验证**是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,可能包含: - 该类是否有父类(除了java.lang.Object外,所有的类都应当有父类) - 该类是否继承了不允许被继承的类(被final修饰的类) - 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法 - 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载) - …… **字节码验证**最复杂的一个验证阶段,主要是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的,主要对类的方法体(class文件中的Code属性)进行校验分析,例如: - 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现在操作数栈放置一个int数据类型的数据,使用时却按long类型来加载入本地变量表中的情况 - 保证任何跳转指令都不会跳转到方法体以外的字节码指令上 - 保证方法体中的类型转换总是有效的 - …… **符号引用验证**发生在虚拟机将符号引用转化为直接引用的时候,可以看作对类自身外的各类信息进行匹配性校验,通常包含: - 符号引用中通过字符串描述的全限定名是否能找到对应的类 - 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段 - 符号引用中的类、字段、方法的可访问性是否可被当前类所访问 - …… ### 3.3 准备(Preparation) 准备阶段是正式为类中定义的变量(static修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配。需要注意以下几种情况: - 此处的类变量,不包括实例变量,实例变量会随着对象实例化分配到堆中 - 假设一个类变量定义为 `static int value = 123`,则准备阶段过后的初始值为0而不是123,因为此时尚未开始执行任何Java方法(把value赋值为123的动作是在类的初始化阶段,其 `putstatic` 指令存放于类构造器`<clinit>()`方法中) - 假设一个类变量定义为 `static fianl int value = 123`,则准备阶段该变量就会被初始化为ConstantValue属性所指定的初始值,即123 以下是基本数据类型的零值: 数据类型 | 零值 | | 数据类型 | 零值 ---- | ---- | ---- | ---- | ---- int | 0 | | boolean | false long | 0L | | float | 0.0f short | (short) 0 | | double | 0.0d char | '\u0000' | | reference | null byte | (byte) 0 | | | ### 3.4 解析(Resolution) 解析阶段是JVM将常量池内的符号引用替换为直接引用的过程,以下是符号引用和直接引用的关联如下: - 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可 - 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。 《Java虚拟机规范》中并未对规定解析阶段发生的具体时间,只要求了在执行以下17个**用于操作符号引用的字节码指令**之前,先对它们所使用的符号引用进行解析:`anewarray`、`checkcast`、`getfield`、`getstatic`、`instanceof`、`invokednamic`、`invokeinterface`、`invokespecial`、`invokestatic`、`invokevirtul`、`ldc`、`ldc_w`、`ldc2_w`、`multianewarray`、`new`、`putfield`、`putstatic` 解析动作主要针对类或接口(CONSTANT_Class_info)、字段(CONSTANT_Fieldref_info)、类方法(CONSTANT_Methodref_info)、接口方法(CONSTANT_InterfaceMethodref_mic_info)、方法类型(CONSTANT_MethodType_info)、方法句柄(CONSTANT_MethodHandle_info)和调用点限定符(CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info)这7类符号引用进行。 #### 3.4.1 类或接口的解析 将一个类D中从未解析过的符号引用N解析为一个类或接口C的直接引用,则解析过程包含3个步骤: 1. 解析非数组类型:使用D的类加载器,根据N的全限定名加载类C,并触发相关类的加载操作,出现异常则解析过程失败 2. 解析数组类型:递归地按照第一点的规则加载数组元素类型,由虚拟机生成一个代表该数组维度和元素的数组对象 3. 进行符号引用验证,确认D是否对C具备访问权限,如果发现不具备,将抛出 `java.lang.IllegalAccessError` 异常 `JDK 9` 引入模块化以后,一个public类型不再意味着程序任何位置都有它的访问权限,还必须检查模块间的访问权限,如果D拥有C的访问权限,则意味着以下规则至少有1条成立: - C为public且与D处于同一模块 - C为public且不与D处于同一模块,但是C的模块允许D的模块访问 - C不为public但是与D处于同一package中 #### 3.4.2 字段解析 要解析一个未被解析过的字段引用,首先会对字段表内 `class_index` 项中索引的 `CONSTANT_Class_info` 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果解析过程出现了任何异常,则解析失败;如果解析成功,则要按照《Java虚拟机规范》的要求,对该字段的类或接口(用C表示)进行后续字段的搜索: 1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;否则进入第2步 2. 如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和父接口,如果父接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;否则进入第3步 3. 如果C不是 `java.lang.Obejct` ,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束,否则进入第4步 4. 查找失败,抛出 `java.lang.NoSuchFieldError` 异常 如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对该字段的访问权限,将抛出 `java.lang.IllegalAccessError` 异常 #### 3.4.3 类方法解析 要解析一个未被解析过的方法引用,首先会对方法表内 `class_index` 项中索引的 `CONSTANT_Class_info` 符号引用进行解析,也就是方法所属的类或接口的符号引用。如果解析过程出现了任何异常,则解析失败;如果解析成功,则要按照《Java虚拟机规范》的要求,对该方法的类或接口(用C表示)进行后续方法的搜索: 1. 由于类方法(CONSTANT_Methodref_info)和接口方法(CONSTANT_InterfaceMethodref_mic_info)的常量类型定义是分开的,因此如果在类的方法表中发现class_index中索引的C是个接口的话,则抛出 `java.lang.IncompatiibleClassChangeError` 异常 2. 如果第1步通过,则在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束;否则进入第3步 3. 在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束;否则进入第4步 4. 在类C的接口列表以及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束,否则进入第5步 5. 查找失败,抛出 `java.lang.NoSuchMethodError` 异常 如果查找过程中成功返回了引用,将会对这个方法进行权限验证,如果发现不具备对该方法的访问权限,将抛出 `java.lang.IllegalAccessError` 异常 #### 3.4.4 接口方法解析 要解析一个未被解析过的接口方法引用,首先会对接口方法表内 `class_index` 项中索引的 `CONSTANT_Class_info` 符号引用进行解析,也就是方法所属的类或接口的符号引用。如果解析过程出现了任何异常,则解析失败;如果解析成功,则要按照《Java虚拟机规范》的要求,对该接口方法的类或接口(用C表示)进行后续接口方法的搜索: 1. 由于类方法(CONSTANT_Methodref_info)和接口方法(CONSTANT_InterfaceMethodref_mic_info)的常量类型定义是分开的,因此如果在接口的接口方法表中发现class_index中索引的C是个类的话,则抛出 `java.lang.IncompatiibleClassChangeError` 异常 2. 如果第1步通过,则在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个接口方法的直接引用,查找结束;否则进入第3步 3. 在接口C的父接口中递归查找,直到 `java.lang.Object` 类为止,看是否有简单名称和描述符都与目标相匹配的接口方法,如果有则返回这个接口方法的直接引用,查找结束;否则进入第4步 4. 由于接口允许多继承,因此如果C的不同父类接口中存有多个简单名称和描述符都与目标相匹配的方法,将从这多个方法中返回其中一个并查找结束(《Java虚拟机规范》中没有进一步规则约束,但是不同厂商实现的Javac可能会对此作限制),否则进入第5步 5. 查找失败,抛出 `java.lang.NoSuchMethodError` 异常 如果查找过程中成功返回了引用,将会对这个方法进行权限验证,如果发现不具备对该方法的访问权限,将抛出 `java.lang.IllegalAccessError` 异常(在JDK 9之前,Java 接口中所有的方法默认public,也没有模块化的约束,不存在访问权限问题;但是JDK 9之后增加了接口的静态私有方法,也有了模块化的约束,则对接口方法的访问也可能抛出该异常) ### 3.5 初始化 在类的初始化阶段,JVM开始真正执行类中编写的Java程序代码。 - 准备阶段会为变量赋予零值,初始化阶段会根据程序代码初始化类变量和其他资源。 - 初始化阶段就是执行类构造器`clinit<>()`方法的过程 `clinit<>()` 方法是Javac的产物,以下是关于`clinit<>()`方法的原理介绍: - `clinit<>()` 方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句,合并产生的;收集顺序与源文件中出现的顺序一致,静态语句块只能访问在其之前出现的变量,**在其之后出现的变量,静态语句块可以赋值,但是不能访问** - `clinit<>()` 方法与类的构造函数`init<>()`不同,它不需要显示地调用父类构造器,JVM会保证在子类的 `<clinit>()` 方法执行之前,其父类的 `<clinit>()` 方法已经执行完毕 - 因此JVM中第一个被执行的 `clinit<>()` 方法一定是 `java.lang.Object` 类的 - 因此父类中定义的静态语句块要优先于子类的变量赋值操作 - `clinit<>()` 方法对类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 `<clinit>()` 方法 - 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 `clinit<>()` 方法,但是: - 执行接口的 `clinit<>()` 方法不需要先执行其父接口的 `clinit<>()` 方法,只有其父接口中定义的变量被使用时,父接口才会被初始化 - 接口的实现类在初始化的过程中,也不会执行接口的 `clinit<>()` 方法 - JVM必须保证一个类的 `clinit<>()` 方法是线程安全的 > 示例:父类中定义的静态语句块要优先于子类的变量赋值操作 ```java class Parent { static int parentVar = 1; static { parentVar = 2; } } class Child extends Parent { static int childVar = parentVar; } public static void main(String[] args) { System.out.println(Child.childVar); // 成功编译,输出2 } ``` > 示例:JVM必须保证一个类的 `clinit<>()` 方法是线程安全的,因此可以借助这种机制实现一种单例模式 ```java /** * 单例类 */ public class Singleton { /** * 将Singleton的实例化放在Holder的初始化过程中,JVM保证线程安全 */ static class Holder { private static Singleton instance = new Singleton(); } /** * 获取Singleton的唯一实例 * * @return Singleton的唯一实例 */ public static Singleton getInstance() { return Holder.instance; } /** * 隐藏Singleton的实例化操作 */ private Singleton() { } } ``` ## 4 类加载器 ### 4.1 类与类加载器 - 对于任意一个Class,都必须由加载它的ClassLoader和这个Class本身来确定其在JVM中的唯一性 - 每个ClassLoader,都拥有一个独立ClassNameSpace - 比较两个Class是否相等包括: - equeals()方法 - isAssignableFrom()方法 - isInstance()方法 - instanceof关键字 ### 4.2 双亲委派模型 > JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构(以下内容不包括JDK 9之后版本) **三层类加载器** 类加载器名称 | 加载路径 | 说明 ---- | ---- | ---- Boostrap Class Loader | `JAVA_HOME/lib` 或者被 `-Xbootclasspath` 参数指定的路径 | C++实现,虚拟机自身的一部分 Extension Class Loader | `JAVA_HOME/lib/ext` 或者被 `java.ext.dirs` 系统变量指定的路径 | `sun.misc.Launcher$ExtClassLoader` 实现 Application Class Loader | `CLASSPATH` 路径 | `sub.misc.Launcher$AppClassLoader` 实现 **双亲委派模型(Parents Delegation Model)** - 过程:如果一个类收到了类加载的请求,首先将请求委派给父类加载器完成,每一层都是如此,当父类加载器反馈无法完成这个请求时(搜搜范围内没有该类,抛出异常),子加载器才会去尝试自行完成加载 - 特点:Java中的类随着类加载器一起具备了一种带有优先级层次的关系,例如java.lang.Object,存放在rt.jar中,无论哪个类加载器去加载它,都会转到Bootstrap Class Loader去加载,保证了其唯一性 - 注意:即使用自定义类加载器,用defineClass()方法强行加载一个以`java.lang`开头的类,也不会成功,将收到虚拟机内部抛出的`java.lang.SecurityException` 异常 > `java.lang.ClassLoader#loadClass` 中双亲委派模型的实现 ```java Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { Class c = findLoadedClass(name); if (c == null) { c = (parent != null) ? parent.loadClass(name, false) : findBootstrapClassOrNull(name); if (c == null) { c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } ``` ### 4.3 破坏双亲委派模型 > 双亲委派模型**并不是一个具有强制性约束**的模型,而是Java设计者推荐给开发者的类加载器实现方式。在JDK 9之前,双亲委派模型出现过三次较大规模的被破坏的情况。 **三次被破坏的情况** - 类加载器的概念和 `java.lang.ClassLoader` 在Java的第一个版本中就已存在,在JDK 1.2之后引入双亲委派模型后,为了兼容已有代码,使得 `loadClass()` 不被子类覆盖,设计者们不得不做出妥协,在 ClassLoader 类中添加了一个 protected 方法`findClass()`,并引导用户去重写该方法,而不是在 `loadClass()` 中编写代码 - 针对基础类型要调用开发者的代码,Java团队设计了Thread Context ClassLoader,该类加载器可以通过 `java.lang.Thread#setContextClassLoader` 进行设置,打通了双亲委派模型的层次结构,以逆向使用类加载器。Java中涉及到SPI(Service Provider Interface)的加载基本上都采用了这种方式来完成,典型例子包括:JNDI、JDBC、JCE、JAXB、JBI。当SPI提供者多于1个时,代码只能根据具体提供者的类型来硬编码判断,为了消除这种影响,在JDK 6提供了 `java.util.ServiceLoader`,以 `META-INF/services` 中的配置信息,辅以责任链模式,为SPI的加载提供了一种相对合理的解决方案 - 源于开发者对程序动态性的追求,例如代码热替换(Hot Swap)、模块热部署(Hot Deployment)。以OSGi为例,OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个Bundle(程序模块)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换 **OSGi类加载过程** 1. 将以java.*开头的类,委派给父类加载器加载 2. 否则,将委派列表名单内的类,委派给父类加载器加载 3. 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载 4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载 5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载 6. 否则,查找Dynamic Import列表的Bundle,委派给对应的Bundle的类加载器加载 7. 否则,类查找失败