Skip to main content

12 字节码:解释器如何解释执行字节码

解释器可以直接解释执行字节码,也可以将字节码编译为二进制代码,然后再执行二进制机器代码。

早期的 V8 为了提高执行速度,直接将 JavaScript 代码编译成机器代码:

基线编译器,负责将 JavaScript 代码编译为没有优化过的机器代码。优化编译器,负责将一些热点代码(执行频繁的代码)优化为执行效率更高的机器代码。

机器代码缓存

JavaScript 代码在浏览器中执行,编译占用了很大一部分时间,浏览器中再次打开相同的页面,页面中的 JavaScript 文件没有被修改,再次编译之后的二进制代码也会保持不变。把二进制代码保存在内存中,重用它们完成后续的调用,省去再次编译的时间。

V8 的两种代码缓存策略:

  • V8 第一次执行一段代码时,编译源 JavaScript 代码,并将编译后的二进制代码缓存在内存中,(内存缓存 in-memory cache)。通过 JavaScript 源文件的字符串在内存中查找对应的编译后的二进制代码。
  • 将代码缓存到硬盘上,即使关闭了浏览器,下次重新打开浏览器再次执行相同代码时,也可以直接重复使用编译好的二进制代码。

字节码降低了内存占用

二进制代码所占用的内存空间是 JavaScript 代码的很多倍,在移动设备中占用过多的内存,会导致 Web 应用的速度大大降低。

虽然采用字节码在执行速度上稍慢于机器代码,但整体上权衡利弊,采用字节码是最优方式。

字节码如何提升代码启动速度

解释器可以快速生成字节码,但字节码通常效率不高。 优化编译器需要更长的时间进行处理,但最终会产生更高效的机器码。

字节码如何降低代码的复杂度

直接生成二进制代码,基线编译器和优化编译器要针对不同的体系的 CPU 编写不同的代码,会大大增加代码量。引入字节码,可以统一将字节码转换为不同平台的二进制代码。

如何生成字节码

function add(x, y) {
var z = x + y;
return z;
}
console.log(add(1, 2));

在 d8 中通过–print-ast 命令来查看 V8 内部生成的 AST:

[generating bytecode for function: add]
--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. DECLS
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 31
. . . INIT at 31
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. . . . ADD at 32
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. RETURN at 37
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"

使用–print-scopes 命令来查看生成的 add 函数的作用域:

Global scope:
function add (x, y) { // (0x7f9ed7849468) (12, 47)
// will be compiled
// 1 stack slots
// local vars:
VAR y; // (0x7f9ed7849790) parameter[1], never assigned
VAR z; // (0x7f9ed7849838) local[0], never assigned
VAR x; // (0x7f9ed78496e8) parameter[0], never assigned
}

理解字节码:解释器的架构设计

常用的字节码的指令集:

阅读汇编代码,需要先理解 CPU 的体系架构和 V8 解释器的整体设计架构,才能分析特定的字节码指令的含义。

解释器就是模拟物理机器执行字节码(实现取指令、解析指令、执行指令、存储数据等),所以解释器的执行架构和 CPU 处理机器代码的架构类似。

解释器类型:

  • 基于栈 (Stack-based):使用栈来保存函数参数、中间运算结果、变量等;
  • 基于寄存器 (Register-based):支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。

大多数解释器基于栈(Java 虚拟机,.Net 虚拟机,早期的 V8 虚拟机),在处理函数调用、解决递归问题和切换上下文时简单明快。现在的 V8 虚拟机采用基于寄存器的设计,将一些中间数据保存到寄存器中。

基于寄存器的解释器架构

累加器(LoaD Accumulator from Register),表示将寄存器中的值加载到累加器中,用来保存中间的结果。

Ldar a1

Star r0

Star(Store Accumulator to Register)把累加器中的值保存到某个寄存器中。

Add a0, [0]

Add a0, [0]是从 a0 寄存器加载值并将其与累加器中的值相加,然后将结果再次放入累加器。

LdaSmi [2]

将小整数(Smi)2 加载到累加器寄存器中。

Return

结束当前函数的执行,并将控制权传回给调用方。返回的值是累加器中的值。

完整分析一段字节码

StackCheck
Ldar a1
Add a0, [0]
Star r0
LdaSmi [2]
Star r1
Ldar r0
Return