一线开发技术官带你深度解析探讨模板解释器,解释器的生成

1496 篇文章 10 订阅
1494 篇文章 14 订阅

解释器生成

解释器的机器代码片段都是在
TemplateInterpreterGenerator::generate_all()中生成的,下面将分小节详细展示该函数的具体细节,以及解释器某个组件的机器代码生成过程与逻辑。与第4章不同的是,本节中的各部分出现的顺序与它们在代码中的顺序不一致。

在研究解释器前了解调试手段是有必要的。由于运行时生成的机器代码是人类不可读的二进制形式,要想阅读它们,可以下载hsdis-amd64插件,并将该插件放到编译后的JDK中的/lib/server目录下面,然后开启虚拟机参数-XX:+PrintAssembly和-XX:+PrintInterpreter,然后便可输出解释器各个例程的机器代码的汇编表示形式了。也可以开启-XX:+TraceBytecodes跟踪解释器正在执行的字节码和对应方法。

普通方法入口

在第4章提到,JavaCalls::call_helper进入_call_stub_entry会创建Java栈帧(见图4-10),然后进入entry_point执行方法。entry_point即第2章中类链接阶段设置的解释器入口。实际上,解释器入口和_call_stub_entry一样,也是一段机器代码,也是在虚拟机初始化时动态生成的,只是生成它的是generate_normal_entry,如图5-4所示。

图5-4 entry_point解释器入口

entry_point的完整逻辑如图5-4左下角所示,它的详细过程比较复杂,但很值得一探究竟。代码清单5-7展示了解释器入口entry_point的(生成)代码。

代码清单5-7 普通方法入口

address TemplateInterpreterGenerator::generate_normal_entry(...) {
... // ebx存放了指向Method的指针
// 获取参数个数,放入rcx
__ movptr(rdx, constMethod);
__ load_unsigned_short(rcx, size_of_parameters);
// 获取局部变量个数
__ load_unsigned_short(rdx, size_of_locals);
__ subl(rdx, rcx);
// 检查栈上是否可以容纳即将分配的局部变量槽generate_stack_overflow_check();
// 获取返回地址
__ pop(rax);
// 计算第一个参数的地址
__ lea(rlocals, ...);
{// 分配局部变量槽,然后初始化这些槽
Label exit, loop;
__ testl(rdx, rdx);
__ jcc(Assembler::lessEqual, exit);
__ bind(loop);
__ push((int) NULL_WORD);// 用0初始化,局部变量有默认值便是因为它
__ decrementl(rdx);
__ jcc(Assembler::greater, loop);
__ bind(exit);
}
// 创建解释器栈帧(有别于Java栈帧)
generate_fixed_frame(false);
// 从当前位置到对方法加锁还有一段距离,万一中间发生异常且方法又是同步方法,则异常处理器
// 会unlock一个未lock的方法,所以这里需要告诉线程不能unlock
NOT_LP64(__ get_thread(thread));
const Address do_not_unlock_if_synchronized(..);
__ movbool(do_not_unlock_if_synchronized, true);
__ profile_parameters_type(rax, rcx, rdx);
// 执行字节码前增加方法调用计数,如果计数到达一定值即跳到后面通知编译器
...
if (inc_counter) {
generate_counter_incr(...);}
// 设置编译后继续执行的地方
Label continue_after_compile;
__ bind(continue_after_compile);
// 检查geneate_fixed_frame分配之后是否栈溢出
bang_stack_shadow_pages(false);
// 对方法加锁,并通知线程可以unlock方法了(因为可能抛出异常的区域已经没了)
NOT_LP64(__ get_thread(thread));
__ movbool(do_not_unlock_if_synchronized, false);
// 如果方法是同步方法,调用lock_method()锁住方法
if (synchronized) {
lock_method();
}
// 将字节码指针设置到第一个字节码,然后开始执行字节码(!)
__ dispatch_next(vtos);
if (inc_counter) {
...
// 如果之前增加调用计数达到一定值,则跳转到此处通知编译器判断是否编译
__ bind(invocation_counter_overflow);
generate_counter_overflow(continue_after_compile);
}
return entry_point;
}

HotSpot VM中有两个地方可能发生“解释器认为Java方法(循环)是热点并通知编译器判断是否编译”这一行为:字节码中的回边(Backedge)分支计数通知(本章后面讨论)与方法调用计数通知。

上面的代码并非指当某个方法/循环超过阈值时立刻进行编译,其中,generate_counter_inc()检查当前方法调用频率是否超过一个通知值(
-XX:TierXInvokeNotifyFreq-Log=<val>,X表示分层编译的层数,该值可根据情况进行调整),generate_counter_overflow通知编译器方法调用频率达到了一定的程度。两者检查在一定时间范围内某个方法调用是否到达了一定的频率,实际上是否编译是根据编译器策略(TieredThreshold Policy)进行抉择的。

方法加锁

普通方法入口中一个重要的内容是方法加锁,即代码清单5-8中所示的lock_method()。

代码清单5-8 方法加锁

void TemplateInterpreterGenerator::lock_method() {
...
{ Label done;
// 检查方法是否为static
__ movl(rax, access_flags);
__ testl(rax, JVM_ACC_STATIC);
// 获取局部变量槽的第一个元素(即receiver)
__ movptr(rax, ...);
__ jcc(Assembler::zero, done);
// 如果是static,加载方法所在类的Class对象__ load_mirror(rax, rbx);
__ bind(done);
// 保持使用receiver
__ resolve(IS_NOT_NULL, rax);
}
// 基本对象锁(BasicObjectLock)包含一个待锁对象和Displaced Header
// 下面会将receiver放入基本对象锁的待锁对象字段
__ subptr(rsp, entry_size);
__ movptr(monitor_block_top, rsp);
__ movptr(Address(rsp, BasicObjectLock),...);
const Register lockreg = NOT_LP64(rdx) LP64_ONLY(c_rarg1);
__ movptr(lockreg, rsp);
// 加锁
__ lock_object(lockreg);
}

HotSpot VM方法加锁的实现和Java语义一致:如果方法是static则对类的Class对象加锁,反之对this对象加锁。在执行lock_object()前需要找到位于解释器栈上的monitor区的基本对象锁,如图5-5所示。

图5-5 解释器栈的布局(左上)

monitor区存放了若干个基本对象锁(Basic Object Lock)。基本对象锁又叫轻量级锁、瘦锁,它包含一个加锁对象和Displaced Header。在获取到加锁对象后,会将加锁对象放入基本对象锁,然后调用lock_object()。lock_object()并不是简单锁住对象,它还会应用一些锁的优化措施:最开始尝试偏向锁,如果加锁失败则尝试基本对象锁,如果仍然失败则会使用重量级锁,具体过程将会在第6章讨论。

本地方法入口

本地方法入口由generate_native_entry()生成,它和普通方法最大的不同是普通方法通过dispatch_next()执行每一条字节码并达到解释的效果,而本地方法本身就是机器代码,可以直接执行。本地方法入口的实现如代码清单5-9所示。代码清单5-9 native方法调用片段

address TemplateInterpreterGenerator::generate_native_entry(...) {
...
// 获取native方法入口,第3章提到过native方法入口在Method后面的第一个槽
{
Label L;
// 获取native入口
__ movptr(rax, ...Method::native_function_offset());
__ cmpptr(rax, unsatisfied.addr());
// 获取失败会调用prepare_native_call以找到native入口和signature handler。
// 查找native入口的过程请参见第3章
__ jcc(Assembler::notEqual, L);
__ call_VM(...InterpreterRuntime::prepare_native_call);
__ get_method(method);
__ movptr(rax, ...Method::native_function_offset());
__ bind(L);
}
// 从线程上拿出JNIEnv并作为第一个参数传入
__ lea(c_rarg0, ...r15_thread);
__ set_last_Java_frame(rsp, rbp, (address) __ pc());
// 设置线程状态位_thread_in_native(即执行native方法)
__ movl(Address(thread, JavaThread::thread_state_offset()),
_thread_in_native);
// 调用native方法__ call(rax);
...
}

generate_native_entry()会先找native方法入口。第3章曾提到该入口位于Method后面的第一个槽,如果获取失败,则调用
InterpreterRuntime::prepare_native_call查找并设置native方法入口和signature handler。查找成功后传递必要参数,然后就可以跳转到本地方法执行了。

本地方法和普通方法的另一个不同之处是对同步方法的处理。

generate_normal_entry()中只使用lock_method()对方法加锁而没有对应的解锁代码,因为dispatch_next()执行字节码时(见5.5.4节),一些字节码(如return、athrow)在移除栈帧的时候会解锁同步方法,所以无须在generate_normal_entry()中解锁。但是generate_native_entry()没有执行字节码,它必须在执行完native方法之后检查是否需要解锁同步方法。

标准字节码

entry_point在准备好解释器栈帧和加锁事项后,会调用dispatch_next()解释执行字节码。作为字节码执行的“发动机”,dispatch_next(),其具体实现如代码清单5-10所示。

代码清单5-10 字节码派发实现

void InterpreterMacroAssembler::dispatch_next(...) {// 加载一条字节码
load_unsigned_byte(rbx, Address(_bcp_register, step));
// 字节码指针(bcp)前进
increment(_bcp_register, step);
// dispatch_next的详细实现
dispatch_base(...);
}

dispatch_next()的实现和前面描述的行为基本一致:读取字节码→前推字节码指针→执行每一条字节码。具体的执行由dispatch_base完成,如代码清单5-11所示。

代码清单5-11 dispatch_base

void InterpreterMacroAssembler::dispatch_base(...) {
// 获取安全点表
address* const safepoint_table = Interpreter::safept_table(state);
Label no_safepoint, dispatch;
// 如果需要生成安全点
if (SafepointMechanism::uses_thread_local_poll() && ...) {
testb(Address(r15_thread,Thread::polling_page_offset()),
SafepointMechanism::poll_bit());
jccb(Assembler::zero, no_safepoint);
lea(rscratch1, ExternalAddress((address)safepoint_table));
jmpb(dispatch);}
// 如果不需要安全点
bind(no_safepoint);
// 获取模板表
lea(rscratch1, ExternalAddress((address)table));
bind(dispatch);
// 跳转到模板表中指定字节码处的机器代码,然后执行
jmp(Address(rscratch1, rbx, Address::times_8));
}

总结来说,dispatch_next相当于一个“发动机”,它能推进字节码指针,从一个模板表(即字节码表)中找到与当前字节码对应的机器代码片段,并跳到该片段执行字节码片段。字节码执行完后还有一段“结尾曲”代码,会再次调用dispatch_next()。整个解释过程就像由dispatch_next()串联起来的链,只要调用并启动dispatch_next()一次,就能执行方法中的所有字节码。详细的过程如图5-6所示。

HotSpot VM的解释器名为模板解释器,它为所有字节码生成对应的机器代码片段,然后全部放入一个表。当VMThread收到一些请求(如垃圾回收任务)并需要安全点时,它会调用
TemplateInterpreter::notice_safepoints()通知模板解释器将普通的模板表切换为安全点表,由HotSpot VM在安全点表中寻找与当前字节码对应的逻辑然后跳到该位置执行字节码指令。如果不需要安全点,VMThread会关闭安全点,并调用TemplateInterpreter::ignore_safepoints(),再从安全点表切换回模板表,然后正常执行相关指令。实际上安全点表相比于模板表只多了一次InterpreterRuntime::at_safepoint调用,这个调用用于处理安全点的逻辑。在了解了字节码执行的方式后,接下来将讨论一些字节码的具体实现。

1. iconst

iconst向栈压入一个整型常量值,如代码清单5-12所示。

代码清单5-12 字节码iconst

void TemplateTable::iconst(int value) {
transition(vtos, itos);
if (value == 0) {
__ xorl(rax, rax);
} else {
__ movl(rax, value);
}
}

iconst的代码并没有压栈操作,它把值放入了rax寄存器,因为相比于寄存器,压栈操作开销较大,模板解释器把rax和xmm0当作栈顶缓存(Top of Stack,ToS),凡是能用ToS解决的绝不用栈。

那么什么是ToS呢?假设两条指令是iconst_1和istore,基于栈的模板解释器首先压入1,然后弹出1,最后保存到局部变量表。这个过程出现了两次栈操作,即内存读写。有了ToS后,模板解释器会将1放入rax寄存器,用istore读取rax寄存器,完全消除了内存读写过程。

为了确保操作的类型正确(如istore要求栈顶为int类型,iconst无须进行栈顶缓存),代码最开始有一个transition(vtos,itos)约束条件,它表示iconst字节码执行前栈顶无缓存,执行后栈顶缓存是int类型。约束条件除了vtos(无缓存)和itos(int),还有btos(byte)、ztos(bool)、ctos(char)、stos(short)、ltos(long)、ftos(float,使用xmm0寄存器)、dtos(double,使用xmm0寄存器)和atos(object)。

2. add、sub、mul、and、or、shl、shr、ushr

由于整数的四则运算和位运算几乎一样,模板解释器统一把它们放到了lop2中进行处理,如代码清单5-13所示。

代码清单5-13 字节码addsubmulandorxorshlshrushr

void TemplateTable::iop2(Operation op) {
transition(itos, itos);
switch (op) {
case add : __ pop_i(rdx); __ addl (rax, rdx); break;
case sub :__ movl(rdx, rax);__ pop_i(rax);__ subl (rax, rdx); break;
case mul : __ pop_i(rdx); __ imull(rax, rdx); break;
case _and: __ pop_i(rdx); __ andl (rax, rdx); break;
case _or : __ pop_i(rdx); __ orl (rax, rdx); break;case _xor: __ pop_i(rdx); __ xorl (rax, rdx); break;
case shl :__ movl(rcx, rax); __ pop_i(rax); __ shll (rax);break;
case shr :__ movl(rcx, rax); __ pop_i(rax); __ sarl (rax);break;
case ushr:__ movl(rcx, rax);__ pop_i(rax); __ shrl (rax);break;
default : ShouldNotReachHere();
}
}

ToS只能缓存一个数据,而加法需要两个操作数,所以第二个参数必须使用栈操作弹出。

3. new

new会真实地在堆上分配对象,然后返回对象引用并压入栈中,如代码清单5-14所示。

代码清单5-14 字节码new

void TemplateTable::_new() {
// new会在创建对象后向栈顶压入对象引用,所以ToS从无变成atos
transition(vtos, atos);
...
// 如果开启-XX:+UseTLAB,则在TLAB中分配对象,否则在Eden区中分配对象,
// 如果分配成功则将对象引用放入rax
if (UseTLAB) {__ tlab_allocate(thread, rax, rdx, 0, rcx, rbx, slow_case);
// 如果开启-XX:+ZeroTLAB
if (ZeroTLAB) {
// 初始化对象头
__ jmp(initialize_header);
} else {
// 初始化对象头和字段
__ jmp(initialize_object);
}
} else {
__ eden_allocate(thread, rax, rdx, 0, rbx, slow_case);
}
// 用0填充对象头和对象字段
...
// 慢速分配路径:调用InterpreterRuntime::_new
__ bind(slow_case);
...
call_VM(rax, ...InterpreterRuntime::_new);
// 分配完成
__ bind(done);
}

new和方法锁类似,在分配前要尽可能使用轻量级操作。它不会直接在堆中分配对象,如果启用-XX:+UseTLAB,它会尝试在线程的TLAB区中分配对象。TLAB(Thread Local Allocation Buffer)是Thread数据结构的一部分。由于TLAB是每个线程私有的存储区域,因此对象分配无须加锁同步。

TLAB有三个指针,分别记录TLAB开始位置、结束位置、当前使用位置。分配新对象时只需根据对象大小移动当前使用的指针,然后调用
ThreadLocalAllocBuffer::allocate()判断是否到达区域结束位置。这种分配方式也叫碰撞指针(Bump the Pointer)。TLAB结合碰撞指针可以快速地进行对象分配。仅当TLAB分配失败时才调用InterpreterRuntime::_new在堆上进行分配,具体分配方式和代码清单3-3所示代码调用MemAllocator::allocate一样,new最终调用特定垃圾回收器的mem_allocate()方法,如Shenandoah GC的ShenandoahHeap::mem_allocate、ZGC的ZCollectedHeap::mem_allocate、Parallel GC的ParallelScavengeHeap::mem_allocate,具体分配细节涉及垃圾回收模块,会在第三部分(第10~11章)详细讨论。

4. iinc

iinc会将某个局部变量的值增加到指定大小。它的字节码占用三个字节,分别对应iinc、index、const。第一个字节表示它是iinc,第二个字节表示局部变量在局部变量表中的索引,第三个字节表示递增大小,如代码清单5-15所示。

代码清单5-15 字节码iinc

void TemplateTable::iinc() {
transition(vtos, vtos); // iinc不改变栈的状态,无须ToS缓存
__ load_signed_byte(rdx, at_bcp(2)); // 获取constlocals_index(rbx); // 获取局部变量
__ addl(iaddress(rbx), rdx); // 局部变量 = 局部变量+const
}

5. arraylength

arraylength获取数组的长度,然后压栈,如代码清单5-16所示。

代码清单5-16 字节码arraylength

void TemplateTable::arraylength() {
transition(atos, itos); // 执行前栈顶是数组引用,执行后栈顶是数组大小
// 空值检查,确保数组有length字段
__null_check(rax, arrayOopDesc::length_offset_in_bytes());
// 获取数组(arrayOop)的length字段,然后放入ToS
__movl(rax,Address(rax,arrayOopDesc::length_offset_in_bytes()));
}

6. monitorenter

monitorenter是Java关键字synchronized的底层实现,它获取栈顶对象,然后对其加锁,如代码清单5-17所示。

代码清单5-17 字节码monitorenter

void TemplateTable::monitorenter() {
transition(atos, vtos);
// 在栈的monitor区中分配一个槽,用来存放基本对象锁
...
__ bind(allocated);
// 字节码指针递增
__ increment(rbcp);
// 将待加锁的对象放入基本对象锁中
__ movptr(Address(rmon, BasicObjectLock::obj_offset_in_bytes()), rax);
// 加锁
__ lock_object(rmon);
// 确保不会因为刚刚栈上分配的槽造成栈溢出
__ save_bcp();
__ generate_stack_overflow_check(0);
// 执行下一条字节码
__ dispatch_next(vtos);
}

monitorenter会在栈的monitor区中分配一个基本对象锁,具体的加锁工作是由代码清单5-12所示的lock_object()完成的。

7. athrow

Java语言的throw关键字反映到JVM上是一个athrow字节码。根据虚拟机规范描述,athrow弹出栈顶的对象引用作为异常对象,然后在当前方法寻找该匹配对象类型的异常处理器。如果找到它则清空当前栈,重新压入异常对象,最后跳转到异常处理器,就像没有发生异常一样;如果没有找到异常处理器,则弹出当前栈帧并恢复到调用者栈帧,然后在调用者栈帧上继续抛出异常,这样相当于将异常继续传播到调用者,如代码清单5-18所示。

代码清单5-18 字节码athrow

void TemplateTable::athrow() {
transition(atos, vtos);
__ null_check(rax);
__ jump(ExternalAddress(Interpreter::throw_exception_entry()));
}

可以看到athrow会跳到抛异常的机器代码片段,该片段由generate_throw_exception()生成,如代码清单5-19所示。

代码清单5-19 抛异常机器代码生成

void TemplateInterpreterGenerator::generate_throw_exception() {
...
// 抛异常入口
Interpreter::_throw_exception_entry = __ pc();
...
// 清空当前栈的一部分__ empty_expression_stack();
// 寻找异常处理器入口地址,如果找到则返回异常处理器入口,否则返回弹出当前栈帧的代码入口
__ call_VM(rdx,
CAST_FROM_FN_PTR(address,
InterpreterRuntime::exception_handler_for_exception),rarg);
// 不管是哪个入口,结果都会放到rax寄存器
__ push_ptr(rdx);
__ jmp(rax); // 跳到入口执行
...
}


exception_handler_for_exception会寻找异常处理器的入口地址,如果没有找到则返回remove_activation_entry入口,该入口和前面的描述一样,会弹出当前栈帧并恢复到调用者的栈帧,然后在调用者栈帧上继续抛出异常。

8. if_icmp<cond> & branch

if_icmp根据条件(大于/等于/小于等于)跳转到指定字节码处,如代码清单5-20所示。

代码清单5-20 字节码if_icmp<cond>

void TemplateTable::if_icmp(Condition cc) {
transition(itos, vtos);Label not_taken;
__ pop_i(rdx);
__ cmpl(rdx, rax); // 栈顶两个整数比较
__ jcc(j_not(cc), not_taken); // 根据条件进行跳转
branch(false, false); // 跳转
__ bind(not_taken); // 不需要跳转
__ profile_not_taken_branch(rax);
}

branch()实现了各种跳转行为,如if_icmp、goto等。前面提到的字节码的回边分支是两个可能触发编译行为的地方之一,而回边的实现就位于branch()。回边是指从当前字节码出发到前面的一条路径,典型的回边是一次循环结束到新条件判断的一条路径,如代码清单5-21所示。

代码清单5-21 回边字节码

0: iconst_0
1: istore_1
2: iload_1
3: iconst_2
4: if_icmpeq 13
7: iinc 1, 1
10: goto 2
13: return

代码清单5-21所示是一个循环语句,其中,goto字节码会向上跳转到第2条字节码,这样向上跳跃的字节码与目标字节码之间形成的就是一条回边。HotSpot VM不但会对热点方法进行性能计数,还会对回边进行性能计数。通过回边可以探测到热点循环。说到回边就不能不提栈上替换,如代码清单5-22所示。

代码清单5-22 JIT并非只是根据方法调用次数进行编译

public static void main(String... args){
int sum = 0;
for (int i=0;i<100000;i++){
int r = sum/2;
int k = r+3;
sum = 5+6/4*2-9 + k + sum;
}
System.out.println(sum);
}

即便解释器通过循环体的回边探测到这是一个热点方法,并对该方法进行了编译,但是main函数只执行一次,被编译后的main方法根本没有执行的机会。为了解决这个问题,需要一种机制:在运行main函数的过程中(而非运行main函数后)使用编译后的方法替代当前执行,这样的机制被称为OSR。OSR用于方法从低层次(解释器)执行向高层次(JIT编译器)执行变换。发生OSR的时机是遇到回边字节码,而回边又是在branch中体现的,如代码清单5-23所示。代码清单5-23 branch实现

void TemplateTable::branch(bool is_jsr, bool is_wide) {
...
if (UseLoopCounter) {
// rax: MethodData
// rbx: MDO bumped taken-count
// rcx: method
// rdx: target offset
// r13: target bcp
// r14: locals pointer
// 检查跳转相对于当前字节码是前向还是后向
__ testl(rdx, rdx);
// 如果是回边跳转就执行下面的计数操作,否则直接跳到dispatch处
__ jcc(Assembler::positive, dispatch);
...
if (TieredCompilation) {
...
// 增加回边计数
__movptr(rcx,Address(rcx, Method::method_counters_offset()));
// 检查回边计数是否到达一定的值,如果是就跳转
__ increment_mask_and_jump(Address(rcx, be_offset), increment,
mask,rax, false, Assembler::zero,
UseOnStackReplacement ? &backedge_counter_overflow : NULL);
} else {
...}
__ bind(dispatch); // [!]正常跳转到目标字节码,然后执行
}
// 加载跳转目标
__ load_unsigned_byte(rbx, Address(rbcp, 0));
// 从目标字节码处开始执行
__ dispatch_only(vtos, true);
if (UseLoopCounter) {
...
// 如果开启栈上替换机制
if (UseOnStackReplacement) {
// 回边计数到达一定值,调用frequency_counter_overflow以通知编译器
__ bind(backedge_counter_overflow);
__ negptr(rdx);
__ addptr(rdx, rbcp);
__ call_VM(..InterpreterRuntime::frequency_counter_overflow);
// rax存放编译结果,为NULL则表示没有编译,否则表示进行了编译,且这次编译的
// 方法即osr nmethod
// 如果进行了编译,则继续执行,否则跳转到dispatch处
__ testptr(rax, rax);
__ jcc(Assembler::zero, dispatch);
// 确保osr nmethod是执行的方法(in_use表示正常方法, not_used表示不可重
//入但可以复活的方法,not_installed表示还在编译,not_entrant表示即将退
// 优化,zombie表示可以被gc,unloaded表示即将转换为zomibe)
__cmpb(Address(rax,nmethod::state_offset()),nmethod::in_use);__ jcc(Assembler::notEqual, dispatch);
// 到这里说明osr nmethod存在且可以使用,即将进行栈上替换(OSR)
__ mov(rbx, rax);
NOT_LP64(__ get_thread(rcx));
// [!]调用OSR_migration_begin将当前解释器栈的数据打包成OSR buffer
call_VM(...SharedRuntime::OSR_migration_begin);
// 将OSR buffer地址放入rax寄存器
LP64_ONLY(__ mov(j_rarg0, rax));
const Register retaddr = LP64_ONLY(j_rarg2) NOT_LP64(rdi);
const Register sender_sp = LP64_ONLY(j_rarg1) NOT_LP64(rdx);
// [!]弹出当前解释器栈
__movptr(sender_sp,Address(rbp, frame::interpreter_frame_sender_sp_offset * wordSize));
__ leave();
__ pop(retaddr);
__ mov(rsp, sender_sp);
__ andptr(rsp, -(StackAlignmentInBytes));
__ push(retaddr);
// [!] 跳到OSR入口,执行编译后的方法。这样一来就成功完成了从低层次执行
// 到高层次执行的转换
__ jmp(Address(rbx, nmethod::osr_entry_point_offset()));
}
}
}

branch会检查目标字节码的位置。如果它位于当前字节码下面(前向跳转),那么不做任何处理,直接跳到dispatch处,加载目标字节码然后调用dispatch_only执行相应代码即可。

如果目标字节码位于当前字节码前面(回边跳转),情况就复杂很多了。假设main方法里面有一个执行10000次的循环,0~5000次时解释执行,5001~10000次时执行编译后的代码,每隔1000次会调用1次
InterpreterRuntime::frequency_counter_overflow。解释器每次执行循环(branch)时都会递增回边计数器,当执行4000次时,回边计数器到达阈值,调用frequency_counter_overflow方法,该方法通知编译器决定是否编译。假设编译器决定异步编译循环,则该函数返回NULL(因为编译过程不是一蹴而就,需要很长时间,frequency_counter_overflow默认不会阻塞,而是等待编译完成),从4001次开始解释器继续解释执行。

在执行到5000次时,回边计数器又到达阈值,


frequency_counter_overflow返回也不为NULL,说明编译完成。此时解释器调用InterpreterRuntime::OSR_migration_begin将解释器栈的局部变量表和基本对象锁打包成一个OSR buffer数组放到堆上并弹出当前的解释器方法栈,最后跳转到已编译的方法入口,从5001次开始执行编译后的机器代码。编译后的机器代码被称为OSR nmethod,它将OSR buffer中的数据拆包,将局部变量和基本对象锁放入合适的寄存器和栈上的槽,再跳转到与目标字节码对应的编译后的代码的位置执行。值得注意的是,解释器的状态除了局部变量和基本对象锁外还应该包括表达式栈,但是为了简单起见,OSR(栈上替换)不会处理非空表达式栈,当JIT编译器发现表达式栈非空时会“拒绝”编译。

9. returnreturn

终止方法执行并返回调用者栈帧,如代码清单5-24所示。

代码清单5-24 字节码return

void TemplateTable::_return(TosState state) {
transition(state, state);
// 如果方法重写了Object.finalize(),会额外调用register_finalizer
if (_desc->bytecode() == Bytecodes::_return_register_finalizer) {
...
__ call_VM(...InterpreterRuntime::register_finalizer);
__ bind(skip_register_finalizer);
}
// 检查是否需要进入安全点
if(SafepointMechanism::uses_thread_local_poll()&& _desc->bytecode() != Bytecodes::_return_register_finalizer) {
...
__ call_VM(...InterpreterRuntime::at_safepoint);
__ pop(state);
__ bind(no_safepoint);
}
...
// 弹出当前栈帧
__ remove_activation(state, rbcp);
__ jmp(rbcp);
}

第2章提到过,如果重写了Object.finalize()方法,return字节码也会重写成非标准字节码
_return_register_finalizer,当解释器发现它是重写版本后,会调用InterpreterRuntime::register_finalizer,将对象加入一个链表,等待后面垃圾回收器调用链表中的对象的finalize()方法。处理完finalize重写后,return还会检查是否允许线程局部轮询(区别于全局安全点轮询,详见第10章),并调用InterpreterRuntime::at_safepoint检查是否允许进入安全点。

10. putstatic/putfield

putfield将一个值存放到对象成员字段,putstatic将一个值存放到类的static字段。熟悉Java的读者都知道Java有一个volatile关键字,作为最弱“同步组件”,它具有如下三个特性。

  • 原子性:读写volatile修饰的变量都是原子性的。相对地,对于非volatile修饰的变量,如long、double类型,是否为原子性由实现定义(Implementation-specific)。
  • 可见性:多线程访问变量时,一个线程如果修改了它的值,其他线程能立刻看到最新值。
  • 有序性:volatile写操作不能和volatile写操作/读操作发生重排序,但是可以和普通变量读写发生重排序;volatile读操作不能与任何操作发生重排序。

这些特性都是经过HotSpot VM源码验证过的。putstatic/putfield源码如代码清单5-25所示。

代码清单5-25 字节码putstatic/putfield

void TemplateTable::putfield_or_static(...) {
...
// 检查成员是否有volatile关键字
__ movl(rdx, flags);
__ shrl(rdx, ConstantPoolCacheEntry::is_volatile_shift);
__ andl(rdx, 0x1);
__ testl(rdx, rdx);
__ jcc(Assembler::zero, notVolatile);
// 如果是volatile变量,先正常赋值给成员变量再插入内存屏障
putfield_or_static_helper(...);
volatile_barrier(...Assembler::StoreLoad|Assembler::StoreStore);
__ jmp(Done);
// 如果不是volatile成员变量,简单赋值即可
__ bind(notVolatile);
putfield_or_static_helper(...);
__ bind(Done);
}

原子性来自于putfield_or_static_helper(),该函数会判断成员变量类型,然后调用access_store_at(),将值写入成员变量。access_store_at会进一步调用
BarrierSetAssembler::store_at,如代码清单5-26所示。

代码清单5-26
BarrierSetAssembler::store_at

void BarrierSetAssembler::store_at(...) {
...switch (type) {
case T_LONG:
#ifdef _LP64
__ movq(dst, rax);
#else
if (atomic) {
__ push(rdx);
__ push(rax); // 必须用FIST进行原子性更新
__ fild_d(Address(rsp,0)); // 先加载进FPU寄存器
__ fistp_d(dst); // 放入内存
__ addptr(rsp, 2*wordSize);
} else {
__ movptr(dst, rax);
__ movptr(dst.plus_disp(wordSize), rdx);
}
#endif
break;
...
}
}

以上代码均为x64架构,如果BarrierSetAssembler发现变量类型为long且是64位CPU,它会直接使用原生具有原子性的mov操作,如果是32位CPU则使用fild将值放入FPU栈,然后使用fistp读取FPU栈顶元素(ST(0))并存放到目标位置。volatile的原子性是指写64位值的原子性(fistp),而不是赋值这一过程的原子性(五条指令)。volatile的可见性和一致性是通过volatile屏障实现的。普通变量和volatile变量写之间的唯一区别是volatile写完会插入一个volatile_barrier,由volatile_barrier实际执行membar内存屏障指令。该内存屏障可以保证volatile写完后,后续的读写指令都可以看到volatile的最新值。虽说是内存屏障,但是虚拟机未必会真的使用指令集中的内存屏障指令。一个典型例子是,x86上lock指令前缀具有内存屏障的效果同时又比内存屏障指令(mfence、sfence、lfence)速度更快,因此在x86上,HotSpot VM是使用代码清单5-27所示的lock addl $0, 0($rsp)指令实现内存屏障的。

代码清单5-27 内存屏障在x86的实现

void membar(Membar_mask_bits order_constraint) {
// x86只会发生StoreLoad重排序,因此只需要处理它
if (order_constraint & StoreLoad) {
...
// 用lock add 实现内存屏障
lock();addl(Address(rsp, offset), 0);
}
}

实际上HotSpot VM只在指令缓存刷新(ICache)组件时使用内存屏障指令mfence。

11. invokestatic

JVM有五条字节码用于方法调用:invokedynamic调用动态计算的调用点;invokeinterface调用接口方法;invokespecial调用实例方法和类构造函数;invokestatic调用静态方法;invokevirtual调用虚函数。以invokestatic为例,如代码清单5-28所示。

代码清单5-28 字节码invokestatic

void TemplateTable::invokestatic(int byte_no) {
transition(vtos, vtos);
prepare_invoke(byte_no, rbx); // 准备调用
__ profile_call(rax);
__ profile_arguments_type(rax, rbx, rbcp, false);
__ jump_from_interpreted(rbx, rax); // 进行调用
}

prepare_invoke从缓存中加载调用的方法,然后跳到方法入口进行执行。

非标准字节码

除了实现Java虚拟机规范规定的字节码外,模板解释器还实现了一些非标准字节码,它们都在
interpterer/templateTable.cpp中定义。这些非标准字节码多是对标准字节码的特化,以加速程序运行,例如第2章提到的根据switch语句的case个数将lookupswitch字节码重写为fast_linearswitch(快速线性搜索)或者fast_binaryswitch(快速二分搜索),这两个字节码就是非标准字节码;本章前面提到,如果类重写了Object.finalize()方法,方法的*return字节码会被重写为return_register_finalizer,该字节码也属于非标准字节码。本节将继续讨论一些非标准字节码的实现。

1. fast_iload2

有时候代码会连续使用iload从局部变量表将变量加载到操作数栈,最典型的例子为定义两个变量,然后相加,如代码清单5-29所示。

代码清单5-29 连续使用iload然后求和

int a = 23; // iload 23
int b = 34; // iload 34
a + b // iadd

上面的字节码将派发三次,每次都要执行前奏曲代码、字节码实现代码、尾曲代码,所以HotSpot VM根据这样的模式,使用非标准字节码fast_iload一次完成两个局部变量的相加,省去了派发流程,如代码清单5-30所示。

代码清单5-30 非标准字节码fast_iload2

void TemplateTable::fast_iload2() {
transition(vtos, itos);
locals_index(rbx);__ movl(rax, iaddress(rbx));
__ push(itos);
locals_index(rbx, 3);
__ movl(rax, iaddress(rbx));
}

2. fast_iputfield

前面讨论的putfield字节码的实现较为复杂,因为需要解析字段,检查字段的修饰符。当第一次完成上述操作后,虚拟机会将标准字节码putfield重写为fast版本,这样在第二次执行时将不需要任何额外的操作,如代码清单5-31所示。

代码清单5-31 非标准字节码fast_iputfield

void TemplateTable::fast_storefield_helper(...) {
switch (bytecode()) {
case Bytecodes::_fast_iputfield:
__ access_store_at(T_INT, IN_HEAP, field, rax, noreg, noreg);
break;
...
}

从代码中可以发现,非标准的putfield没有做任何多余的操作。

本文给大家讲解的内容是详解探讨模板解释器,解释器的生成

  1. 下篇文章给大家讲解的是讨论虚拟机在并发方面付出的努力;
  2. 觉得文章不错的朋友可以转发此文关注小编;
  3. 感谢大家的支持!
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值