03 编程语言全景图(下):虚拟机是如何执行字节码的?
你好,我是海纳。
上一节课我们介绍了编译器是如何把源代码翻译成字节码的,这一节课我们将会沿用上节课中的例子,实现一个简单的虚拟机,来运行生成的字节码。
我们先来看上节课定义的字节码。
#define BINARY_MUL 20
#define BINARY_DIV 21
#define BINARY_ADD 23
#define BINARY_SUB 24
#define LOAD_CONST 100
上节课的最后,生成的字节码内容是 [100, 00, 12, 100, 00, 48, 20, 100, 00, 59, 23]
,我们把这段字节码翻译出来。
这段字节码共包含了5个操作。
- 将数字 12 加载到操作数栈顶。
- 将数字 48 加载到操作数栈顶。
- 将两个数字从栈顶取出,并做乘法,然后将结果送回栈顶。
- 将数字 59 加载到栈顶。
- 将两个数字从栈顶取出,并做加法,然后将结果送回栈顶。
这节课我们将完成三个任务:一是将字节码从内存中写入硬盘文件中,实现字节码的序列化;二是实现一个小的虚拟机,将字节码文件再读入内存;三是比较解释器和JIT编译器两种不同的执行策略。
保存字节码文件
由源代码翻译成的Python字节码会按照一定的格式保存在硬盘上,这就是字节码文件,这里我们引入一个新的类 CodeObject 来表示编译得到的字节码对象,你可以看一下它的代码。
int CodeObject::write_to_file(string& filename, CodeObject& co) {
ofstream file = ofstream(filename, ios::out | ios::binary);
int magic_number = 0xa0df303;
if (!file.good()) {
cerr << "stream buf state is bad" << endl;
return -1;
}
file.write(reinterpret_cast<const char*>(&magic_number), 4);
co.save_to_file(file);
file.close();
return 0;
}
int CodeObject::save_to_file(ofstream& file) {
save_code_to_file(file);
return 0;
}
template<typename T>
int CodeObject::save_const_value(T t, ofstream& file) {
file.write(reinterpret_cast<const char*>(&t), sizeof(T));
return 0;
}
int CodeObject::save_code_to_file(ofstream& file) {
file << 's';
save_const_value((int)_insts.size(), file);
for (auto it = _insts.begin(); it != _insts.end(); it++) {
file << *it;
}
return 0;
}
这段代码的主要作用是把字节码以一定的格式写入到文件中。0xa0df303 是 Python 虚拟机要求的魔数,所有的字节码文件都必须以这个魔数为开头,代表自己是一个合法的 Python 字节码文件(第3行和第10行)。
save_code_to_file方法负责将字节码对象写入文件中。这个方法先向文件中写入一个字符 's'
,代表接下来是一段可变长度的字符串(第31行),然后向文件中写入字符串的长度(第32行),最后遍历指令序列,将所有的指令依次写入文件(第33至35行)。
save_const_value 是一个泛型方法,它的主要作用是将不同类型的数字以字符的形式写入文件中。这里使用了reinterpret_cast,代表以字符指针的形式读取传入的参数(第26行)。
除此之外,其他的代码比较容易理解,我就不再赘述了。
编译执行,我们就可以得到一个名为 test_token.txtc 的文件,这是一个二进制文件,文件的内容是由源代码所生成的字节码文件。使用 Linux 自带的 xxd 命令以16进制来查看这个文件的内容,结果如下所示:
# xxd test_token.txtc
00000000: 03f3 0d0a 7308 0000 0064 0c64 3014 643b ....s....d.d0.d;
00000010: 17 .
注意,0x64正是十进制的100,代表 LOAD_CONST 指令,紧跟在后面的那个字节是这个指令的操作数。0x14 是十进制的 20,代表 BINARY_MUL 指令。可见这个字节码文件是符合我们的预期的。
接下来,我们就可以实现一个简单的虚拟机来执行这个字节码文件了。
虚拟机解释执行字节码
我们先在项目目录下新建一个子目录,名为vm,代表 Python 虚拟机。
第一步,使用 BinaryFileParser 类加载字节码文件,读出文件的 magic number,然后把字节码装载进 HiString 类。HiString 代表虚拟机中的字符串,这个类与 C++ 内建的 string 类有所不同,虚拟机中的每个字符串的长度都由 length 属性描述。字符 0 在 HiString 中是合法字符,不代表字符串结尾。
HiString 的构造函数定义如下:
HiString::HiString(const char* x) {
_length = strlen(x);
_value = new char[_length];
strcpy(_value, x);
}
HiString::HiString(const char * x, const int length) {
_length = length;
_value = new char[length];
// do not use strcpy here, since '\0' is allowed.
for (int i = 0; i < length; i++) {
_value[i] = x[i];
}
}
请注意,虚拟机的代码里不使用任何的 STL 内建库,这是因为虚拟机中的字符串、整数、列表、字典等结构未来都应该由垃圾回收器自动管理。所以虚拟机必须对数据结构中的每一个字节的分配位置和生命周期有完全的掌控权,这就必然要求所有的数据结构都自主实现,而不能使用第三方类库。
BinaryFileParser 其他部分的代码主要是文件读入操作,逻辑十分简单,这里就不再列出它的源码了。
最后一步,实现 Interpreter 类,执行字节码。这是虚拟机解释器的主类,它的 run 方法的职责是逐条取出字节码,然后依次执行。你可以看一下对应的代码。
void Interpreter::run(HiString* codes) {
int pc = 0;
int code_length = codes->length();
_stack = new int[16];
int top = 0;
while (pc < code_length) {
unsigned char op_code = codes->value()[pc++];
bool has_argument = (op_code & 0xFF) >= ByteCode::HAVE_ARGUMENT;
int op_arg = -1;
if (has_argument) {
op_arg = (codes->value()[pc++] & 0xFF);
}
int v, w;
switch (op_code) {
case ByteCode::LOAD_CONST:
_stack[top++] = op_arg;
break;
case ByteCode::BINARY_ADD:
v = _stack[--top];
w = _stack[--top];
_stack[top++] = v + w;
break;
case ByteCode::BINARY_MULTIPLY:
v = _stack[--top];
w = _stack[--top];
_stack[top++] = v * w;
break;
default:
printf("Error: Unrecognized byte code %d\n", op_code);
}
}
printf("%d\n", _stack[0]);
delete[] _stack;
}
run 方法创建一个运行时栈(第 5 行),然后使用一个大的循环不断地从字节码数组中取出指令(第 8 至 39 行)。
每取出一条指令都需要判断这条指令是否带有参数(第 10 行),如果有参数,还要取出参数。LOAD_CONST 指令是带有参数的,而BINARY_ADD指令则是没有参数的。
接下来的 switch 语句分别对不同的指令进行处理。LOAD_CONST 就是将指令参数加载到操作数栈顶(第 20 至 22 行),二元操作数指令从栈顶取出两个数,进行二元运算以后,再把结果放回栈顶(第 24 至 34 行)。这和前面示例的执行过程是完全对应的。
这种把字节码逐条取出,然后按照字节码的语义直接在操作数栈上进行运算的方式就是解释执行。这种做法的优点是语义清晰、实现简单,缺点在于运行的性能不高。
对于一个简单的运算,如果使用 C 语言,经过 GCC 等编译器的优化,只需要三四条机器指令就可以完成了。而使用解释执行,最少也要几百条指令才能完成。所以采用解释执行的 Python、Lua 等脚本语言相比 C 语言等静态编译语言,性能表现上往往有数量级的差距。
然而采用虚拟机的语言,性能并不一定就都很差。基于 Hotspot 虚拟机的 Java 语言和基于 V8 虚拟机的 Node.js 在很多场景上都可以达到与 C/C++ 相同的性能表现。这主要得益于它们采用了一种名为即时编译(Just In Time,JIT)的策略。
第二种执行策略——JIT
Java 语言虚拟机(JVM)在运行之初将class文件加载进内存,然后就开始解释执行。如果一个函数被执行多次,JVM就会认为这个函数是一个热点(hotspot)函数,然后就将它翻译成机器码执行。JIT最大的特点是在程序运行时进行编译。
这种编译方式相对解释器,性能得到了巨大的提升。它与静态编译相比又具有怎样的特点呢?我们接下来从原理入手,来回答这个问题。我们先来了解JIT编译器能成功运行所依赖的两大核心机制。
- 申请可写可执行的内存区域,确保在运行期可以生成可执行的机器码。
- 基于性能采样的编译优化(Profiling Guided Optimization, PGO),可以使JIT编译器获得超过静态编译器的运行性能。
我们先来看看JIT编译的第一个核心机制:可写可执行的内存区域。
可写可执行的内存区域
具体来讲,这个机制是申请一块既有写权限又有执行权限的内存,然后把你要编译的Java方法翻译成机器码,写入到这块内存里。当需要再次调用原来的Java方法时,就转向调用这块内存。我们来看一个例子。
这个例子很简单,就是把3加上1,然后打印出来,我们通过以下命令,查看一下它的机器码。
在这一堆输出中,我们可以看到 inc 方法最终被翻译成了这样的机器码:
40052d: 55 push %rbp
40052e: 48 89 e5 mov %rsp,%rbp
400531: 89 7d fc mov %edi,-0x4(%rbp)
400534: 8b 45 fc mov -0x4(%rbp),%eax
400537: 83 c0 01 add $0x1,%eax
40053a: 5d pop %rbp
40053b: c3 retq
我们先来分析一下这块机器码,可以看到,它首先会保存上一个栈帧的基址,并把当前的栈指针赋给栈基址寄存器(第1行),这是进入一个函数的常规操作。
然后把edi存到栈上(第3行)。在X86 64位Linux系统上,前6个参数都是使用寄存器传参的。第一个参数会使用rdi,第二个参数使用 rsi,后面几个参数也是这样。所以 edi 里存的其实就是第一个参数,也就是整数 3,为什么使用rdi的低32位,也就是 edi 呢?因为我们的入参 a 是 int 型,你可以试着换成long型,看看会有什么样的效果。
接着,把上一步存到栈上的那个整数再存进 eax 中(第4行)。最后给 eax 加上 1, 然后就退栈,返回(第5行往后)。按照二进制接口(Application Binary Interface,ABI)的规定,返回值通过eax传递。
通过刚刚的分析,你会发现,其实上面编译代码的第3行和第4行根本没有存在的必要,GCC 在默认情况下,生成的机器码有点傻,它总是要把入参放到栈上,但其实我们是可以直接把参数从 rdi 中放到 rax 中的。
如果你还是觉得这段代码太复杂了,那我们可以自己改一下,让它更精简一点。怎么做呢?就是在运行时修改 inc 的逻辑,你可以看一下修改后的代码。
#include<stdio.h>
#include<memory.h>
#include<sys/mman.h>
typedef int (* inc_func)(int a);
int main() {
char code[] = {
0x55, // push rbp
0x48, 0x89, 0xe5, // mov rsp, rbp
0x89, 0xf8, // mov edi, eax
0x83, 0xc0, 0x01, // add $1, eax
0x5d, // pop rbp
0xc3 // ret
};
void * temp = mmap(NULL, sizeof(code), PROT_WRITE | PROT_EXEC,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
memcpy(temp, code, sizeof(code));
inc_func p_inc = (inc_func)temp;
printf("%d\n", p_inc(7));
return 0;
}
在这个例子中,我们使用了 mmap 申请了一块有写权限和执行权限的内存,把我们手写的机器码复制进去,然后使用一个函数指针指向这块内存,并且调用它。通过这种方式我们就可以执行这一段手写的机器码了。我们来运行一下看看。
为了生成更精简的机器码,我们可以引入编译器优化手段,例如全局值编码、死代码消除、标量展开、公共子表达式消除和常量传播等等。这样生成出来的机器码会更优。所以这种编译方式就被叫做即时编译。
我们搞清楚了JIT编译的第一个核心机制后,再来看它的第二个核心机制:基于采样的编译优化。
基于采样的编译优化和退优化
我们知道,架构师的一个核心职责是对比各种技术方案的优劣,我至今还能听到有的架构师说Java性能不好,理由有很多,比如Java是解释执行,Java里所有函数都是虚函数,每次调用都需要查询虚表等等。
这些说法在老版本的JVM中可能是对的,但随着JVM中JIT编译器的演进和优化,Java语言的这些性能缺陷都在逐渐被克服。
编译器的作用是把高级语言翻译成性能最好的机器码。不管是静态编译还是JIT编译,它们的功能都是一样的,但是JIT编译往往可以做得更好。我们通过一个编译优化的常量传播例子来说明。
这个例子的代码如下:
在这块代码中,由于第2行给b赋值一个常量后,后面的语句没有再改过b的值,我们就可以把后面所有出现b的地方都改为3,同理,所有出现c的地方都改为4。经过这种优化,代码的第4行就可以改写成 return 3 + 4;
。接着,编译器再做一轮分析,对于运算符两边都是常量的情况,直接进行计算,也就是把上面的代码再优化成 return 7;
。这种优化被叫做常量折叠。
这里也请你思考一下,如果这行代码是C语言的,那么在最优的情况下,GCC 会生成什么样的机器码?
通过上面的例子,我们讲了编译器优化的一个简单思路。其实,编译器的优化还有很多其他的思路和方法,这里我就不再一一列举了,如果你对编译器设计特别感兴趣的话,我推荐你去看看《编译原理》和《高级编译器设计与实现 》这2本书。
接下来,我们再看第二个例子,这是一个C语言编译器没有办法优化,但是JIT编译却能进一步优化的例子。在这个例子中,你会发现常量传播不再起作用了。
public static int test(boolean flag) {
int b = 0;
if (flag) {
b = 3;
}
else {
b = 2;
}
return b + 4;
}
和第一个例子相比较,这个例子增加了第3行到第8行的条件判断。所以编译器无法知道第9行b的真实取值是什么。只能严格按照这个函数的逻辑去生成比较、跳转、赋值等操作,那么这个例子就比可以常量折叠的那个例子复杂多了。
一般情况下,虽然这个例子中的test函数没有优化空间了,但是JVM的JIT技术还是在这里找到了优化的机会。假如存在一种情况,每一次test方法被调用的时候,传的参数flag都是true或者都是false,也就是说,flag的取值固定。那么JIT编译器就可以认为另外一个分支是不存在的,可以不编译。
JIT编译器在开始之前,test方法是由解释器执行的。解释器一边执行,一边会统计flag的取值,这种统计就叫做性能采样(Profiling)。当JIT编译器发现,test方法被调用了500次(这个阈值由JVM参数指定),每一次flag的值都是true,那它就可以合理地猜测,下一次可能还是true,它就会把test方法优化成下面这个样子:
但是这种做法有一些问题,相信你也看出来了,如果恰好test方法的下一次调用就是false呢?所以JVM必须在test方法里留一个哨兵,当参数flag的值为false的时候,可以再退回到解释器执行。这个过程就是退优化(Deoptimization),JVM的JIT编译器生成的机器码等效于以下代码:
在这段代码中,deoptimize方法是JVM提供的内建方法,它的作用是由JIT编译器退回到解释器进行执行。这个过程涉及栈帧的运行时切换,无疑是非常精巧和复杂的,我们的目标是实现一个简单的虚拟机,并不打算实现完备的JIT机制,所以这里就不再展开介绍它的技术细节了。
让JIT编译器运行得好,我们只需要遵守一条原则:让程序行为可预测。因为JIT编译优化的基本假设是过去和未来,程序的运行规律基本一致,所以它基于过去的行为测试未来。如果它预测的未来和真实情况不一致,就会发生退优化。退优化会对性能带来巨大的伤害,所以JIT有时也可能是一把双刃剑。
到此为止,我们就把JIT编译器的基本原理介绍完了。可写可执行内存和基于采样的编译优化这两大机制保障了JIT编译器的实现,而JIT编译器又是JVM高效执行的核心秘密。
总结
这节课我们继续上节课的内容,将编译器前端生成的字节码先保存成二进制文件。然后实现了一个非常简单的虚拟机,用于解释执行字节码。
虚拟机的主流执行策略有两种,一种是解释执行,解释器按照字节码的语义直接在操作数栈上进行运算。另一种是即时编译执行,它会在运行时生成机器码,从而极大地提升虚拟机性能。即时编译包括两个核心技术,一是在运行时申请可写执行的内存区域,可以让编译器在运行时生成机器码,二是基于采样的编译优化和退优化,可以让编译器生成激进的优化代码,从而获得极致的运行性能。
从这两节课的例子中,我们可以看到字节码文件是 Python 编译器和虚拟机的关键中枢。编译器的输出是字节码文件,而虚拟机也是从加载字节码文件开始。所以下一节课,我们就来研究 Python 字节码文件的格式。
注:点击链接查看课程代码地址
思考题
请阅读 PEP 542,并思考能否使用 CPython 的现有机制来实现插入式的 JIT 执行器?欢迎你把你思考后的结果分享到评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!
- @Michael 👍(1) 💬(1)
我们的python虚拟机会基于jit来实现吗?我粗略看了一下仓库代码,好像没看见欸
2024-06-01 - qinsi 👍(1) 💬(1)
两个月前听说的一个例子, 具体细节可能不准确: jvm做性能采样时如果发现检查null指针的次数达到一定阈值, 就会生成jit代码, 尝试直接访问指针并捕获sigsegv信号来检查null指针. 结果macos 14.4正式版发布的时候, 如果是m芯片的mac, 遇到内存非法访问就直接sigkill. 于是就出现了mac上的java程序随机性崩溃.
2024-05-11 - 细露仔 👍(0) 💬(1)
老师您好。之前经常看到一些文章说什么两行代码加速python。然后点进去全是说的用numba.njit装饰一下就行了。但是这种文章里都没有介绍原理是啥。我印象中py3.13官方自己从解释器的层面实现了jit这个功能,那请问numba.njit是怎么通过只改python代码就做到了呢?
2024-06-03 - buckwheat 👍(0) 💬(1)
老师,有实现的源码可供参考吗
2024-05-13 - qinsi 👍(0) 💬(1)
似乎有点小问题. save_const_value时用了原生类型的字长, 而解释器假设int都是32位的, 跟实际环境不一致的话就会出错
2024-05-11 - Geek_0050d6 👍(0) 💬(1)
直播课🈶回放吗?
2024-05-10 - 林先森 👍(0) 💬(1)
老师好,您的例子中是按照基于栈的方式来实现虚拟机执行字节码的,能讲讲基于寄存器的虚拟机是如何执行字节码的吗?
2024-05-09 - ifelse 👍(0) 💬(0)
学习打卡,非常不戳
2024-10-18