一个类需要经过漫长的旅程才能被虚拟机其他组件,如解释器、编译器、GC等在运行时使用,下面将详细介绍类的一个完整生命周期,即加载、链接、初始化三部曲。
类加载过程先于虚拟机的绝大部分组件的加载过程,具体会在第4章讲解。虚拟机初始化完成后做的第一件事情就是加载用户指定的主类。类加载也是类可用机制的第一步,它负责定位并解析位于磁盘(通常)的字节码文件,生成一个包含残缺数据的用于在JVM内部表示类的数据结构,然后将该结构传递给下一步链接做后续工作。
字节码
Java源码通过javac(Java编译器)编译生成字节码,然后将字节码送入虚拟机运行。字节码是Java源码的一种紧凑的二进制表示,它相对于Java源码来说比较低级,但是更符合机器模型,更容易被机器“理解”。以代码清单2-1的Java代码为例:
代码清单2-1 加法示例源码
public class Foo{
public static void main(String[] args){
int a· = 3;
int b = a+2;
System.out.println(b);
}
}
使用javac编译Foo.java得到二进制字节码文件Foo.class,但二进制的Foo.class难以被人类理解,为了直观地查看编译后的字节码,可以使用JDK中的javap -verbose Foo.class输出人类可读的字节码,部分输出如代码清单2-2所示:
代码清单2-2 加法示例字节码
0: iconst_3
1: istore_1
2: iload_1
3: iconst_2
4: iadd
5: istore_2
6: getstatic#2 // Field Ljava/io/PrintStream;
9: iload_2
10: invokevirtual#3 // Method java/io/PrintStream.println:(I)V
13: return
字节码中的#2表示常量池索引2的位置,后面注释说明了该位置表示被调用的方法,这样后面的字节码可以使用字节索引而不需要表示函数的字符串,在减少冗余的同时节省了空间。可以看到两个变量相加被编译成了栈操作:iconst_3压入3到操作栈,istore_1读取栈顶的3到变量a,然后iload_1读取a并入栈,iconst_2压入2,iadd弹出a和2并将结果入栈,istore_2将刚刚计算得到的结果即栈顶弹出放入b,最后输出。
也正是由于字节码对于源码的描述是栈的形式,所以Java虚拟机属于栈式机器(Stack Machine)。与之相对的是寄存器机器(RegisterMachine),如代码清单2-3所示的Lua字节码,它对上面加法的描述截然不同:
代码清单2-3 luac -l -p生成的加法字节码
-- lua源码
a = 3
b = a + 2
io.write(b)
-- lua字节码
0+ params, 2 slots, 1 upvalue, 0 locals, 6 constants, 0 functions
1 [1] SETTABUP 0 -1 -2 ; _ENV "a" 3
2 [2] GETTABUP 0 0 -1 ; _ENV "a"
3 [2] ADD 0 0 -4 ; - 2
4 [2] SETTABUP 0 -3 0 ; _ENV "b"
5 [3] GETTABUP 0 0 -5 ; _ENV "io"
6 [3] GETTABLE 0 0 -6 ; "write"
7 [3] GETTABUP 1 0 -3 ; _ENV "b"
8 [3] CALL 0 2 1
9 [3] RETURN 0 1
寄存器机器的加法是直接使用add 0 0 -4指令完成的,它的操作数和指令组成一个整体,而栈式机器的iadd没有操作数,它隐式地假设了一个操作数栈,用于存放iadd需要的数据,这是两者的主要区别。寄存器机器和栈式机器很大程度上是指虚拟机指令集(InstructionSet Architecture,ISA)的特点,与虚拟机本身如何实现并无关系。当然,这并不是说寄存器机器就是用寄存器执行指令的虚拟机,事实上,很多寄存器机器都是用数组模拟寄存器执行读写指令的。寄存器机器的指令集更紧凑,性能也可能更好;栈式机器的指令集易于编译器生成,两者各有千秋,并无绝对优势的一方。
类加载器
在了解了Java字节码的基本概念后,就可以步入类可用机制的世界了。前面提过,javac编译器编译得到字节码,然后将字节码送入虚拟机执行。实际上送入虚拟机的字节码并不能立即执行,它与视频文件、音频文件一样只是一串二进制序列,需要虚拟机加载并解析后才能执行,这个过程位于ClassLoader::load_class()。
ClassLoader是虚拟机内部使用的类加载器,即Bootstrap类加载器。
除了Bootstrap类加载器外,HotSpot VM还有Platform类加载器和Application类加载器,它们三个依次构成父子关系(不是代码意义上由承构造出来的父子关系,而是逻辑上的父子关系)。虚拟机使用双亲委派机制加载类。当需要加载类时,首先使用Application类加载器加载,由Application类加载器将这个任务委派给Platform类加载器,而Platform类加载器又将任务委派给Bootstrap类加载器,如果Bootstrap类加载器加载完成,那么加载任务就此终止。如果没有加载完成,它会将任务返还给Platform类加载器等待加载,如果Platform类加载器也无法加载则又会将任务返还给Application类加载器加载。每个类加载器对应一些类的搜索路径,如果所有类加载器都无法完成类的加载,则抛出ClassNotFoundException。双亲委派加载模型避免了类被重复加载,而且保证了诸如java.lang.Object、java.lang.Thread等核心类只能被Bootstrap类加载器加载。
在HotSpot VM中用ClassLoader表示类加载器,可以使用ClassLoader::load_class()加载磁盘上的字节码文件,但是类加载器的相关数据却是存放在ClassLoaderData,简称CLD。源码中很多CLD字样指的就是类加载器的数据。每个类加载器都有一个对应的CLD结构,这是一个重要的数据结构,如图2-1所示。
CLD存放了所有被该ClassLoader加载的类、当前类加载器的Java对象表示、管理内存的metaspace等。另外CLD还指示了当前类加载器是否存活、是否需要卸载等。除此之外,CLD还有一个next字段指向下一个CLD,所有CLD连接起来构成一幅CLD图,即ClassLoaderDataGraph。
通过调用 ClassLoaderDataGraph::classes_do可以在垃圾回收过程中很容易地遍历该结构找到所有类加载器加载的所有类。
文件解析
ClassLoader::load_class()负责定位磁盘上字节码文件的位置,读取该文件的工作由类文件解析器ClassFileParser完成,如代码清单2-4所示:
代码清单2-4 类文件解析器
void ClassFileParser::parse_stream(...) {
// 开始解析
stream->guarantee_more(8, CHECK);
// 读取字节码文件开头的魔数,即0xcafebabe
const u4 magic = stream->get_u4_fast();
guarantee_property(magic == JAVA_CLASSFILE_MAGIC,...);
// 读取major/minor版本号
_minor_version = stream->get_u2_fast();
_major_version = stream->get_u2_fast();
// 读取常量池
...
// 读取this_class和super_class
_this_class_index = stream->get_u2_fast();
Symbol* class_name_in_cp = cp->klass_name_at(_this_class_index);_class_name = class_name_in_cp;
...
}
Java所有的类最终都继承自Object类,每个类的常量池都会包含诸如“[java/lang/Object;”的字符串。为了节省内存,HotSpot VM用Symbol唯一表示常量池中的字符串,所有Symbol统一存放到SymbolTable中。
SymbolTable是一个并发哈希表,虚拟机会根据该表中Symbol的哈希值判断是返回已有的Symbol还是创建新的Symbol。
SymbolTable有个特别的地方:它使用引用计数管理Symbol。如果两个类常量池都包含字符串“hello world”,当两个类都卸载后该Symbol计数为0,且下一次垃圾回收的时候不会做可达性分析,而是直接清除。
在HotSpot VM中,SymbolTable还有个孪生兄弟StringTable。
StringTable这个名字可能比较陌生,但是读者一定见过String.intern(),如代码清单2-5所示,String.intern()底层依托的正是StringTable:
代码清单2-5 java.lang.String.intern()的实现
JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str))
JVMWrapper("JVM_InternString");
JvmtiVMObjectAllocEventCollector oam;
if (str == NULL) return NULL;
oop string = JNIHandles::resolve_non_null(str);
oop result = StringTable::intern(string, CHECK_NULL);return (jstring) JNIHandles::make_local(env, result);
JVM_END
String.intern()会返回一个字符串的标准表示。所谓标准表示是指对于相同字符串常量会返回唯一内存地址。StringTable则是用来存放这些标准表示的字符串的哈希容器。它没有使用引用计数管理,是众多类型的GC Root之一,在垃圾回收过程中会被当作根,以它为起点出发进行标记。虚拟机用户可以使用参数-XX:+ PrintStringTableStatistics在虚拟机退出时输出StringTable和SymbolTable的统计信息,或者使用jcmd <pid>VM.stringtable在运行时输出相关信息。
回到源码的解析上,这个过程比较简单,按照如代码清单2-6所示的Java虚拟机规范中规定的字节码文件格式读取对应字节即可:
代码清单2-6 字节码文件格式
ClassFile {
u4 magic; // 字节码文件魔数,0xcafebabe
u2 minor_version; // 主版本号
u2 major_version; // 次版本号
u2 constant_pool_count; // 常量池大小
cp_info constant_pool[constant_pool_count-1]; // 常量池
u2 access_flags; // 该类是否public,是否final
u2 this_class; // 当前类在常量池的索引号
u2 super_class; // 父类在常量池的索引号
u2 interfaces_count; // 接口个数u2 interfaces[interfaces_count]; // 接口
u2 fields_count; // 字段个数
field_info fields[fields_count]; // 字段
u2 methods_count; // 方法个数
method_info methods[methods_count]; // 方法
u2 attributes_count; // 属性信息,比如是否内部类
attribute_info attributes[attributes_count]; // 属性
}
Java虚拟机规范要求字节码文件遵循大端序,并且要求字节码文件最开始四个字节是魔数0xcafebabe,接下来两个字节是主版本号等。类文件解析器根据Java虚拟机规范以大端的方式读取四个字节并检查其是否为正确的魔数,然后检查主版本号,如此继续即可。
类加载的最终任务是得到InstanceKlass对象。当parse_stream()解析完二进制的字节码文件后,由类加载器为InstanceKlass分配所需内存,然后使用fill_instance_klass()结合解析得到的数据填充这片内存。
InstanceKlass是HotSpot VM中一个非常重要的数据结构,java.lang.Class在Java层描述对象的类,而InstanceKlass在虚拟机层描述对象的类,它记录类有哪些字段,名字是什么,类型是什么,类名是什么,解释器如何执行它的方法等信息,关于InstanceKlass会在第3章详细讨论。类加载的一个完整流程如下:
1)分配InstanceKlass所需内存
(InstanceKlass::allocate_instance_klass);
2)使用parse_stream()得到的数据填充InstanceKlass的字段,如major/minor version;
3)如果引入了miranda方法,设置对应
flag(set_has_miranda_methods);
4)初始化itable( klassItable::setup_itable_offset_table);
5)初始化OopMapBlock(fill_oop_maps);
6)分配klass对应的java.lang.Class,在Java层描述类
(java_lang_Class::create_mirror);
7)生成Java8的default方法
(DefaultMethods::generate_default_methods);
8)得到完整的InstanceKlass。
类加载得到InstanceKlass后,此时的InstanceKlass虽然有了类的字段、字段个数、类名、父类名等信息,但是还不能使用,因为有些关键信息仍然缺失。HotSpot VM的执行模式是解释器与JIT编译器混合的模式,当一个Java方法/循环被探测到是“热点”,即执行了很多次时,就可能使用JIT编译器编译它然后从解释器切换到执行后的代码再执行它。
那么,如何让方法同时具备可解释执行、可执行编译后的机器代码的能力呢?HotSpot VM的实现是在方法中放置解释器、编译器的入口地址,需要哪种模式就进入哪种入口。
第二个问题,在哪里设置这些入口呢?结合类的实现过程,在前面的类加载中没有提到,而后面的类初始化会执行代码,说明在执行代码时入口已设置,即它们是在类链接阶段设置的。类链接源码位于 InstaceKlass::link_class_impl(),源码很长,主要有5个步骤:
1)字节码验证(verify_code);
2)字节码重写(rewrite_class);
3)方法链接(link_method);
4)初始化vtable(虚表)和itable(接口表);
5)链接完成(set_init_state)。
字节码验证
字节码验证可以确保字节码是结构性正确的。举个例子,if_icmpeq字节码判断两个整数是否相等并根据结果做跳转,结构性正确就是指跳转位置必须位于该方法内这一事实。又比如,一个方法返回boolean、byte、char、short、int中的任意一种类型,那么结构性正确要求该方法的返回字节码必须是ireturn而不能是freturn、lreturn等。字节码验证的代码位于classfile/verifier.cpp,它是一个对于程序安全运行很重要但是对于源码分析又不必要的部分,感兴趣的读者请对照verifier源码和Java虚拟机文档4.9、4.10节(关于结构性正确的一些要求)阅读。
字节码重写
字节码重写器(Rewritter)位于interpreter/rewriter.cpp,它实现了如下功能。
1. finalize方法重写
本章最开始使用javap反编译了类Foo的字节码,其中包括Foo构造函数。Foo的构造函数默认调用Object构造函数,Object构造函数只有固定的三条字节码:aload0, invokespecial,return。
当某个类重写了Object.finalize()方法时,在运行时,最后一条字节码return会被重写器重写为 _return_register_finalizer。这是一条非标准的字节码,在Java虚拟机规范中没有要求,是虚拟机独有的字节码,如果虚拟机在执行时发现是非标准的_return_register_finalizer,则会额外执行很多代码(代码清单2-7):插入机器指令判断当前方法是否重写finalize,如果重写,则经过一个很长的调用链,最终调用java.lang.ref.Finalizer的register()。
代码清单2-7 重写finalize额外需要执行的代码
instanceOop InstanceKlass::register_finalizer(...) {
instanceHandle h_i(THREAD, i); JavaValue result(T_VOID);
JavaCallArguments args(h_i);
// 对应java.lang.ref.Finalizer的register方法(该类为package-private)
methodHandle mh (THREAD, Universe::finalizer_register_method());
JavaCalls::call(&result, mh, &args, CHECK_NULL);
return h_i();
}
register()会将重写了finalize()的对象放入一个链表,等待后面垃圾回收对链表每个对象执行finalize()方法。
2. switch重写
重写器还会优化switch语句的性能。它根据switch的case个数是否小于-XX:BinarySwitchThreshold[1](默认5)选择线性搜索switch或者二分搜索switch。线性搜索可以在线性时间内定位到求值后的case,二分搜索则保证在最坏情况下,在O(logN)内定位到case。switch重写使用的二分搜索算法如代码清单2-8所示:
代码清单2-8 二分搜索伪代码(Edsger W. Dijkstra, W.H.J. Feijen)
int binary_search(int key, LookupswitchPair* array, int n) {
int i = 0, j = n;
while (i+1 < j) {
int h = (i + j) >> 1;
if (key < array[h].fast_match())
j = h;
else
i = h;
}
return i;
}
方法链接
方法链接是链接阶段乃至整个类可用机制中最重要的一步,它直接关系着方法能否被虚拟机执行。本节从方法在虚拟机中的表示开始,详细描述方法链接过程。
1. Method数据结构
OpenJDK 8以后的版本是用Method这个数据结构,在JVM层表示Java方法,位于oops/method.cpp,里面包含了解释器、编译器的代码入口和一些重要的用于统计方法性能的数据。“HotSpot”的中文意思是“热点”,指的是它能对字节码中的方法和循环进行Profiling性能计数,找出热点方法或循环,并对其进行不同程度的优化。这些Profiling数据就存放在MethodData和MethodCounter中。热点探测与方法编译是一个复杂又有趣的过程,虚拟机需要回答什么程度才算热点、单个循环如何优化等,这些内容将在本书的第二部分详细讨论。
Method另一个重要的字段是_intrinsic_id。如果某方法的实现广为人知,或者某方法另有高效算法实现,对于它们,即便使用JIT编译性能也达不到最佳。为了追求极致的性能,可以将这些方法视作固有方法(Intrinsic Method)或者知名方法(Well-known Method),解放CPU指令集中所有支持的指令,由虚拟机工程师手写它们的实现。_intrinsic_id表示固有方法的id,如果该id有效,即该方法是固有方法,即便方法有对应的Java实现,虚拟机也不会走普通的解释执行或者编译Java方法,而是直接跳到该方法对应的手写的固有方法实现例程并执行。
所有固有方法都能在classfile/vmSymbols.hpp中找到,一个绝佳的例子是java.lang.Math。对于Math.sqrt(),用Java或者JNI均无法达到极致性能,这时可以将其置为固有方法,当虚拟机遇到它时只需要一条CPU指令fsqrt(代码清单2-9),用硬件级实现碾压软件级算法:
代码清单2-9 Math.sqrt固有方法实现
// 32位:使用x87的fsqrt
void Assembler::fsqrt() {
emit_int8((unsigned char)0xD9);
emit_int8((unsigned char)0xFA);}
// 64位:使用SSE2的sqrtsd
void Assembler::sqrtsd(XMMRegister dst, XMMRegister src) {
...
int encode = simd_prefix_and_encode(...);
emit_int8(0x51);
emit_int8((unsigned char)(0xC0 | encode));
}
2. 编译器、解释器入口
Method的其他数据字段会在后面陆续提到,目前方法链接需要用到的数据只是图2-2右侧的各个入口地址,具体如下所示。
有了上面的知识,方法链接的源码就很容易理解了。如代码清单2-10所示,链接阶段会将i2i_entry和_from_interpreter_entry都指向解释器入口,另外还会生成c2i适配器,将_from_compiled_entry也适配到解释器:
代码清单2-10 方法链接实现
void Method::link_method(...) {
// 如果是CDS(Class Data Sharing)方法
if (is_shared()) {
address entry = Interpreter::entry_for_cds_method(h_method);
if (adapter() != NULL) {
return;
}
} else if (_i2i_entry != NULL) {
return;
}
// 方法链接时,该方法肯定没有被编译(因为没有设置编译器入口)if (!is_shared()) {
// 设置_i2i_entry和_from_interpreted_entry都指向解释器入口
address entry = Interpreter::entry_for_method(h_method);
set_interpreter_entry(entry);
}
...
// 设置_from_compiled_entry为c2i适配器入口
(void) make_adapters(h_method, CHECK);
}
各种入口的地址不会是一成不变的,当编译/解释模式切换时,入口地址也会相应切换,如从解释器切换到编译器,编译完成后会设置新的_code、_from_compiled_entry和_from_interpreter_entry入口;如果发生退优化(Deoptimization),从编译模式回退到解释模式,又会重置这些入口。关于入口设置的具体实现如代码清单2-11所示:
代码清单2-11 编译器/解释器入口的设置
void Method::set_code(...) {
MutexLockerEx pl(Patching_lock, Mutex::_no_safepoint_check_flag);
// 设置编译好的机器代码
mh->_code = code;
...
OrderAccess::storestore();
// 设置解释器入口点为编译后的机器代码
mh->_from_compiled_entry = code->verified_entry_point();OrderAccess::storestore();
if (!mh->is_method_handle_intrinsic())
mh->_from_interpreted_entry = mh->get_i2c_entry();
}
void Method::clear_code(bool acquire_lock /* = true */) {
MutexLockerEx pl(...);
// 清除_from_interpreted_entry,使其再次指向c2i适配器
if (adapter() == NULL) {
_from_compiled_entry = NULL;
} else {
_from_compiled_entry = adapter()->get_c2i_entry();
}
OrderAccess::storestore();
// 将_from_interpreted_entry再次指向解释器入口
_from_interpreted_entry = _i2i_entry;
OrderAccess::storestore();
// 取消指向机器代码
_code = NULL;
}
3. C2I/I2C适配器
在上述代码中多次提到c2i、i2c适配器,如图2-3所示。所谓c2i是指编译模式到解释模式(Compiler-to-Interpreter),i2c是指解释模式到编译模式(Interpreter-to-Compiler)。由于编译产出的本地代码可能用寄存器存放参数1,用栈存放参数2,而解释器都用栈存放参数,需要一段代码来消弭它们的不同,适配器应运而生。它是一段跳床(Trampoline)代码,以i2c为例,可以形象地认为解释器“跳入”这段代码,将解释器的参数传递到机器代码要求的地方,这种要求即调用约定(Calling Convention),然后“跳出”到机器代码继续执行。
如图2-3所示,两个适配器都是由 SharedRuntime::generate_i2c2i_adapters生成的,该函数会在里面进一步调用geni2cadapter()生成i2c适配器。由于代码较多,这里只以i2c适配器的生成为例(见代码清单2-12),对c2i适配器感兴趣的读者可自行反向分析。
代码清单2-12 i2c入口适配器生成
void SharedRuntime::gen_i2c_adapter(...) {
// 将解释器栈顶放入rax
__ movptr(rax, Address(rsp, 0));
...
// 保存当前解释器栈顶到saved_sp__ movptr(r11, rsp);
if (comp_args_on_stack) { ... }
__ andptr(rsp, -16);
// 将栈顶压入栈作为返回值,本地代码执行完毕后返回解释模式,即使用这个地址
__ push(rax);
const Register saved_sp = rax;
__ movptr(saved_sp, r11);
// 获取本地代码入口放入r11,这是解释执行到本地代码执行的关键步骤
__ movptr(r11,...Method::from_compiled_offset());
// 从右向左逐个处理位于解释器的方法参数
for(int i = 0; i < total_args_passed; i++) {
// 如果参数类型是VOID,就不将该参数从解释器栈转移到编译后的代码执行的栈
if (sig_bt[i] == T_VOID) {
continue;
}
// 获取解释器方法栈最右边参数偏移到ld_off
int ld_off = ...;
// 获取解释器方法栈最右边的前一个参数偏移到next_off
int next_off = ld_off - Interpreter::stackElementSize;
// r_1和r_2都表示32位,组合起来构成一个VMRegPair表示64位。如果是
// 64位则r_2无效,所以下面代码的r_2->is_valid()相当于判断是否为64位
VMReg r_1 = regs[i].first();
VMReg r_2 = regs[i].second();
if (!r_1->is_valid()) { continue; }
// 如果本地代码执行栈要求解释器栈参数放到栈中
if (r_1->is_stack()) {
// 获取本地代码执行栈距栈顶偏移int st_off = ...;
// 用r13做中转,将解释器栈参数放入r13,再移动到本地代码执行栈
if (!r_2->is_valid()) {
__ movl(r13, Address(saved_sp, ld_off));
__ movptr(Address(rsp, st_off), r13);
} else {
// 这里表示32位,一个槽放不下long和double
…
}
}
// 如果本地代码执行栈要求解释器栈参数放到通用寄存器中
else if (r_1->is_Register()) {
Register r = r_1->as_Register();
// 寄存器直接执行mov命令即可,不需要r13中转
if (r_2->is_valid()) {
const int offset = ...;
__ movq(r, Address(saved_sp, offset));
} else {
__ movl(r, Address(saved_sp, ld_off));
}
}
else { // 如果本地代码执行栈要求解释器栈参数放到XMM寄存器中
if (!r_2->is_valid()) {
__ movflt(r_1->as_XMMRegister(),...);
} else {
__ movdbl(r_1->as_XMMRegister(), ...);
}}
}
...
// r11保存了本地代码入口,所以跳到r11执行本地代码
__ jmp(r11);
}
适配器的逻辑清晰,但是由于使用了类似汇编的代码风格,看起来比较复杂。可以这样理解适配器:想象有一个解释器方法栈存放所有参数,然后有一个本地方法执行栈和寄存器,如图2-4所示,适配器要做的就是将解释器执行栈的参数传递到本地方法执行栈和寄存器中。
4. CDS
最后,方法链接还有个细节:在设置入口前,它会区分该方法是否是CDS(Class Data Sharing,类数据共享)方法,并据此设置不同的解释器入口。
CDS是JDK5引入的特性,它把最常用的类从内存中导出形成一个归档文件,在下一次虚拟机启动可使用mmap/MapViewOfFile等函数将该文件映射到内存中直接使用而不再加载解析这些类,以此加快Java程序启动。如果有多个虚拟机运行,还可以共享该文件,减小内存消耗。
但是CDS只允许Bootstrap类加载器加载类共享文件,适用场景非常有限,所以JEP 310于Java 10引入了新的AppCDS(Application ClassData Sharing,应用类数据共享),让Application类加载器和Platform类加载器甚至自定义类加载器也能拥有CDS。
AppCDS对于快速启动、快速执行、立即关闭的应用程序有不错的效果,使用代码清单2-13的命令可以开启AppCDS:
代码清单2-13 使用AppCDS
$java -Xshare:off -XX:DumpLoadedClassList=class.lit HelloWorld
$java -Xshare:dump -XX:SharedClassListFile=class.list -XX:SharedArchiveFile=hello.jsa HelloWorld
$java -Xshare:on -XX:SharedArchiveFile=hello.jsa HelloWorld
AppCDS并不是故事的全部,它虽然可以导出更多类,但是使用比较麻烦,需要三步:
1)运行第一次,生成类列表;2)运行第二次,根据类列表从内存中导出类到归档文件;
3)附带着归档文件运行第三次。
为此,JEP 350于Java 13引入了DynamicCDS,它可以消除AppCDS的第一步,在第一次运行程序退出时将记录了本次运行加载的CDS没有涉及的类自动导出到归档文件,第二次直接附带归档文件运行即可。
类可用三部曲的最后一步是类初始化。《Java虚拟机规范》的第5章对初始化流程有非常详尽的描述,指出整个类的初始化流程有12步。
1)获取类C的初始化锁LC。
2)如果另外一个线程正在初始化C,那么释放锁LC,阻塞当前线程,直到另一个线程初始化完成。
3)如果当前线程正在初始化C,那么释放LC。
4)如果C早已初始化,不需要做什么,那么释放LC。
5)如果C处于错误的状态,初始化不可能完成,则释放LC并抛出NoClassDefFoundError。
6)否则,标示当前线程正在初始化C,释放LC。然后初始化每个final static常量字段,初始化顺序遵照代码写的顺序。
7)下一步,如果C是类而不是接口,初始化父类和父接口。
8)下一步,查看C的断言是否开启。
9)下一步,执行类或者接口的初始化方法。
10)如果初始化完成,那么获取锁LC,标示C已经完全初始化,通知所有等待的线程,然后释放LC。
11)否则,初始化一定会遇到类问题,抛出异常E。如果类E是Error或者它的子类,那么创建一个 ExceptionInitializationError对象,将E作为参数,然后用该对象替代下一步的E。如果因为OutOfMemoryError原因不能创建ExceptionInitializationError实例,则使用OutOfMemoryError实例作为下一步E的替代品。
12)获取LC,标示C为错误状态,通知所有线程,然后释放LC,以上一步的E作为本步的终止。
为了通用性和抽象性,可能《Java虚拟机规范》在语言描述方面比较学究。要想直观了解类初始化过程,可以阅读 InstanceKlass::initialize_impl()源码实现。HotSpot VM几乎是按照Java虚拟机规范要求的步骤进行的,只是看起来更简单明了。不难看出,上面步骤很多都是为了处理错误和异常情况,真正意义上的初始化其实是第9步,如代码清单2-14所示:
代码清单2-14 类初始化
void InstanceKlass::initialize_impl(TRAPS) {
...
// Step 8 (虚拟机文档的第9步对应源码第8步,因为源码省略了文档第8步的处理)
call_class_initializer(THREAD);
}
void InstanceKlass::call_class_initializer(TRAPS) {
// 如果启用了编译重放则跳过初始化
if (ReplayCompiles && ...){
return;
}// 获取初始化方法,包装成一个methodHandle
methodHandle h_method(THREAD, class_initializer());
// 调用初始化方法
if (h_method() != NULL) {
JavaCallArguments args; // <clinit>无参数
JavaValue result(T_VOID);
JavaCalls::call(&result, h_method, &args, CHECK);
}
}
类初始化首先会判断是否开启了编译重放(Replay Compile)。使用“-XX:CompileCommand=option,ClassName::MethodName,DumpInline”可以将一个方法的编译信息存放到文件,这样就可以在下一次运行时使用-XX:+ReplayCompiles -XX:ReplayDataFile=file从文件读取编译数据,并创建编译任务投入编译队列,然后进入阻塞状态,在编译完成后继续执行程序。这种“第一次运行存放编译任务→第二次运行获取编译任务→第二次执行编译”的过程就是编译重放。
编译重放固定了编译顺序,而固定的编译顺序减少了虚拟机的不确定性,可用于JIT编译器性能数据分析和GC性能数据分析等场景。除此之外,虚拟机参数 -XX:ReplaySuppressInitializers=<val>的值还可以控制类初始化行为:
0:不做特殊处理;
1:将所有类初始化代码视为空;
2:将所有应用程序类初始化代码视为空;
3:允许启动时运行类初始化代码,但是在重放编译时忽略它们。
处理了编译重放后,虚拟机会调用class_initializer()函数,该函数返回当前类的<clinit>方法。类的构造函数和静态代码块在虚拟机中有特殊的名字,前者是<init>,后者则是<clinit>。静态代码块如代码清单2-15所示。
代码清单2-15 静态代码块
public class ClinitTest{
private static int k;
private static Object obj = new Object();
static{
k = 12;
}
public static void main(String[] args){
new ClinitTest();
}
}
对于代码清单2-15,Java编译器会将静态字段的初始化代码也放入<clinit>,所以字段k和字段obj的赋值都是在类初始化阶段完成的,也正是因为赋值操作需要真实的执行代码,所以需要在链接阶段提前设置解释器入口,以便初始化代码的执行。在确认class_initializer()返回的当前类的<clinit>方法存在后,虚拟机会将其包装成methodHandle送入JavaCalls::call执行。
虚拟机和Java沟通的两座桥梁是JNI和JavaCalls,Java层使用JNI进入JVM层,而JVM层使用JavaCalls进入Java层。JavaCalls可以在HotSpotVM中调用Java方法,main方法执行也是使用这种JavaCalls实现的。关于JavaCalls在第4章会详细讨论。
加载、链接、初始化是标准的类可用机制,除此之外,Java提供了一个用于特殊场景的类重定义功能,由JDK 5引入的 java.lang.instrument.Instrumentation实现。
Instrumentation可以在应用程序运行时修改或者增加类的字节码,然后替换原来的类的字节码,这种方式又称为热替换,如代码清单2-16所示:
代码清单2-16 Num类重定义
// Num.java
public class Num {
public int getNum() { return 3; }
}
// java -javaagent:AgentMain.jar ...
import java.lang.instrument.Instrumentation;
public class AgentMain {
public static void premain(String args, Instrumentation inst) {
inst.addTransformer((loader, className, classBeingRedefined,
protectionDomain, byteCode) -> {
// 修改Num.getNum()的字节码,使它返回1
if("Num".equals(className)){ byteCode[261] = 4; }
return byteCode;
});try {
inst.retransformClasses(Num.class);
} catch (UnmodifiableClassException e) {
e.printStackTrace();
}
}
}
在这段代码中,AgentMain首先添加了类字节码转换器,然后触发Num类的转换。这时会调用之前添加的类字节码转换器,在上面的例子中,转换器将修改Num.getNum的代码,使它返回整数1。然后每当需要加载一个类时,虚拟机会检查类是否为Num类,如果是则修改它的字节码。如果将Instrumentation与asm、cglib、Javaassist等字节码增强框架结合使用,开发者可以灵活地在运行时修改任意类的方法实现,这样无须修改源代码,也无须重编译运行就能改变方法的行为,达到近似热更新的效果。
注意,如果类字节码转换器没有修改字节码,正确的做法是返回null,如果修改了字节码,应该创建一个新的byte[]数组,将原来的byteCode复制到新数组中,然后修改新数组,而不是像代码清单2-16一样修改原有的byteCode再返回。这样直接修改byteCode可能会造成虚拟机崩溃的情况。
Instrumentation的底层实现是基于JVMTI(Java虚拟机工具接口)的RedefineClasses。虚拟机创建VM_RedefineClasses,投递给VMThread,然后等待VMThread执行 VM_RedefineClasses::redefine_single_class重定义一个类。类的重定义是一个烦琐的过程,它会移除原来类(the_class)中的所有断点,所有依赖原来类的编译后的代码都需要进行退优化,原来类的方法、常量池、内部类、虚表、接口表、调试信息、版本号、方法指纹等数据也会一并被替换为新的类定义(scratch_class)中的数据。
本章从2.1节开始,介绍了位于磁盘的二进制表示的字节码被类文件解析器加载并解析,得到虚拟机内部用于表示类的InstanceKlass数据结构。为了保证字节码是安全可靠的,在2.2节链接阶段,首先验证了字节码的结构正确性;出于性能考虑,链接阶段还可能调用重写器将一些字节码替换为高性能的版本,加快后面的解释执行;链接阶段的核心工作是设置编译器/解释器入口以便后续代码能够正常执行,同时为了保障后续解释/编译模式的切换,还会设置适配器来消除两种模式之间的沟壑。接着,根据《Java虚拟机规范》中赋予类初始化的语义,在2.3节介绍了初始化阶段同时执行用户的静态代码块和隐式静态字段初始化。最后2.4节特别讨论了类的重定义。
本文就是愿天堂没有BUG给大家分享的内容,大家有收获的话可以分享下,想学习更多的话可以到微信公众号里找我,我等你哦。