最简单的Java虚拟机可以只包括类加载器和解释器:类加载器加载字节码iconst_1、iconst_1、iadd并传给虚拟机,解释器按照字节码计算并得到结果。在没有JIT编译器的情况下,解释器从某种程度上来说就是虚拟机本体,有关虚拟机的绝大部分问题都能在解释器中找到答案。
本章将详细讨论解释器的内部构造和解释执行过程。
众所周知,HotSpot VM默认使用解释和编译混合(-Xmixed)的方式执行代码。首先它使用模板解释器对字节码进行解释,当发现一段代码是热点时,就使用C1或C2即时编译器优化编译后再执行,这也是它的名字——“热点”的由来。解释器的代码位于hotspot/share/interpreter,它的总体架构如图5-1所示。
HotSpot VM有一个C++字节码解释器,还有一个模板解释器(Template Interpreter),它们有很大的区别。
C++解释器行为
对于Java字节码istore_0和iadd来说,如果是C++字节码解释器(见图5-1右侧部分所示),那么它的工作流程如代码清单5-1所示。
代码清单5-1 C++字节码解释器伪代码
void cppInterpreter::work(){
for(int i=0;i<bytecode.length();i++){
switch(bytecode[i]){
case ISTORE_0:
int value = operandStack.pop();
localVar[0] = value;
break;
case IADD:
int v1 = operandStack.pop();
int v2 = operandStack.pop();
int res = v1+v2;
operandStack.push(res);
break;
....
}
}
}
C++解释器使用C++语言模拟字节码的执行:iadd是两个数相加,字节码解释器从栈上pop两个数据然后求和,再push到栈上。如果是模板解释器就完全不一样了。
模板解释器
行为模板解释器是一堆机器代码的例程,会在虚拟机创建时初始化好,换句话说,模板解释器在虚拟机初始化的时候为iadd和istore_0申请两片内存,并设置为可读、可写、可执行,然后向内存写入模拟iadd和istore_0执行的机器代码。在解释执行时遇到iadd,跳转到相应内存,并将该片内存的数据视作代码直接执行。
通常,JIT暗指即时编译器,但是JIT(Just-In-Time)这个词本身并没有编译器的含义,它只是表示“即时”,如果按照这个定义,JIT指运行时机器代码生成技术。在这个定义下,模板解释器也属于JIT范畴,因为根据上面的描述,它的各个组件如同各种字节码,异常处理、安全点处理等都是在虚拟机启动的时候动态生成机器代码,然后组成一个整体的。如果上面的描述太过抽象,可以参见代码清单5-2,它直观地说明了模板解释器是什么。
代码清单5-2 模板解释器
class TemplateInterpreter: public AbstractInterpreter {
protected:
static address _throw_ArrayIndexOutOfBoundsException_entry;
static address _throw_ArrayStoreException_entry;
static address _throw_ArithmeticException_entry;
static address _throw_ClassCastException_entry;
static address _throw_NullPointerException_entry;
static address _throw_exception_entry;
static address _throw_StackOverflowError_entry;
static address _remove_activation_entry;
static address _remove_activation_preserving_args_entry;static EntryPoint _return_entry[number_of_return_entries];
static EntryPoint _earlyret_entry;
static EntryPoint _deopt_entry[number_of_deopt_entries];
static address _deopt_reexecute_return_entry;
static EntryPoint _safept_entry;
static DispatchTable _active_table;
static DispatchTable _normal_table;
static DispatchTable _safept_table;
static address _wentry_point[DispatchTable::length];
...
};
TemplateInterpreter包含各种机器代码入口,例如字节码对应的机器代码模板(_normal_table)、退优化的机器代码(_deopt_entry)、常见异常发生时的机器代码(_throw_X)。除此之外,TemplateInterpreter继承自AbstractInterpreter,也包含一些机器代码入口,如代码清单5-3所示。
代码清单5-3 抽象解释器
class AbstractInterpreter: AllStatic {
protected:
static StubQueue* _code;
static bool _notice_safepoints;
static address _native_entry_begin;
static address _native_entry_end;// 方法入口点
static address _entry_table[number_of_method_entries];
static address _cds_entry_table[number_of_method_entries];
static address _slow_signature_handler;
static address _rethrow_exception_entry;
...
};
如代码清单5-3所示,抽象解释器中包含普通方法入口的机器代码(_entry_table)、CDS方法入口的机器代码(_cds_entry_table)、第4章提到的处理解释器与JNI调用约定的机器代码(_slow_signature_handler)等。_entry_table等价于代码清单5-1中的for-switch,也就是说,模板解释器把“遍历方法字节码然后逐个执行”这一过程也写成了机器代码。
上面的TemplateInterpreter和AbstractInterpreter包含各种机器代码片段,它们构成解释器本体。机器代码片段的生成是由 TemplateInterpreterGenerator完成的,它是解释器本体的生成器。关于重要入口机器代码的生成过程将在本章后面详细描述,这里我们关心的是生成的机器代码片段,它们都会放入桩代码队列(_code),如代码清单5-4所示。
代码清单5-4 桩代码队列
class StubQueue: public CHeapObj<mtCode> {
private:
StubInterface* _stub_interface; // 沟通Stub和StubQueue的接口
address _stub_buffer; // 存放机器的地方(buffer)
int _buffer_size; // buffer大小
int _buffer_limit; // buffer大小限制
int _queue_begin; // 队列开始
int _queue_end; // 队列结束
int _number_of_stubs; // 机器代码片段个数
Mutex* const _mutex;
public:
StubQueue::StubQueue(...) : _mutex(lock) {
intptr_t size = align_up(buffer_size, 2*BytesPerWord);
BufferBlob* blob = BufferBlob::create(name, size);if( blob == NULL) {
vm_exit_out_of_memory(...);
}
_stub_interface = stub_interface;
_buffer_size = blob->content_size();
_buffer_limit = blob->content_size();
_stub_buffer = blob->content_begin();
_queue_begin = 0;
_queue_end = 0;
_number_of_stubs = 0;
}
};
StubQueue是code/stubs中的一个结构。它抽象出一个存放机器代码片段的队列,当模板解释器的生成器生成机器代码时会将代码片段放入该队列。StubQueue只是一个队列抽象,真正存放机器代码的片段是_stub_buffer,它由BufferBlob::create()创建。
在HotSpot VM中,除了模板解释器外,有很多地方也会用到运行时机器代码生成技术,如广为人知的C1编译器产出、C2编译器产出、C2I/I2C适配器代码片段、解释器到JNI适配器的代码片段等。为了统一管理这些运行时生成的机器代码,HotSpot VM抽象出一个CodeBlob体系,由CodeBlob作为基类表示所有运行时生成的机器代码,并衍生出五花八门的子类:
1)CompiledMethod:编译后的Java方法。
a)nmethod:JIT编译后的Java方法。
b)AOTCompiledMethod:AOT编译的方法。
2)RuntimeBlob:非编译后的代码片段。
a)BufferBlob:解释器等使用的代码片段。
AdapterBlob:C2I/I2C适配器代码片段。
VtableBlob:虚表代码片段。
MethodHandleAdapterBlob:MethodHandle代码片段。
b)RuntimeStub:调用运行时方法的代码片段。
c)SingletonBlob:单例代码片段。
DeoptimizationBlob:退优化代码片段。
ExceptionBlob:异常处理代码片段。SafepointBlob:错误指令异常处理代码片段。
UncommonTrapBlob:打破编译器假设的稀有情况代码片段。
前面提到过C2I/I2C适配器代码片段,它们就存放在AdapterBlob中。解释器到JNI的调用约定适配器代码片段和模板解释器一样,都存放在BufferBlob中。前面进行分类是为了区分代码片段的类型,而统一管理这些即时生成的机器代码片段的区域是CodeCache,由虚拟机将所有CodeBlob都放入CodeCache。
第4章曾提到Threads::create_vm会初始化线程和组件,CodeCache便是这里所说的组件之一,它在Threads::create_vm初始化主线程后初始化,如代码清单5-5所示。
代码清单5-5 CodeCache初始化
void CodeCache::initialize() {
... // 开启分段CodeCache,将运行时生成的代码片段按类别放到三个区域
if (SegmentedCodeCache) {
initialize_heaps();
} else {
... // 不开启分段CodeCache,所有运行时生成的代码片段都放到一个区域
add_heap(rs, "CodeCache", CodeBlobType::All);
}
// 初始化指令缓存刷新模块(ICache Flush)
icache_init();
// * Windows上为CodeCache中的运行时生成的代码注册结构化异常处理(SEH)
os::register_code_area((char*)low_bound(), (char*)high_bound());
}
CodeCache区域的最大空间可以用-XX:ReservedCodeCacheSize=<val>指定。
Java 9在JEP 197中引入了CodeCache分段。如果没有开启CodeCache分段,JVM会用一个区域存放所有运行时生成的代码片段。
如果使用-XX:+SegmentedCodeCache开启分段,JVM会将CodeCache内
部拆分为三个区域,分别用于存放非nmethod代码片段(如解释器、C2I/I2C适配器等)、处于分层编译的2和3级别带Profiling信息的nmethod、处于分层编译的1和4级别不带Profiling信息的nmethod。
CodeCache分段有很多好处,比如:
分隔非nmethod方法,例如带Profiling的nmethod与不带Profiling的nmethod,可以根据需要访问不同的区域,无须每次遍历整个CodeCache。
提升程序运行时尤其是GC的性能。在开启分段堆后GC扫描根只需要遍历一个区域。
提升代码局部性,因为相同类型的代码很有可能在最近一段时间被频繁访问。
模板解释器和JIT编译器都重度依赖运行时代码生成技术,它们在运行时向内存写入数据,这些数据可以被当作指令执行。CPU和主存间一般有L1、L2、L3三级高速缓存,L1级高速缓存又可以分为指令缓存(Instruction Cache)和数据缓存(Data Cache),这样划分后CPU可以同时获取指令和数据,进而提升性能,但是也带来了一致性问题。
处理器只能执行位于指令缓存中的指令,不能直接将数据缓存中的数据视作指令来执行。同时处理器只能看到位于数据缓存中的数据,不能直接访问内存。因为不能直接修改指令缓存和内存,所以会出现如图5-2所示的情况。
处理器未来会自动将数据缓存的数据写回内存,然后指令缓存重新读取位于内存的指令,但是没有办法知道处理器何时这样做。举个例子,如果虚拟机运行时生成了新的代码想要立即执行它们,处理器可能会忽略它们执行旧的代码,因为旧的代码仍然位于指令缓存中。观察图片的箭头不难知道,要解决这个问题需要强制将数据缓存中的新数据先写回内存,然后载入指令缓存,如图5-3所示。
要想执行新的指令,可以强制刷新指令缓存的数据,使缓存的指令无效化,这时处理器会主动将数据缓存中的数据写入内存,然后读取内存的新指令到指令缓存。HotSpot VM中无效化指令缓存的操作由runtime/icache模块完成,CodeCache区域初始化后会调用icache_init()初始化指令缓存刷新模块,如代码清单5-6所示。
代码清单5-6 指令缓存清理的实现
void ICacheStubGenerator::generate_icache_flush(...) {
...
// 如果待清理的内存地址为0,则跳过清理
__ testl(lines, lines);
__ jcc(Assembler::zero, done);
// 禁止CPU指令重排序(只能使用mfence屏障)
__ mfence();
// 否则清理[0,ICache::line_size]内存地址范围内的缓存行
__ bind(flush_line);
__ clflush(Address(addr, 0)); // 底层是x86的clflush实现
__ addptr(addr, ICache::line_size);
__ decrementl(lines);
__ jcc(Assembler::notZero, flush_line);
// 禁止CPU指令重排序
__ mfence();
// 清理完成
__ bind(done);
__ ret(0);
*flush_icache_stub = (ICache::flush_icache_stub_t)start;
}
x86上指令缓存刷新是由clflush指令实现的,该指令是唯一一个必须配合使用mfence的指令。
本文就是愿天堂没有BUG给大家分享的内容,大家有收获的话可以分享下,想学习更多的话可以到微信公众号里找我,我等你哦。