本章的内容承接自JVM:类文件简明分析,总结于《深入了解 JVM 虚拟机 第三版》 第七章。首先,Class 文件本身是 "静止" 的。和其它在编译时就连接的语言不同,JVM 只有在运行时才会将 "静止" 的 Class 文件通过 类加载 的方式读取到内存。
这种做法虽然会带来少许 "不便":比如在编译期间使用符号引用来 "占位",直到运行期间才替换成直接引用的做法,给编译期以及第一次类加载都带来了少许额外开销。然而,正是该特性为 Java 带来了极大的灵活性 —— "见机行事",动态组装。典型的案例包含了 Applet,Jsp 等。
前文已经探讨了 Class 文件的大体结构,接下来的任务就是探究 JVM 是如何将 Class 加载到内存中的 ( 即 "类加载" 的完整细节 ) 。有两个要讨论的核心问题:
- 何时会触发类加载?( When )
- 类加载的过程? ( How )
另外两个前提是:
- 本文的 " Class 文件 " 将不仅仅指代狭义上存储在磁盘上的
.class
文件,它的本质是符合特定格式的二进制码,其来源可以是网络,数据库,内存,甚至是另一个正在运行中的程序,故下文可能也会称之 "二进制流" 。 - 本文的 "类" 默认情况下包含了一般的类以及接口两种情况。当两者需要被区别开来时,笔者会注额外的说明。
1. 何时进行类加载 —— When
类加载的 7 个阶段如图所示:
注意,类加载 ( Class Loading ) 的流程中包含了一个名为加载 ( Loading ) 的阶段,不要混淆。加载阶段和 类加载器 相关;
验证,准备,解析三者可被统称为连接 ( Linking ) 阶段;
在这七个阶段中,有五个阶段是严格按顺序 开始 的:加载,验证,准备,初始化,卸载。由于这些阶段通常都会交叉进行 ( 比如在某个阶段的执行过程中就激活另一个阶段 ),因此不保证它们严格按照该次序依次结束。
1.1 初始化 ( Initialization ) 的时机
《Java 虚拟机规范》没有严格规定何时进行类加载的第一个阶段 —— 加载,但是严格规定了 有且六种情况 ( 其规范就是用如此严谨的形容词来修饰的 ) 必须开始执行初始化。 通俗的讲,就是某个类在 Java 运行过程中被调用的情形 :
- 有 4 条字节码指令涉及到该类,而该类还未被初始化:
new
指令:创建了有关于该类的实例;putstatic
,getstatic
指令:调用了有关于该类的静态字段 ( 读 、写 );invokestatic
指令:调用了有关于该类的静态方法;
- 使用
java.lang.Reflect
包对该类进行了反射调用,而该类未被初始化; - 初始化某个类之前,要先初始化其父类,而该父类未被初始化;( 该条对于接口而言未必 。接口继承的父接口可以推迟到真正调用它们内容时才初始化。 )
- 虚拟机启动时,承载着主程序 ( public static void main 方法 ) 的类 ( 称 "主类" ) 需要被率先初始化。
5*) 在 JDK 8 版本后,接口允许携带默认方法。如果有实现类实现了这样的接口,则该接口会在调用其实现类之前被初始化。
6*) java.lang.invoke.MethodHandle
实例解析的结果为 REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial 四种方法类型的句柄,该这些句柄对应的类没有被初始化;
前四种情况非常好理解,而 ( 尤其是最后一个 ) 情况则略微复杂,因为它和 JDK 7 引入的动态语言支持相关。而这六者以外的情形全部被称之为 被动引用 。被动引用的类是不会被立刻初始化的,下面用一些例子为证。
1.2 被动引用的例子
第一个情形是:有继承关系的两个类 SuperClass
和 SubClass
。当通过子类去调用继承于父类的静态内容 ( 包括字段,方法 ) 时,虚拟机仅初始化其父类。如:
public class SuperClass {
static {
System.out.println("SuperClass has been initialized!");
}
protected static int value = 123;
}
class SubClass extends SuperClass{
static {
System.out.println("SubClass has been initialized!");
}
}
class MainTest{
public static void main(String[] args) {
// SuperClass has been initialized!
// 123
System.out.println(SubClass.value);
}
}
编译并运行这个 .java
文件,控制台并不会打印 SubClass has been initialized!
。
第二个情形是,仅仅创建了某个类的数组,如 ( 这里复用了前文中 SuperClass
和 SubClass
类 ) :
class MainTest{
public static void main(String[] args) {
// 虚拟机初始化的是 [LSubClass,而非 SubClass 类。
SuperClass[] superClasses = new SuperClass[10];
}
}
执行程序,控制台没有打印任何 SuperClass
或 SubClass
在静态域中留下的字符串,换句话说,创建 SuperClass[]
对象的动作和 SuperClass
和 SubClass
并没有什么联系。这是由于数组对象是由 newarray
字节码生成的,而非 new
。
Java 本身就对数组类型做了一些封装,我们因此得以通过 .length
快速获取数组的长度,或者是安全地访问数组元素。和 C/C++ 的指针访问而言,对于越界问题,Java 程序会抛出异常并可能提前结束程序 ( 取决于用户代码是否 try catch ) ,而不是直接非法地读取内存。
第三个情形是,直接引用了另一个类的常量属性。如:
class ConstClass {
static {
System.out.println("ConstClass has been initialized!");
}
public static final double Pi = 3.14;
}
class MainTest{
public static void main(String[] args) {
// 程序没有打印 "ConstClass has been initialized!"
System.out.println(ConstClass.Pi);
}
}
引发此现象的原因是:javac 早在编译阶段就会通过 常量传播优化 直接将 ConstClass.Pi
值 3.14
存储到 MainTest
类的常量池内。如此一来,自然就没有 ConstClass
类的事了。在编译得到的 MainTest
类中,并没有对 ConstClass
的符号引用。同时,虚拟机还省去了初始化 ConstClass
类的时间。
2. 类加载的流程
2.1 加载 ( Loading )
这里的加载指类加载流程中的第一个阶段,其首要任务是:找到该类对应的二进制流 ( 或文件 ) 。
- 通过类的全限定名称对应到二进制流 ( 文件 );
- 将二进制流 ( 文件 ) 内的常量池内容转化到方法区的运行时常量池;
- 在内存中创建一个表示该类的
java.lang.Class<?>
对象,作为访问该类的入口。
《Java 虚拟机规范》没有对这三条做出任何强力约束。比如对于第一条而言:
- 二进制流可以从
.zip
包中提取,这日后演化出了.jar
,.war
包。 - 从网络中获取,比如 Web Applet ( 它已经有些古老了) 。
- 动态生成,最著名的就是动态代理技术,如
jdk
代理会创建出大量的$Proxy
类。
加载阶段中 "获取类二进制流" 的动作是 Java 开发人员最可控的一个阶段。他们可以选择将加载的工作交付给虚拟机内置的引导类加载器进行,也可以自行编写类加载器进行 ( 这里引入了一个新的概念 "类加载器" ,我们会在后文详细展开 ) ,通过操作二进制流的获取方式来赋予应用程序运行代码的灵活性。
这里不得不再提及数组类型 ( 假定它是 [LXs
类型, 组件类型 为 Xs
,指原数组类型去掉一个维度的类型,它可能还是一个嵌套的类型 )。它本身不依赖类加载器,而是通过虚拟机直接在内存中创建。但是,其最终的 元素类型 ( [LXs
去掉所有维度后的那个类 LX
) 还是要通过类加载器完成的。
2.2 验证 ( Verification ) / 连接 ( 1 / 3 )
虚拟机需要保证加载进内存的二进制流是严格符合 《 Java 虚拟机约束》 的。Java 相比 C/C++ 而言更加安全,比如在源码的层面上无法做到越界访问数组,或者尝试将一个对象上转型为另一个毫不相关的类型。然而,这不意味着在字节码层面上无法做到,因为 Class 文件未必就是由 Java 源代码编译产生的,只要有人熟悉 Class 文件结构和 Java 字节码指令序列,他完全可以通过二进制编辑器编写出恶意代码让虚拟机执行。
为此,Java 虚拟机在后续的版本中大幅增加了对验证阶段的描述和声明。整体来看,验证阶段大体上有以下四个过程:文件格式验证,元数据验证,字节码验证和符号引用验证。
2.2.1 文件格式验证
格式验证是最基础的,最先行的验证,相当于检查某个人写的文章 "文体是否符合题目要求" 。该步骤大概有以下检查点:
- 魔数是否是
0xCAFE BABE
( 上一章节提到了,它是隐藏在二进制内部的文件类型标记 ); - 该二进制流标识的主、次版本号是否可此虚拟机接纳;
- 常量池中是否有违背《JVM 虚拟机规范》规定的常量类型;
- 指向常量池的索引值是否指向了不存在的,或者不符合类型的常量;
CONSTANT_UTF8_INFO
型常量是否存在非 UTF-8 编码的数据;- ....
只有通过了文件格式验证,二进制流才会被送入方法区 。后续的验证不会再直接读取二进制流,而是读取方法区内的对应内容。
2.2.2 元数据验证
下一过程则是对字节码的信息进行语义分析,相当于检查 "文章内的每个句子是否包含语病" 。比如说:
java.lang.Object
以外的类都应该存在非空的父类索引;- 此类是否继承了一个不允许被继承的类 ( 比如这个类被
final
修饰 ); - 这个类如果是非抽象类,那么它是否实现了所有被父类,接口要求实现的所有方法;
- ...
2.2.3 字节码验证
字节码验证是验证阶段中最复杂的过程,相当于检查 "文章的段落是否合乎逻辑,文意是否切题,积极健康":即对数据流和程序流整体进行分析,确定类内的方法在运行时不会危害到 JVM 的正常运行。该过程主要是提取 Class 文件中的 Code 属性 ( 位于方法表的属性表内 ) 进行校验。比如:
- 任意时刻的操作数栈和指令序列都可以配合工作。避免诸如对同一个操作数使用
istore
指令 "写" 却使用fstore
指令 "读" 这样的错误指令; - 控制转移指令不会跳转到意料之外的位置;
- 方法体内的 ( 强制 ) 类型转换总是合理的;
- ......
如果它们没有通过字节码验证,那就一定会引发 "Bug" 。但是,方法体内的指令序列即使通过了验证,也不保证其方法在运行时不会引发 "Bug"。这是由于 "没有任何一个程序 P 可以判断程序 H 是否会陷入死循环",这是一个停机问题悖论 ( Halting Problem ) ,相似的问题还有理发师悖论。
Java 团队为了优化字节码优化的效率,选择了将尽可能多的步骤挪到 javac 编译器内进行 ( 牺牲一点编译时间,以提升运行时加载效率 ) 。具体做法是在方法表的 Code 属性内增加了 "StackMapTable" 属性 ( 详细见文末的拓展内容 ) 。它记载了方法体内的所有基本块 ( Basic Block,按照指令流控制拆分出的代码块 ) 在开始时本地变量表操作栈应有的状态 ( 笔者将它理解成了类似于垃圾回收中使用的 OopMap
标记 ),这样就将字节码的类型推导转换为类型检查,从而节省大量的时间。
2.2.4 符号引用验证
符号引用验证的过程直到在下一个解析阶段才会进行。当虚拟机逐步将常量池内的符号引用转化为直接引用的时候,还会顺带检查这个符号引用的合法性。通俗的说就是:
- 能否根据字符串描述的全限定名称找到对应的类;
- 指定的类中是否存在符合方法的字段描述符和简单名称所描述的方法和字段;
- 符号引用中的类,字段,方法的可访问性;
- ...
如果符号引用验证失败了,则 Java 虚拟机会抛出 java.lang.IncompatibaleClassChangeError
的子类异常,比如:java.lang.IllegalAccessError
( 无访问权限 ),java.lang.NoSuchFieldError
( 没有找到此字段 ) 或者 java.lang.NoSuchMethodError
( 没有此方法 ) 等。
2.3 准备 ( Preparation ) / 连接 ( 2 / 3 )
准备阶段会为类的静态变量 ( 使用 static 修饰的变量 ) 分配内存并 赋零值 。从逻辑上,这类内容都应该在方法区 ( 非堆 ) 空间进行分配,而实际上在 JDK 8 之后,字符串常量池和静态变量都已经都迁移到了堆空间中,其它的类信息,运行时常量池等被迁移到了元空间中1。
这里的赋零值需要强调一下。比如有这样的代码:
public static int value = 100;
在准备阶段,这个 value
值仍然会被赋予 0 而非 100。因为准备阶段还没有执行 Java 方法,而 "将 value
赋值为 100" 的 putstatic
指令是程序被编译后,存方于类方法的 <clinit>()
方法中。下表是 Java 基本数据类型的零值。
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
short | (short)0 | double | (double)0.0d |
char | \u0000 | reference | null |
byte | (byte)0 |
而赋零值也存在特殊情况,比如将上述的 value 定义为一个常量:
public static final int value = 100;
这样,编译后的字段表中将生成一个 ConstantValue
属性。此时就会在准备阶段就将 value 赋值为 100 。
2.4 解析 ( Resolution ) / 连接 ( 3 / 3 )
解析阶段是正式将二进制流内的所有符号引用替换成直接引用的阶段,对应验证阶段的符号引用验证过程。符号引用在 Class 文件中以 CONSTANT_Class_info
,CONSTANT_Field_info
,CONSTANT_Methodref_info
等形式出现 ( 它们的值本身是 UTF-8 编码的字符串 ) 。
这里重新对符号引用和直接引用做大体的描述:
符号引用 ( Symbolic References ):单纯使用符号所引用的目标,可以通过 javap -v
命令在常量池中找到。
直接引用 ( Direct References ):直接引用是可以直接指向目标的指针,偏移量等,是无法通过 Class 文件直接查看的内容,因为它和虚拟机的内存布局相关。如果说一个引用的目标已经是直接引用,那么该目标必然已经被加载到虚拟机内存中了。
举一个例子,如果在源代码中编写了这样一行代码:
System.out.println("hello");
该行代码在编译时,Class 文件的常量池会增加以下内容:
Constant pool:
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // hello
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
...
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 hello
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
...
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
大体可以理解为:当生成静态的 Class 文件时,javac 并不知道 java.lang.System
及其方法的句柄会在内存的哪个地址,因此这里仅使用了 UTF-8 符号做了标记。直到解析此 Class 文件时,虚拟机再将这些符号替换成可被直接寻址的直接引用。
说得更严谨一些,其实 《 Java 虚拟机规范》 没有强制约束解析阶段的具体时机,仅要求在 17 个 ( 包含了 getfield
,getstatic
,ldc
,new
... 等 ) 涉及操作符号引用的字节码指令之前进行解析。虚拟机其实可以选择在类刚刚被类加载器加载之后就开始对常量池内的符号引用解析,也可以等到相关符号引用被第一次使用时才解析。
对同一个符号的多次解析经常发生,因此虚拟机可以选择在第一次解析完毕后将解析结果放到缓存,以节省后续对相同符号的解析时间。虚拟机则需要保证,如果第一次对该符号的解析成功了,则后续对同样的符号解析应该也是一直成功的。
然而对于 invokeDynamic
指令而言不成立,该指令用于对动态语言的支持,它的引用称之为 "动态调用点限定符 ( Dynamically-Computed Call Site Specifier )",这里的动态指:程序必须运行到该处指令时,解析动作才能完成。
其安全性检查将由验证阶段的符号引用验证来完成。
解析动作主要针对类/接口、字段、类方法、接口方法、接口类型、方法句柄和调用点限定符这 7 类和一个字符串类型 ( 对它的转化非常直接,我们可以在常量池就观察到 ) ,分别对应:
- CONSTANT_Class_info
- CONSTANT_Fieldref_info
- CONSTANT_Methodref_info
- CONSTANT_InterfaceMethodref_info
- CONSTANT_MethodType_info
- CONSTANT_MethodHandle_info
- CONSTANT_Dynamic_info
- CONSTANT_InvokeDynamic_info
- CONSTANT_String_info
这里介绍前四种解析过程,后四种 ( 除去字符串解析 ) 则在后续动态语言调用时结合 invokeDynamic
指令一同叙述。
2.4.1 类或接口的解析
设当前代码所处的类为 A
类,代码某处有一个符号 S
,它代表一个指向 B
类/接口的直接引用,则:
一、如果 A
是非数组类型,则虚拟机根据符号 S
描述的全限定名称去查找 B
类的类加载器去加载 B
,加载过程会因 B
的继承关系而触发 "链式反应" 。
二、如果 A
是数组类型,且数组的元素类型是对象 ( 如 [Ljava/lang/Integer
类型 ),那么就按照第一条规则去加载对应的元素类型, 然后由虚拟机生成代表该数组维度和元素的数组对象 。
三、前两条都通过的情况下,剩下的就是等待符号引用验证通过,否则抛出 java.lang.IllegalAccessError
异常。针对 JDK 9 及之后的版本,还需要检查模块之间的检查权限,包括了:
- 被访问类
B
本身是public
的,并且和类A
在一个模块。 B
(public
的 ) 、A
类不在同一个模块,但是这两个模块之间允许访问。B
不是public
的,但是和A
在同一个包下。
2.4.2 字段解析
字段解析 ( Fieldref_info ) 依赖于类或接口 ( Class_info ) 的解析,因为字段是 "类的字段" 。如果在类解析过程中就失败了,那么字段解析也随之失败。在类解析完成之后 ( 用 C
来表示 ) ,《Java 虚拟机规范》 要求按下述的步骤对 C
继续搜索:
- 如果
C
类本身包含这个字段 ( 表现在C
的常量池内具备其字段描述符和字段简单名称 ),则返回字段的直接引用。 - 否则,如果
C
实现了接口,则按照接口的继承关系递归搜寻匹配的字段并返回。 - 否则,如果
C
继承了父类,且本身是非java.lang.Object
类型,则按照继承关系递归搜索匹配的字段并返回。 - 否则,查找失败,返回
java.lang.NoSuchFieldError
异常。
对于查找成功的引用,虚拟机会对此进行权限验证,并有可能抛出 IllegalAccessError
异常。
针对第二条和第三条,有这样的难题:如果一个类继承的父类和实现的接口中,存在相同名称以及类型的字段,此时字段解析应该返回哪个内容?
public class SubClass extends SuperClass implements SuperImplement{
public static void main(String[] args) {
// Reference to 'value' is ambiguous, both `SuperClass.value` and `SuperImplement.value` match.
System.out.println(new SubClass().value);
}
}
class SuperClass{protected String value = "...";}
interface SuperImplement{String value = "???";}
实际上,javac 早在编译期就会拒绝对这样 "含糊其辞" 的引用,IDE 会提前告知此处代码存在语法错误。
2.4.3 方法解析
注意,方法解析特指对类的方法解析,和接口方法解析是区分开来的。这里需要介绍 CONSTANT_Methodref_info
表的结构:
类型 | 名称 | 描述 |
---|---|---|
u1 | tag | 固定为10 |
u2 | class_index | 指向声明此方法的类描述符CONSTANT_Class_info的索引 |
u2 | nameAndType_index | 指向名称和方法及类型描述符CONSTANT_NameAndType_info的索引 |
首先,虚拟机要确定这个 class_index
指向的是一个类 C
,否则就会直接抛出 IncompatibaleClassChangeError
异常。剩下的逻辑和字段解析部分大体相似,但是在成功解析的情形,其实现的接口不应在查找范围内:
首先在 C
类内查找是否简单名称和描述符都匹配的方法,然后在继承的父类中递归查找。如果还没能返回直接引用,则去实现的接口中递归查找。如果该方法在接口中被搜索到,显然,这个方法现在是一个抽象方法,此时抛出 java.lang.AbstractMethodError
异常。
否则,最终抛出 NoSuchMethodError
。另外,即便是成功返回了直接引用,如符号引用验证不通过,这仍然会返回 java.lang.IllegalAccessError
。
2.4.4 接口方法解析
注意,接口方法解析特指对接口的方法解析,和方法解析是区分开来的。这里需要介绍 CONSTANT_InterfaceMethodref_info
表的结构:
类型 | 名称 | 描述 |
---|---|---|
u1 | tag | 固定为11 |
u2 | class_index | 指向声明此方法的类描述符CONSTANT_Class_info的索引 |
u2 | nameAndType_index | 指向名称和方法及类型描述符CONSTANT_NameAndType_info的索引 |
与方法解析相反,虚拟机要确定这个 class_index
指向的是一个接口 I
,否则就会直接抛出 IncompatibaleClassChangeError
异常。剩下的逻辑查找逻辑是:
首先,在 I
接口中查找是否有简单名称和描述符全部匹配的方法,有则返回其直接引用。
否则,在 I
继承的父接口中递归查找,直到最终查找 java.lang.Object
类,有则返回其直接引用。注意,由于 Java 允许多接口继承,因此这里可能会找到多个匹配的直接引用。《Java 虚拟机规范》没有对这种情况做进一步约束,对于不同的虚拟机厂商,它们的策略有可能是返回第一个满足条件的直接引用,也有可能在编译器那里做出限制,拒绝这类模糊的引用。
否则,抛出 NoSuchMethodError
异常。在 JDK 9 之前,所有的接口默认就是 public
权限的,且没有模块化带来的限制,因此在接口方法解析过程不可能抛出 IllegalAccessError
。但是在其之后,接口方法的访问也有可能出现 "权限不足" 的问题,进而抛出 IllegalAccessError
异常。
2.5 初始化 ( Initilization )
类的初始化是类加载的最后一个阶段 ( 在之后就是由用户程序去使用了 ) ,在之前的几个阶段,除了加载阶段用户可以通过设计类加载器进行干预,剩下的都是由 Java 虚拟机来完成的。 直到初始化阶段,Java 虚拟机才真正执行类编写的 Java 代码 ,将主导权移交给用户程序。
在准备阶段,Java 虚拟机已经根据系统的定义为这个类赋零值。而初始化阶段,则会根据程序员在源代码中编写的代码块来控制类变量和静态资源。这些工作都要交给类构造器 <clinit>()
来完成,该构造器是由 javac 编译器生成的。
2.5.1 关于 () 方法
该方法由 Java 编译器收集源码中静态变量的赋值语句和 static{}
语句块 ( 称 "静态语句块" ) 自动生成的。其收集顺序取决于这些语句在源代码中的出现顺序。在静态语句块中只能访问声明在之前的静态变量,否则就是非法的前向访问。
public class Clazz {
/*
正确的引用
static int i = 10;
static{
i = 12;
System.out.println(i);
}
*/
static{
i = 12;
// Illegal forward reference;
System.out.println(i);
}
static int i = 10;
}
<clinit>()
和 实例构造器<init>()
不同,它不需要显式调用父类构造器,因为 Java 虚拟机一定会保证在调用子类的 <clinit>()
方法之前,其父类的 <clinit>()
方法已经被执行过了。换句话说,Java 虚拟机第一个被执行 <clinit>()
方法的类型一定是 java.lang.Object
。
父类的 <clinit>()
方法总是优于子类去执行。下面的例子验证了这点:
public class SuperClass{
static int i = 1;
static {
i = 3;
}
}
class SubClass extends SuperClass{
static int j;
static {
j = i;
}
}
class RunApp{
public static void main(String[] args) {
System.out.println(SubClass.j);
}
}
这段程序的输出结果是 3 。另外,并不是所有的类都需要生成一个 <clinit>()
类构造器。如果这个类没有静态语句块,也不存在静态变量,那么它就不需要类构造器。
对于接口而言,我们不能在其内部编写静态语句块,但是编译器仍然可以为其生成 <clinit>()
方法,但是和类有所不同,如果一个父接口暂时没有被使用,那么就不会先去执行父类的 <clinit>()
方法。
下一个问题是,Java 虚拟机必须保证 <clinit>()
方法被正确地加锁同步,或者称只会有一个线程负责去执行 <clinit>()
方法。而其它线程只能阻塞等待。如果那个线程已经执行完 <clinit>()
方法并退出,那么其它线程被再次唤醒时就不会再去尝试执行同样的 <clinit>()
方法了。
但是,如果这个 <clinit>()
非常耗时,那么也会导致其它调用该类的线程陷入阻塞状态。
import java.util.Random;
public class SuperClass {
static int i = 1;
static {
i = 3;
while (Mutex.lock == 1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Mutex {
public static int lock = 1;
}
class RunApp {
public static void main(String[] args) {
Runnable b = () -> {
while (true) {
try {
// 这意味着每秒 SuperClass 只有 1/32 的概率被成功初始化。
Thread.sleep(1000);
int random = new Random().nextInt(31);
if (random == 16) {
Mutex.lock = 0;
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
new Thread(b).start();
// 在 SuperClass 被成功初始化之前,main 线程会一直阻塞等待。
System.out.println(new SuperClass());
}
}
3. 类加载器
Java 设计团队当时刻意地将类加载的加载 ( Loading ) 阶段放到了 Java 虚拟机的外部去实现,这样,用户程序便能自己决定如何去获取 "指定全限定名类的二进制流"。实现这部分的代码称之为类加载器 ( Class Loader ) 。
3.1 类的相等性
对于任何一个类,都必须要由加载它的类加载器和这个类本身构成在 Java 虚拟机空间内的唯一性。说得通俗一点,即便是同一个 "配方" 经不同的人手中最终做出了 "外观,味道" 都没有任何差异的两个 "甜点",这两个 "甜点" 也不会被称作是相同的。
下面用代码来演示这个过程:
public class Clazz {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try{
// 获取的是 去掉路径名之后的类名.class
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 o = myLoader.loadClass("com.i.classloading.Clazz").newInstance();
Clazz clazz = new Clazz();
// 两个类均是 "com.i.classloading.Clazz"
System.out.println(o.getClass());
System.out.println(clazz.getClass());
// 结果是 false
System.out.println(o instanceof Clazz);
// 无法进行强制转换
Clazz a = (Clazz) o;
}
}
从运行代码所体现的信息来看,现在出现了 两个 同名的 Clazz
类`,并且这两个类之间无法进行强制转换。其原因是一者是由自定义类加载器产生的,一者是由 Java 的应用程序类加载器所加载的。因此这两个类是相互独立的类。
请注意,这是一个逆向的双亲委派模型,因为这个类加载器的逻辑是:当自己无法加载此类时,才通过 super.loadClass(name)
令父类加载器完成它。
3.2 双亲委派模型
对于 Java 虚拟机而言,类加载器只分为两种类加载器:一个是启动类加载器 ( Bootstrap ClassLoader ),这个加载器使用 C++ 语言实现,本身就是 Java 虚拟机的一部分。而其它类加载器则是独立于 Java 虚拟机之外的部分,它们全部继承自 java.lang.ClassLoader
。
而对于 Java 开发者而言,"其他类加载器" 的划分要更细致。对于 JDK 8 时期的 Java 而言,绝大部分应用程序都使用了下列三个类加载器,下面按层级由高到低的顺序来介绍:
- 启动类加载器 ( Bootstrap ClassLoader ):该加载器负责加载
<JAVA_HOME>\lib
,以及-Xbootclasspath
参数指定的路径中存放的,并且可以被 Java 虚拟机内部实别的类库,启动类加载器由 C++ 代码实现,并作为 Java 虚拟机的一部分,因此不可以被 Java 代码直接引用。 如果要使用启动类加载器,在 Java 代码的逻辑上直接使用null
来表示 。
通过下面的代码可以查看哪些是由启动类加载器负责加载的路径:
System.out.println(System.getProperty("sun.boot.class.path").replace(";","\n"));
- 拓展类加载器 ( Extension ClassLoader ):拓展类加载器由 Sun 的 ExtClassLoader (
sun.misc.Launcher$ExtClassLoader
) 实现。负责将<JAVA_HOME>\lib\ext
或者Djava.ext.dir
系统变量指定的类库加载到内存中。开发者可以直接使用标准拓展类加载器。
通过下面的代码可以查看哪些是由拓展类加载器负责加载的路径:
System.out.println(System.getProperty("java.ext.dirs").replace(";","\n"));
- 系统类加载器 ( Application ClassLoader,也称用户程序类加载器 ),该类加载器由 Sub 的
AppClassLoader
(sun.misc.Launcher$AppClassLoader
) 实现,负责将用户类路径java -classpath
或者Djava.class.path
系统变量指定的类库加载到内存中。在大部分情况下,用户使用的都是系统类加载器。
通过下面的代码可以查看哪些是由拓展类加载器负责加载的路径:
System.out.println(System.getProperty("java.class.path").replace(";","\n"));
在往下就是由用户实现的,"继承" 于 AppClassLoader
的自定义类加载器,这里我们暂且先不提它,文后给出了一个如何自实现通过网络加载类的例子。
JDK 9 之前的类加载器主要就是由这三个类加载器配合工作的,其工作方式为 双亲委派机制 ( Parents Delegation Model ) 。注:这个 "双亲" 指的是一个类加载器必有一个父加载器 ( Bootstrap ClassLoader 除外) ,因此建立的 "一父一子" 的关系。
下面的代码体现了类加载器之间的继承关系:
// 获取用户类的默认类加载器
ClassLoader c1 = RunApp.class.getClassLoader();
System.out.println(c1.getClass().getName());
// 获取 c1 的父 类加载器
ClassLoader c2 = c1.getParent();
System.out.println(c2.getClass().getName());
// 获取 c2 的父 类加载器
ClassLoader c3 = c2.getParent();
System.out.println(c3);
从打印结果来看,我们可以得证:AppClassLoader < ExtClassLoader < BootstrapClassLoader
。而对于 c3
而言,之前已经提到 Java 代码无法直接获取启动类加载器,因此 c3
引用将是一个空指针 null
。
双亲委派机制,通俗的说,就是当有加载类的需求时,子类加载器总是试图先请求父加载器完成它,对于每一层次的类加载器都是如此,只有父类加载器无法处理时,才会让子类加载器处理。
而双亲委派模型的最大优势是: 维护了 Java 基础组件的安全性和唯一性 。显然,根据上面的描述,所有的类加载请求总是优先传递给启动类加载器去完成。我们又可以很容易推导出:那些 "靠近系统" 或 "靠近底层" 的类,最终一定是由启动类加载器去完成;而那些 "靠近业务" 的类,则通常都由系统类加载器,甚至是用户自定义的类加载器来完成的。
换句话说,Java 开发者永远都做不到通过自定义类加载器去加载由自己编写的 java.lang.Object
( 以及所有其它 java.lang.*
下的内容 )。如果他成功了,这将动摇 Java 类型体系的根基。Java 虚拟机当然非常清楚这一点,因此它对这些内容都施加了内部保护。
而双亲委派模型也未必就是 "铁律",在某些情况下,这个模型会被 "破坏" ,比如我们经常使用的 JDBC 组件。
3.3 双亲委派模型的反例 —— JDBC
这里有必要提及 SPI 机制,Service Provider Interface,中文名称 "服务发现接口",采用的是面向接口编程的思想:先用接口规定输入/输出,然后再决定由哪个具体类给出实现,以此达到 "热插拔" 的部署目的。
使用 SPI 的 JDBC 正是因 "约定先于实现" 而引起双亲委派模型被破坏的例子。Java 库虽然定义了其相关驱动 java.sql.DriverManager
,java.sql.Driver
等组件 ( 均位于 rt.jar
包内,显然 JDK 将它们视作重要的基础组件 ),但这充其量仅仅是 "预留了一些接口" ,并没有给出面向各种 DBA 的详细实现,因为这是各个 DBA 厂商应该考虑的事情 。
鉴于这类 SPI 接口由启动类加载器管理,而它本身却又对此类 "反向注入" 的工作又束手无策,这里引入了一个线程类上下文加载器 ( Thread Context ClassLoader ) 来代替完成,它的工作就是 "令父类加载器回调子类加载器" 。在 JDK 6 之后,通过 java.lang.Thread
线程类的 setContextClassLoader
可以指派这个上下文加载器,在默认情况下,它就是系统类加载器。
说得再直接点,就是原本由 Java 库规定并 "管辖" 的 SPI 将不由启动类加载器完成,而是由线程上下文加载器 ( 它可能是系统类加载器,甚至是自定义类加载器 ) 完成,各厂商也得以对 Java 库的接口交付各自的实现方式,Java 虚拟机也能正确地加载这些实现类。在之前使用 JDBC 时我们通常都要加上下面一行代码:
Class.forName("com.mysql.jbdc.Driver");
它表示将 MySQL 的驱动注册到 Java 库的 java.sql.DriverManager
中去,随后只需要调用 Java 库提供的接口方法就可以实现和指定 DBA 的交互了。
在 JDK 6 版本之后,JDK 又提供了 java.util.ServiceLoader
辅助工具,我们只需要将原先的 com.mysql.jbdc.Driver
配置到 META-INF/services/java.sql.Driver
中 ( 文件名规定要和被实现的 SPI 接口全限定名保持一致 ),在执行 DriverManager.getConnection(...)
获取数据库连接时,Java 虚拟机便可以直接将 MySQL 驱动扫描进去,免除了在源代码中硬编码的步骤 ...... 这种配置方式已经非常贴近 Spring 框架的 IoC 了。
本小节的内容参考自:
[1].SPI 简单案例
[3].什么是 SPI
[拓展] StackMapTable 属性
StackMapTable 属性位于方法表内 Code 属性的属性表当中,供新的类型检查验证器 ( Type Checker ) 使用。其工作机制是将原本运行期中通过分析数据流才能得到的验证类型 ( Verification Type ) 直接记录在 Class 文件上,从而大幅度提高字节码验证的性能。
StackMapTable 属性结构如下表所示:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | number_of_entires | 1 |
stack_map_frame | stack_map_frame_entires | number_of_entires |
StackMapTable 包含了多个栈映射帧 ( Stack Map Frame ) ,每一个栈帧都代表了一个字节码偏移量 ( 对应上文的基本块?) ,表示当线程运行到该处时,局部标量表和操作数栈应该具备的状态供检查验证器验证。
[拓展] 自定义类加载器从网络中获取二进制流
这个例子参考了:深入理解Java类加载器(一):Java类加载原理解析
创建一个类名 NetworkClassLoader
作为自定义类加载器,它应当继承于 ClassLoader
,这里只覆盖必要的 findClass(String name)
方法,我们在其中编写如何通过网络从远程获取字节码并加载类信息 ( 获取其 Class<?>
) ,然后在本地获取一个实例并测试功能是否正常。
该类加载器的预期功能是:首先设置字节码的根 URL ( 假定是 http://hadoop101/
,hadoop101:80 指向笔者本地虚拟机部署的 Nginx server ) ,当传入类名 ( 假定是 com.i.classloading.TargetImpl
) 时,该类加载器能够自动搜寻 http://hadoop101/com/i/classloading/TargetImpl.class
字节码文件,将二进制加载到内存中并返回 Class<?>
实例。
而用于测试的目标,这里创建两个文件,一个是名为 Target
的接口,一个则是它的实现类 TargetImpl
,里面编写了一个简单方法 greet()
。它们的编译文件存放在远程库中的 {root}/com/i/classloading/net
文件夹中。
public interface Target {
void greet();
}
public class TargetImpl implements Target{
public void greet() {
System.out.println("Yes");
}
}
NetworkClassLoader
的逻辑如下:
package com.i.classloading.net;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
public class NetworkClassLoader extends ClassLoader {
private String root;
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public NetworkClassLoader(String root) {
this.root = root;
}
private String toUrl(String root, String classPath){
String replace = classPath.replace('.', '/') + ".class";
return root + replace;
}
@Override
protected Class<?> findClass(String path) throws ClassNotFoundException {
String realUrl = toUrl(root, path);
byte[] classByte = findClassByte(realUrl);
if(classByte == null){
throw new ClassNotFoundException();
}else return defineClass(path,classByte,0,classByte.length);
}
private byte[] findClassByte(String path) {
try {
URL url = new URL(path);
InputStream is = url.openStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = is.read(buffer)) != -1){
baos.write(buffer,0,bytesNumRead);
}
return baos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
主函数的测试方法如下,通过反射机制执行 greet
方法:
String root = "http://hadoop101/";
String classPath = "com.i.classloading.net.TargetImpl";
Class<?> aClass = new NetworkClassLoader(root).findClass(classPath);
Object o = aClass.newInstance();
Method method = aClass.getMethod("greet");
method.invoke(o);
在成功执行时,控制台将打印一个 yes
。或者,我们也可以通过 Target
接口来使用它:
String root = "http://hadoop101/";
String classPath = "com.i.classloading.net.TargetImpl";
Class<?> aClass = new NetworkClassLoader(root).findClass(classPath);
Target target = (Target) aClass.newInstance();
target.greet();
Footnotes
- 字符串常量池,运行时常量池,静态变量在 JDK 6 ~ 8 版本期间经历了一次大迁移。见:方法区元空间实现之jdk7和8字符串常量池、运行时常量池、静态变量到底在哪? ↩