前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >大牛巧用一文带你彻底搞懂解释器的内部构造和解释执行过程

大牛巧用一文带你彻底搞懂解释器的内部构造和解释执行过程

作者头像
愿天堂没有BUG
发布2022-10-31 11:20:44
8500
发布2022-10-31 11:20:44
举报
文章被收录于专栏:愿天堂没有BUG(公众号同名)

模板解释器

最简单的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++字节码解释器伪代码

代码语言:javascript
复制
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 模板解释器

代码语言:javascript
复制
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 抽象解释器

代码语言:javascript
复制
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 桩代码队列

代码语言:javascript
复制
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()创建。

CodeCache

在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初始化

代码语言:javascript
复制
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 指令缓存清理的实现

代码语言:javascript
复制
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的指令。

本文给大家讲解的内容是详细讨论解释器的内部构造和解释执行过程

  1. 下篇文章给大家讲解的是详解探讨模板解释器,解释器的生成;
  2. 觉得文章不错的朋友可以转发此文关注小编;
  3. 感谢大家的支持!

本文就是愿天堂没有BUG给大家分享的内容,大家有收获的话可以分享下,想学习更多的话可以到微信公众号里找我,我等你哦。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-03-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 愿天堂没有BUG 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 模板解释器
  • 解释器体系
  • 机器代码片段
  • CodeCache
  • 指令缓存刷新
  • 本文给大家讲解的内容是详细讨论解释器的内部构造和解释执行过程
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档