Java 虚拟机把描述类的数据从 Class 文件加载到内存, 并对数据进行校验, 转换解析和初始化, 最终形成可以被虚拟机直接使用的 Java 类型,
这个过程被称作虚拟机的类加载机制。
与那些在编译时需要进行连接的语言不同, 在 Java 语言里面, 类的加载, 连接和初始化过程都是在程序运行期间完成的, 这种策略让 Java 语言进行提前编译会面临额外的困难,
也会让类加载时稍微增加一些性能开销, 但是却为 Java 应用提供了极高的扩展性和灵活性, Java 天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
比如, 要加载一个接口的实现了, 除了可以从当前的 jar 里面或外部的依赖 jar 包加载外, 还可以自定义自己的类加载器或 Java 预置,
通过网络请求一个二进制流文件作为程序运行的实现类等。
从上面的例子中, 我们可以知道, JVM 的类加载机制的数据来源, 除了是一个真真正正的 Class 文件外, 还是可以通过网络加载回来的二进制流,
乃至可以是从数据库中读取的数据, 所以对于 Class 文件的定义范围可以适当扩大到二进制流的范畴, 还有每个 Class 文件都有代表着 Java 语言中的一个类或接口的可能性。
1 类加载的时机
如图, 一个类型从被加载到虚拟机内存中开始, 到卸载出内存为止, 它的整个生命周期将会经历
加载 (Loading)
验证 (Verification)
准备 (Preparation)
解析 (Resolution)
初始化 (Initialization)
使用 (Using)
卸载 (Unloading)
七个阶段, 其中验证 + 准备 + 解析三个部分统称为连接 (Linking)。
加载, 验证, 准备, 初始化和卸载这五个阶段的顺序是确定的, Class 的加载过程必须按照这种顺序按部就班地开始,
而解析阶段则不一定: 它在某些情况下可以在初始化阶段之后再开始, 这是为了支持 Java 语言的运行时绑定特性, 也称为动态绑定或晚期绑定。
对于一个类什么时候加载, 《Java虚拟机规范》没有强制要求, 但是对于类的初始化阶段, 则严格规定了有且只有六种情况必须立即对类进行 “初始化” (而加载, 验证, 准备自然需要在此之前开始):
- 遇到 new / getstatic / putstatic / invokestatic 这四条字节码指令时, 如果类型没有进行过初始化, 则需要先触发其初始化阶段。
能够生成这四条指令的典型 Java 代码场景有
1.1 使用 new 关键字实例化对象的时候
1.2 读取或设置一个类型的静态字段 (被 final 修饰, 已在编译期把结果放入常量池的静态字段除外) 的时候
1.3 调用一个类型的静态方法的时候
- 使用 java.lang.reflect 包的方法对类型进行反射调用的时候, 如果类型没有进行过初始化, 则需要先触发其初始化
- 当初始化类的时候, 如果发现其父类还没有进行过初始化, 则需要先触发其父类的初始化
- 当虚拟机启动时, 用户需要指定一个要执行的主类 (包含 main() 方法的那个类), 虚拟机会先初始化这个主类
- 当使用 JDK7 新加入的动态语言支持时, 如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic / REF_putStatic / REF_invokeStatic / REF_newInvokeSpecial
四种类型的方法句柄, 并且这个方法句柄对应的类没有进行过初始化, 则需要先触发其初始化 - 当一个接口中定义了 JDK8 新加入的默认方法 (被 default 关键字修饰的接口方法) 时, 如果有这个接口的实现类发生了初始化, 那该接口要在其之前被初始化
这六种场景中的行为称为对一个类型进行主动引用。除此之外, 还有一些引用类型不会触发初始化, 称为被动引用。
被动引用的例子:
- 通过子类引用父类的静态字段, 不会导致子类初始化
public class SuperClass {
public static int value = 123;
}
public class SubClass extends SuperClass {
}
public class TestClass {
public static void main(String[] args) {
// 通过子类调用父类的静态属性
System.out.println(SubClass.value);
}
}
对于静态字段, 只有直接定义这个字段的类才会被初始化, 因此通过其子类来引用父类中定义的静态字段, 只会触发父类的初始化而不会触发子类的初始化
- 通过数组定义来引用类, 不会触发此类的初始化
public class SuperClass {
}
public class TestClass {
public static void main(String[] args) {
// 通过数组定义来引用类
SuperClass[] sca = new SuperClass[10];
}
}
这段代码不会触发 SuperClass 的加载, 但是这段代码里面触发了另一个名为 “[Lxxx.xxx.SuperClass” 的类的初始化阶段, 这并不是一个合法的类型名称,
它是一个由虚拟机自动生成的, 直接继承于 java.lang.Object 的子类, 创建动作由字节码指令 newarray 触发。
这个类代表了一个元素类型为 xxx.xxx.SuperClass 的一维数组, 数组中应有的属性和方法 (用户可直接使用的只有被修饰为 public 的 length 属性和 clone() 方法) 都实现在这个类里。
- 常量在编译阶段会存入调用类的常量池中, 本质上没有直接引用到定义常量的类, 因此不会触发定义常量的类的初始化
public class ConstClass {
public static final String HELLO_WORLD = "hello world";
}
public class TestClass {
public static void main(String[] args) {
// 通过类引用其常量
System.out.println(ConstClass.HELLO_WORLD);
}
}
在编译阶段通过常量传播优化, 已经将此常量的值 “hello world” 直接存储在 TestClass 类的常量池中, 以后 TestClass 对常量 ConstClass.HELLO_WORLD 的引用, 实际都被转化为 TestClass 类对自身常量池的引用了。
另外:
接口的加载过程与类加载过程稍有不同, 接口也有初始化过程, 接口与类真正有所区别的是前面讲述的六种初始化场景中的第三种:
当一个类在初始化时, 要求其父类全部都已经初始化过了, 但是接口没有这个限制, 只有在真正使用到父接口的时候 (如引用接口中定义的常量) 才会初始化。
2 类的加载过程
2.1 加载
在加载阶段, Java 虚拟机需要完成以下三件事情
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的 java.lang.Class 对象, 作为方法区这个类的各种数据的访问入口
在上面的说明中可以知道, 只要是一个可以转为 Class 对象的二进制字节流, 就可以作为输入源。
那么可操作的空间就很大了, 从网络请求, 运行时动态代理, 由其他文件生成 (JSP 页面等), 从加密文件中获取等, 用户完全可以使用自定义的
类加载器 (重写一个类加载器的 findClass() 或 loadClass() 方法), 实现根据自己的想法来赋予应用程序获取运行代码的动态性。
对于数组类而言, 情况就有所不同, 数组类本身不通过类加载器创建, 它是由 Java 虚拟机直接在内存中动态构造出来的。
但是数组类的元素类型 (Element Type, 指的是数组去掉所有维度的类型) 最终还是要靠类加载器来完成加载,
一个数组类 (下面简称为 C) 创建过程遵循以下规则:
- 如果数组的组件类型 (Component Type, 指的是数组去掉一个维度的类型, 注意和前面的元素类型区分开来)是引用类型, 那就递归采用上面定义的加
载过程去加载这个组件类型, 数组 C 将被标识在加载该组件类型的类加载器的类名称空间上- 如果数组的组件类型不是引用类型 (例如 int[] 数组的组件类型为 int), Java 虚拟机将会把数组 C 标记为与引导类加载器关联 (引导类加载器 Boostrap ClassLoader)
- 数组类的可访问性与它的组件类型的可访问性一致, 如果组件类型不是引用类型, 它的数组类的可访问性将默认为 public, 可被所有的类和接口访问到
类名称空间的知识可以看这里
加载阶段与连接阶段的部分动作 (如一部分字节码文件格式验证动作) 是交叉进行的, 加载阶段尚未完成, 连接阶段可能已经开始, 但这些夹在加载阶段之中进
行的动作, 仍然属于连接阶段的一部分, 这两个阶段的开始时间仍然保持着固定的先后顺序。
2.2 验证
验证是连接阶段的第一步, 这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求, 保证这些信息被当作代码运
行后不会危害虚拟机自身的安全。
验证阶段大致上会完成下面四个阶段的检验动作
- 文件格式验证
第一阶段要验证字节流是否符合 Class 文件格式的规范, 并且能被当前版本的虚拟机处理。 这一阶段可能包括下面这些验证点
- 是否以魔数 0xCAFEBABE 开头
- 主, 次版本号是否在当前 Java 虚拟机接受范围之内
- 常量池的常量中是否有不被支持的常量类型 (检查常量 tag 标志)
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据
- Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
- …
实际上第一阶段的验证点还远不止这些, 上面所列的只是从 HotSpot 虚拟机验证的一小部分内容。
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内, 格式上符合描述一个 Java 类型信息的要求。
通过了这个阶段, 原本的字节流才被允许存放在 Java 虚拟机的方法区中, 因为后面的三个阶段都是基于方法区的存储结构上进行的。
- 元数据验证
第二阶段是对字节码描述的信息进行语义分析, 以保证其描述的信息符合《Java语言规范》的要求, 这个阶段可能包括的验证点如下
- 这个类是否有父类 (除了 java.lang.Object 之外, 所有的类都应当有父类)
- 这个类的父类是否继承了不允许被继承的类 (被 final 修饰的类)
- 如果这个类不是抽象类, 是否实现了其父类或接口之中要求实现的所有方法
- 类中的 字段/方法 是否与父类产生矛盾 (例如覆盖了父类的 final 字段, 或者出现不符合规则的方法重载, 例如方法参数都一致, 但返回值类型却不同等)
- …
- 字节码验证
第三阶段通过数据流分析和控制流分析, 确定程序语义是合法的, 符合逻辑的。这阶段对类的方法体 (Class 文件中的 Code 属性) 进行校验分析, 保证被校
验类的方法在运行时不会做出危害虚拟机安全的行为, 这个阶段可能包括的验证点如下
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作, 例如不会出现类似于 “在操作栈放置了一个 int 类型的数据, 使用时却按 long 类型来加
载入本地变量表中” 这样的情况 - 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换总是有效的, 例如可以把一个子类对象赋值给父类数据类型, 这是安全的, 但是把父类对象赋值给子类数据类型, 甚至把对象赋值给
与它毫无继承关系, 完全不相干的一个数据类型, 则是危险和不合法的 - …
如果一个方法体通过了字节码验证, 也仍然不能保证它一定就是安全的。
由于数据流分析和控制流分析的高度复杂性, 为了避免过多的执行时间消耗在字节码验证阶段中, 在 JDK6 之后的 Javac 编译器和 Java 虚拟机里进行了一
项联合优化, 把尽可能多的校验辅助措施挪到 Javac 编译器里进行。具体的做法:
- 给方法体 Code 属性的属性表中新增加了一项名为 “StackMapTable” 的新属性, 这项属性描述了方法体所有的基本块 (Basic Block, 指按照控制流拆分
的代码块) 开始时本地变量表和操作栈应有的状态 - 在字节码验证期间, Java 虚拟机就不需要根据程序推导这些状态的合法性, 只需要检查 StackMapTable 属性中的记录是否合法即可, 从原来的将字节码
验证的类型推导转变为类型检查, 从而节省了大量校验时间。
理论上 StackMapTable 属性也存在错误或被篡改的可能, 所以是否有可能在恶意篡改了 Code 属性的同时, 也生成相应的 StackMapTable 属性来骗过虚
拟机的类型校验, 则是虚拟机设计者们需要仔细思考的问题。
HotSpot 虚拟机对于主版本号大于 50 (对应 JDK6) 的 Class 文件, 使用类型检查来完成数据流分析校验则是唯一的选择
- 符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候, 这个转化动作将在连接的第三阶段 – 解析阶段中发生。符号引用验证可以看作
是对类自身以外 (常量池中的各种符号引用) 的各类信息进行匹配性校验, 通俗来说就是, 该类是否缺少或者被禁止访问它依赖的某些外部类, 方法, 字段等资源,
这个阶段可能包括的验证点如下
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- 符号引用中的类, 字段, 方法的可访问性 (private / protected / public / ) 是否可被当前类访问
- …
符号引用验证的主要目的是确保解析行为能正常执行, 如果无法通过符号引用验证, Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError
的子类异常, 例如: java.lang.IllegalAccessError, java.lang.NoSuchFieldError, java.lang.NoSuchMethodError 等。
验证阶段对于虚拟机的类加载机制来说, 是一个非常重要的, 但却不是必须要执行的阶段, 在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关
闭大部分的类验证措施, 以缩短虚拟机类加载的时间。当然前提的是确保程序运行的所有代码 (第三方包, 外部加载等) 都是安全的。
2.3 准备
准备阶段是正式为类中定义的变量 (即静态变量, 被 static 修饰的变量) 分配内存并设置类变量初始值的阶段。
这些变量所使用的内存都应当在方法区中进行分配, 方法区本身是一个逻辑上的区域。
在 JDK7 及之前, HotSpot 使用永久代来实现方法区时, 在 JDK8 及之后, 类变量则会随着 Class 对象一起存放在 Java 堆中。
准备阶段 2 个混淆点
- 这时候进行内存分配的仅包括类变量, 而不包括实例变量, 实例变量将会在对象实例化时随着对象一起分配在 Java 堆中
- 这时候进行初始值的赋值, 一般赋予的是数据类型的零值, 而不是用户设置的数值
// 在准备阶段, 这时 value = 0;
public static int value = 123;
变量 value 在准备阶段过后的初始值为 0 而不是 123, 因为这时尚未开始执行任何 Java 方法, 而把 value 赋值为 123 的 putstatic 指令是程序被
编译后, 存放于类构造器 () 方法之中, 所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。
数据类型的零值
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
赋值为零值的特殊情况:
如果类字段的字段属性表中存在 ConstantValue 属性, 那在准备阶段变量值就会被初始化为 ConstantValue 属性所指定的初始值, 假设上面类变量 value
的定义修改为:
public static final int value = 123;
编译时 Javac 将会为 value 生成 ConstantValue 属性, 在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。
2.4 解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用 (Symbolic References)
符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关, 引
用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的, 因为符号引用
的字面量形式明确定义在 《Java虚拟机规范》 的 Class 文件格式中。
- 直接引用 (Direct References)
直接引用是可以直接指向目标的指针 / 相对偏移量 / 一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的, 同一个符号引用在不同
虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用, 那引用的目标必定已经在虚拟机的内存中存在。
《Java虚拟机规范》 之中并未规定解析阶段发生的具体时间, 只要求在执行
anewarray
checkcast
getfield
getstatic
instanceof
invokedynamic
invokeinterface
invokespecial
invokestatic
invokevirtual
ldc
ldc_w
ldc2_w
multianewarray
new
putfield
putstatic
这 17 用于操作符号引用的字节码指令之前, 对它们所使用的符号引用进行解析即可。
所以虚拟机实现可以根据需要来自行判断, 到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
对方法或者字段的访问, 也会在解析阶段中对它们的可访问性 (public, protected, private, ) 进行检查。
存在对同一个符号引用进行多次解析请求的情况, 除 invokedynamic 指令以外, 虚拟机实现可以对第一次解析的结果进行缓存, 譬如在运行时直接引用常量池
中的记录, 并把常量标识为已解析状态, 从而避免解析动作重复进行。无论是否真正执行了多次解析动作, Java 虚拟机都需要保证的是在同一个实体中, 如果
一个符号引用之前已经被成功解析过, 那么后续的引用解析请求就应当一直能够成功。同样地, 如果第一次解析失败了, 其他指令对这个符号的解析请求也应该
收到相同的异常, 哪怕这个请求的符号在后来已成功加载进 Java 虚拟机内存之中。
不过对于 invokedynamic 指令, 上面的规则就不成立了。当碰到某个前面已经由 invokedynamic 指令触发过解析的符号引用时, 并不意味着这个解析结果
对于其他 invokedynamic 指令也同样生效。因为 invokedynamic 指令的目的本来就是用于动态语言支持。它对应的引用称为 “动态调用点限定符
(Dynamically-Computed Call Site Specifier)”, 这里 “动态” 的含义是指必须等到程序实际运行到这条指令时, 解析动作才能进行。
解析动作主要针对类或接口, 字段, 类方法, 接口方法, 方法类型, 方法句柄和调用点限定符这 7 类符号引用进行, 分别对应常量池的 8 种常量类型
CONSTANT_Class_info
CONSTANT_Fieldref_info
CONSTANT_Methodref_info
CONSTANT_InterfaceMethodref_info
CONSTANT_MethodType_info
CONSTANT_MethodHandle_info
CONSTANT_Dynamic_info
CONSTANT_InvokeDynamic_info
对于后 4 种, 它们都和动态语言支持密切相关, 也就是 invokedynamic 指令有关。
2.4.1 类或接口的解析
假设当前代码所处的类为 D, 如果要把一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用, 那虚拟机完成整个解析的过程需要包括以下 3 个步骤
- 如果 C 不是一个数组类型, 那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中, 由于元数据验证 / 字节码验证
的需要, 又可能触发其他相关类的加载动作, 例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常, 解析过程就将宣告失败- 如果 C 是一个数组类型, 并且数组的元素类型为对象, 也就是 N 的描述符会是类似 “[Ljava/lang/Integer” 的形式, 那将会按照第一点的规则先加
载数组的元素类型。既需要加载的元素类型就是 “java.lang.Integer”, 接着由虚拟机生成一个代表该数组维度和元素的数组对象- 如果上面两步没有出现任何异常, 那么 C 在虚拟机中实际上已经成为一个有效的类或接口了, 但在解析完成前还要进行符号引用验证, 确认 D 是否具备
对 C 的访问权限。如果发现不具备访问权限, 将抛出 java.lang.IllegalAccessError 异常
针对上面第 3 点访问权限验证, 在 JDK9 引入了模块化以后, 一个 public 类型也不再意味着程序任何位置都有它的访问权限, 我们还必须检查模块间的访
问权限。
如果我们说一个 D 拥有 C 的访问权限, 那就意味着以下 3 条规则中至少有其中一条成立
- 被访问类 C 是 public 的, 并且与访问类 D 处于同一个模块
- 被访问类 C 是 public 的, 不与访问类 D 处于同一个模块,但是被访问类 C 的模块允许被访问类 D 的模块进行访问
- 被访问类 C 不是 public 的, 但是它与访问类 D 处于同一个包中
2.4.2 字段解析
要解析一个未被解析过的字段符号引用, 首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析, 也就是字段所属的类
或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常, 都会导致字段符号引用解析的失败, 如果解析成功完成, 那把这个字段所属的
类或接口用 C 表示,《Java虚拟机规范》 要求按照如下步骤对 C 进行后续字段的搜索
- 如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段, 则返回这个字段的直接引用, 查找结束
- 否则, 如果在 C 中实现了接口, 将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,
则返回这个字段的直接引用, 查找结束 - 否则, 如果 C 不是 java.lang.Object 的话, 将会按照继承关系从下往上递归搜索其父类, 如果在父类中包含了简单名称和字段描述符都与目标相匹配的
字段, 则返回这个字段的直接引用, 查找结束 - 否则, 查找失败, 抛出 java.lang.NoSuchFieldError 异常
如果查找过程成功返回了引用, 将会对这个字段进行权限验证, 如果发现不具备对字段的访问权限, 将抛出 java.lang.IllegalAccessError 异常
但在实际情况中, Javac 编译器往往会采取比上述规范更加严格一些的约束, 譬如有一个同名字段同时出现在某个类的接口和父类当中, 或者同时在自己或父类
的多个接口中出现, 按照解析规则仍是可以确定唯一的访问字段, 但 Javac 编译器就可能直接拒绝其编译为 Class 文件。
2.4.3 方法解析
方法解析的第一个步骤与字段解析一样, 也是需要先解析出方法表的 class_index 项中索引的方法所属的类或接口的符号引用, 如果解析成功, 那么我们依
然用 C 表示这个类, 接下来虚拟机将会按照如下步骤进行后续的方法搜索
- 由于 Class 文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的, 如果在类的方法表中发现 class_index 中索引的 C 是个接口的话, 那
就直接抛出 java.lang.IncompatibleClassChangeError 异常 - 如果通过了第一步, 在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法, 如果有则返回这个方法的直接引用, 查找结束
- 否则, 在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法, 如果有则返回这个方法的直接引用, 查找结束
- 否则, 在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法, 如果存在匹配的方法, 说明类 C 是一个抽象类,
这时候查找结束, 抛出 java.lang.AbstractMethodError 异常 - 否则, 宣告方法查找失败, 抛出 java.lang.NoSuchMethodError
最后, 如果查找过程成功返回了直接引用, 将会对这个方法进行权限验证, 如果发现不具备对此方法的访问权限, 将抛出 java.lang.IllegalAccessError 异常
2.4.4 接口方法解析
接口方法也是需要先解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用, 如果解析成功, 依然用 C 表示这个接口, 接下来虚拟机
将会按照如下步骤进行后续的接口方法搜索
- 与类的方法解析相反, 如果在接口方法表中发现 class_index 中的索引 C 是个类而不是接口, 那么就直接抛出 java.lang.IncompatibleClassChangeError
异常 - 否则, 在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法, 如果有则返回这个方法的直接引用, 查找结束
- 否则, 在接口 C 的父接口中递归查找, 直到 java.lang.Object 类 (接口方法的查找范围也会包括 Object 类中的方法) 为止, 看是否有简单名称和描
述符都与目标相匹配的方法, 如果有则返回这个方法的直接引用, 查找结束 - 对于规则 3, 由于 Java 的接口允许多重继承, 如果 C 的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法, 那将会从这多个方法中返回其中
一个并结束查找, 《Java虚拟机规范》中并没有进一步规则约束应该返回哪一个接口方法。 不同发行商实现的Javac编
译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。 - 否则, 宣告方法查找失败, 抛出 java.lang.NoSuchMethodError 异常
在 JDK9 之前, Java 接口中的所有方法都默认是 public 的, 也没有模块化的访问约束, 所以不存在访问权限的问题, 接口方法的符号解析就不可能抛出
java.lang.IllegalAccessError 异常。 但在 JDK9 中增加了接口的静态私有方法, 也有了模块化的访问约束, 所以从 JDK9 起,
接口方法的访问也完全有可能因访问权限控制而出现 java.lang.IllegalAccessError 异常
2.5 初始化
进行准备阶段时, 变量已经赋过一次系统要求的初始零值, 而在初始化阶段, 则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源, 初始化
阶段就是执行类构造器 <clinit>()
方法的过程, 由 Javac 编译器的自动生成物。
<clinit>()
方法是由编译器自动收集类中的所有类中的静态变量的赋值动作和静态语句块 (static{} 块) 中的语句合并产生的, 编译器收集的顺序是由语句在
源文件中出现的顺序决定的, 静态语句块中只能访问到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问
<clinit>()
方法与类的构造函数 (即在虚拟机视角中的实例构造器 <init>()
方法) 不同, 它不需要显式地调用父类构造器, Java 虚拟机会保证在子类的
<clinit>()
方法执行前, 父类的 <clinit>()
方法已经执行完毕。
<clinit>()
方法对于类或接口来说并不是必需的, 如果一个类中没有静态语句块, 也没有对静态变量的赋值操作, 那么编译器可以不为这个类生成 <clinit>()
方法
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成 <clinit>()
方法, 但接口与类不同的是, 执行接口的 <clinit>()
方法不需要先执行父接口的 <clinit>()
方法, 因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。 同样的, 接口的实现类在初始化时也一样不
会执行接口的 <clinit>()
方法
Java 虚拟机必须保证一个类的 <clinit>()
方法在多线程环境中被正确地加锁同步, 如果多个线程同时去初始化一个类, 那么只会有其中一个线程去执行这个
类的 <clinit>()
方法, 其他线程都需要阻塞等待, 直到活动线程执行完毕 <clinit>()
方法。如果在一个类的 <clinit>()
方法中有耗时很长的操作,
那就可能造成多个进程阻塞, 如果执行 <clinit>()
方法的那条线程退出 <clinit>()
方法后, 其他线程唤醒后则不会再次进入 <clinit>()
方法
3 类的加载器
类加载阶段中的 “通过一个类的全限定名来获取描述该类的二进制字节流” 这个动作是放到 Java 虚拟机外部去实现, 以便让应用程序自己决定如何去获取所需的
类, 实现这个动作的代码被称为 “类加载器 (Class Loader)”。
对于任意一个类, 都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性, 每一个类加载器, 都拥有一个独立的类名称空间。
也就是两个类是否 “相等”, 只有在这两个类是由同一个类加载器加载的前提下才有意义, 否则, 即使这两个类来源于同一个 Class 文件, 被同一个 Java 虚
拟机加载, 只要加载它们的类加载器不同, 那这两个类就必定不相等。
这里所指的 “相等”, 包括代表类的 Class 对象的
equals()
isAssignableFrom()
isInstance()
方法的返回结果, 也包括了使用 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("xxxx.ClassLoaderTest").newInstance();
// xxxx.ClassLoaderTest
System.out.println(obj.getClass());
// 结果 false
System.out.println(obj instanceof xxxx.ClassLoaderTest);
}
}
ClassLoaderTest 先是由 AppClassLoader 进行了加载, 执行内部的 main 方法, main 里面的 ClassLoaderTest 则是有自定义的 ClassLoader
加载的, 所以 2 个的 ClassLoaderTest 的 ClassLoader 不一样, 所以程序判断的结果为 false
3.1 双亲委派模型
在 JVM 中, 只存在两种不同的类加载器: 一种是启动类加载器 (Bootstrap ClassLoader), 这个类加载器使用 C++ 语言实现, 是虚拟机自身的一部分;
另外一种就是其他所有的类加载器, 这些类加载器都由 Java 语言实现, 独立存在于虚拟机外部, 并且全都继承自抽象类 java.lang.ClassLoader
自 JDK1.2 以来, Java 一直保持着三层类加载器, 双亲委派的类加载架构。绝大多数 Java 程序都会使用到以下 3 个系统提供的类加载器来进行加载
- 启动类加载器 (Boostrap Class Loader)
这个类加载器负责加载存放在 <JAVA_HOME>\lib 目录, 或者被 -Xbootclasspath 参数所指定的路径中存放的, 而且是 Java 虚拟机能够识别的 (按照
文件名识别, 如 rt.jar, tools.jar, 名字不符合的类库即使放在 lib 目录中也不会被加载) 类库加载到虚拟机的内存中, 启动类加载器无法被 Java 程序
直接引用, 用户在编写自定义类加载器时, 如果需要把加载请求委派给引导类加载器去处理, 那直接使用 null 代替即可
/**
* Returns the class loader for the class. Some implementations may use null to represent the bootstrap cla
*/
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;
}
-
扩展类加载器 (Extension Class Loader)
这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载 <JAVA_HOME>\lib\ext 目录中, 或者被
java.ext.dirs 系统变量所指定的路径中所有的类库。根据这个加载器的名称, 可以推断出这是一种 Java 系统类库的扩展机制, 允许用户将具有通用性的类
库放置在 ext 目录里以扩展 Java SE 的功能, 在 JDK9 之后, 这种扩展机制被模块化带来的天然的扩展能力所取代 -
应用程序类加载器 (Application Class Loader)
这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是 ClassLoader 类中的 getSystemClassLoader() 方法的返
回值, 所以有些场合中也称它为 “系统类加载器”。它负责加载用户类路径 (ClassPath) 上所有的类库, 开发者同样可以直接在代码中使用这个类加载器。如
果应用程序中没有自定义过自己的类加载器, 一般情况下这个就是程序中默认的类加载器
JDK 9 之前的 Java 应用都是由这三种类加载器互相配合来完成加载的, 如果用户认为有必要, 还可以加入自定义的类加载器来进行拓展, 典型的如增加除了
磁盘位置之外的 Class 文件来源, 或者通过类加载器实现类的隔离 / 重载等功能。
图中展示的各种类加载器之间的层次关系被称为类加载器的 “双亲委派模型 (Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,
其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承 (Inheritance) 的关系来实现的, 而是通常使用组合 (Composition)
关系来复用父加载器的代码。
双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层
次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请求 (它的搜索范围中没
有找到所需的类) 时, 子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织类加载器之间的关系, 一个显而易见的好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类 java.lang.Object, 它存放在 rt.jar 之中, 无论哪一个类加载器要加载这个类, 最终都是委派给处于模型最顶端的启动类加载器进行加载, 因此
Object 类在程序的各种类加载器环境中都能够保证是同一个类。反之, 如果没有使用双亲委派模型, 都由各个类加载器自行去加载的话, 如果用户自己也编写
了一个名为 java.lang.Object 的类, 并放在程序的 ClassPath 中, 那系统中就会出现多个不同的 Object 类, Java 类型体系中最基础的行为也就无
从保证, 应用程序将会变得一片混乱
双亲委派模型的实现
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 先检查类是否加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 有父级的话,让父级进行加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 没有父级,自己进行加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
// 依旧为空的,自己尝试进行查找
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
3.2 破坏双亲委派模型
- 线程上下文类加载器 (ThreadContextClassLoader)
JNDI 现在已经是 Java 的标准服务, 它的代码由启动类加载器来完成加载 (在 JDK1.3 时加入到 rt.jar 的), 属于 Java 中很基础的类型, 但 JNDI 存
在的目的就是对资源进行查找和集中管理, 它需要调用由其他厂商实现并部署在应用程序的 ClassPath 下的 JNDI 服务提供者接口 (Service Provider
Interface, SPI) 的代码, 现在问题来了, 启动类加载器是绝不可能认识, 加载这些代码的。
为了解决这个困境, Java 的设计团队只好引入了一个不太优雅的设计: 线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过
java.lang.Thread 类的 setContextClassLoader() 方法进行设置, 如果创建线程时还未设置, 它将会从父线程中继承一个, 如果在应用程序的全局范
围内都没有设置过的话, 那这个类加载器默认就是应用程序类加载器。
JNDI 服务使用这个线程上下文类加载器去加载所需的 SPI 服务代码, 这是一种父类加载器去请求子类加载器完成类加载的行为, 这种行为实际上是打破了双亲
委派模型的层次结构来逆向使用类加载器, 已经违背了双亲委派模型的一般性原则
-
还有以 IBM 公司主导的 JSR-291 (即 OSGi R4.2) 提案, 通过自定义的类加载器机制的实现动态特性。
-
JDK9 的模块化实现也对类加载架构做了跳转
-
要破坏的化, 可以通过自定义类加载器, 继承 ClassLoader, 重写 loadClass 方法
4 参考
《深入理解Java虚拟机》- 周志明