什么是字节码?
字节码(Byte-code)是一种包含执行程序,由一序列 op 代码/数据对组成的二进制文件,是一种中间码(IR)。是机器码的一种抽象。
我们常说的字节码一般是Java字节码,但其实很多动态编译解释的语言都有字节码,比如Javascript, python、ruby。
那么,字节码长什么样?我们用文本编辑器打开对应的文件,可以看到里面都是些二进制的字符
image-20220109210401562.png下图是V8的JS源码、字节码和机器码
imgHermes的JS源码:
print("helloword");
Hermes的字节码
Bytecode File Information:
Bytecode version number: 83
Source hash: 0000000000000000000000000000000000000000
Function count: 1
String count: 3
String Kind Entry count: 2
RegExp count: 0
Segment ID: 0
CommonJS module count: 0
CommonJS module count (static): 0
Bytecode options:
staticBuiltins: 0
cjsModulesStaticallyResolved: 0
Global String Table:
s0[ASCII, 0..5]: global
s1[ASCII, 6..14]: helloword
i2[ASCII, 15..19] #A689F65B: print
Function<global>(1 params, 11 registers, 0 symbols):
Offset in debug table: source 0x0000, lexical 0x0000
GetGlobalObject r0
TryGetById r2, r0, 1, "print"
LoadConstUndefined r1
LoadConstString r0, "helloword"
Call2 r0, r2, r1, r0
Ret r0
Debug filename table:
0: /tmp/hermes-input.js
Debug file table:
source table offset 0x0000: filename id 0
Debug source table:
0x0000 function idx 0, starts at line 1 col 1
bc 2: line 1 col 1
bc 14: line 1 col 6
0x000a end of debug source table
Debug lexical table:
0x0000 lexical parent: none, variable count: 0
0x0002 end of debug lexical table
为什么需要字节码?
首先,我们知道JS是一种脚本语言,他运行在不同的平台,包括Windows、Linux、MacOS、iOS、Android 等。不管什么样的语言,最终都是要变成机器码的,而不同平台由于有不同的处理器架构以及对应的指令集,所以就需要有一个中间层来负责对应平台的指令转换成对应的机器码,使得脚本语言的开发者无需关心底层这些复杂的硬件和指令系统。这个中间层就是我们说的虚拟机,而在虚拟机上面执行的代码就是字节码。
虚拟机是一种的抽象化的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。虚拟机屏蔽了与具体操作系统平台相关的信息,使得程序只需生成在虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
例如JavascriptCore内部的SquirrelFish、V8的Ignition,以及React Native专用的Hermes。
以V8为例,现在带有字节码的JS引擎工作流程一般如下:
2017但事实上,V8早期时没有使用字节码,而是直接从JS转换成机器码
2010这样执行的性能确实比从JS转成字节码再转成机器码要更好,但后面他们发现这样做有一些弊端:
-
机器码占空间很大。在V8执行的过程会将js源代码转化成二进制代码并且将二进制代码存储到内存中,退出进程后会将二进制代码存储到硬盘上。将js源码转化成的二进制代码占用的内存空间是非常巨大的,如果说一个js源码的文件大小是1M,那么生成的二进制代码可能就是十几M,而早期手机的内存普遍不高,过度占用会导致性能大大降低。
-
代码复杂度太高。上文提到过不同的CPU架构对应的指令集是完全不同的,而市面上CPU架构的种类又非常多,那么将AST转化为二进制代码的Full-Codegen引擎以及优化编译的Crankshaft引擎要针对不同的CPU架构编写代码,这个复杂程度及工作量可想而知,而对字节码进行编译可以大大的减少这个工作量
v8.png
-
重复编译bug
Bug的报告人在当时的Chrome浏览器下重复加载Facebook,并打开了各项监控发现:第一次加载时 v8.CompileScript 花费了 165 ms,而重复加载时发现真正耗时高的js代码并没有被缓存,导致重复加载时编译的时间和第一次加载的消耗大致相同。
导致这个bug的原因其实也很好理解,之前提到过因为二进制代码占用内存空间大,根据惰性编译的优化原则,所以V8并不会将所有代码进行编译只会编译最外层的代码,而在函数内部的代码会在第一次调用时编译,比如:
v8 function.png
如果浏览器只缓存最外层代码,那么对我们前端高度工程化的模块来说会导致里面的关键代码却无法被缓存,这也是导致上述问题的主要原因。
而引入字节码之后,上面的三个问题就可以得到缓解。通过恰当地设计字节码的编码方式,字节码可以做到比机器码紧凑很多。
memory.png启动时只需要编译出字节码,然后逐句执行字节码,编译出字节码的速度可远远快于编译出二进制代码的速度。
pageload.png
字节码的形式
先看一段JS代码
function incrementX(obj) {
return 1 + obj.x;
}
incrementX({x: 42});
V8字节码
编译为V8字节码是这样的
$ node --print-bytecode incrementX.js
...
[generating bytecode for function: incrementX]
Parameter count 2
Frame size 8
12 E> 0x2ddf8802cf6e @ StackCheck
19 S> 0x2ddf8802cf6f @ LdaSmi [1]
0x2ddf8802cf71 @ Star r0
34 E> 0x2ddf8802cf73 @ LdaNamedProperty a0, [0], [4]
28 E> 0x2ddf8802cf77 @ Add r0, [6]
36 S> 0x2ddf8802cf7a @ Return
Constant pool (size = 1)
0x2ddf8802cf21: [FixedArray] in OldSpace
- map = 0x2ddfb2d02309 <Map(HOLEY_ELEMENTS)>
- length: 1
0: 0x2ddf8db91611 <String[1]: x>
Handler Table (size = 16)
我们稍微解释下其中一些指令的意思
LdaSmi [1]
LdaSmi [1] 将常量 1 加载到累加器中。
imgStar r0
接下来,Star r0 将当前在累加器中的值 1 存储在寄存器 r0 中。
imgLdaNamedProperty a0, [0], [4]
LdaNamedProperty 将 a0 的命名属性加载到累加器中。ai 指向 incrementX() 的第 i 个参数。在这个例子中,我们在 a0 上查找一个命名属性,这是 incrementX() 的第一个参数。该属性名由常量 0 确定。LdaNamedProperty 使用 0 在单独的表中查找名称:
- length: 1
0: 0x2ddf8db91611 <String[1]: x>
可以看到,0 映射到了 x。因此这行字节码的意思是加载 obj.x。
那么值为 4 的操作数是干什么的呢? 它是函数 incrementX() 的反馈向量的索引。反馈向量包含用于性能优化的 runtime 信息。
现在寄存器看起来是这样的:
imgAdd r0, [6]
最后一条指令将 r0 加到累加器,结果是 43。 6 是反馈向量的另一个索引。
imgReturn
Return 返回累加器中的值。返回语句是函数 incrementX() 的结束。此时 incrementX() 的调用者可以在累加器中获得值 43,并可以进一步处理此值。
乍一看,V8 的字节码看起来非常奇怪,特别是当我们打印出所有的额外信息。但是一旦你知道 Ignition 是一个带有累加器寄存器的寄存器,你就可以分析出大多数字节码都干了什么。
Hermes字节码
同样一段代码,用Hermes生产的字节码如下(导出成了可阅读格式):
Bytecode File Information:
Bytecode version number: 83
Source hash: 0000000000000000000000000000000000000000
Function count: 2
String count: 3
String Kind Entry count: 2
RegExp count: 0
Segment ID: 0
CommonJS module count: 0
CommonJS module count (static): 0
Bytecode options:
staticBuiltins: 0
cjsModulesStaticallyResolved: 0
Global String Table:
s0[ASCII, 0..5]: global
i1[ASCII, 6..15] #FC148982: incrementX
i2[ASCII, 16..16] #0001E7F9: x
Function<global>(1 params, 11 registers, 0 symbols):
Offset in debug table: source 0x0000, lexical 0x0000
DeclareGlobalVar "incrementX"
CreateEnvironment r0
CreateClosure r1, r0, 1
GetGlobalObject r0
PutById r0, r1, 1, "incrementX"
GetByIdShort r2, r0, 1, "incrementX"
NewObject r1
LoadConstUInt8 r0, 42
PutNewOwnByIdShort r1, r0, "x"
LoadConstUndefined r0
Call2 r0, r2, r0, r1
Ret r0
Function<incrementX>(2 params, 2 registers, 0 symbols):
Offset in debug table: source 0x0010, lexical 0x0000
LoadParam r0, 1
GetByIdShort r1, r0, 1, "x"
LoadConstUInt8 r0, 1
Add r0, r0, r1
Ret r0
Debug filename table:
0: /tmp/hermes-input.js
Debug file table:
source table offset 0x0000: filename id 0
Debug source table:
0x0000 function idx 0, starts at line 1 col 1
bc 14: line 1 col 1
bc 20: line 5 col 1
bc 30: line 5 col 12
bc 36: line 5 col 11
0x0010 function idx 1, starts at line 1 col 1
bc 3: line 2 col 17
bc 11: line 2 col 10
0x001a end of debug source table
Debug lexical table:
0x0000 lexical parent: none, variable count: 0
0x0002 end of debug lexical table
Hermes字节码的可读版本可以通过https://hermesengine.dev/playground/ 或者 hermes test1.js -O -dump-bytecode显示出来(这里test1.js 的内容就是上面的JS代码)
同样的,这里节选incrementX 这个函数里面的指令说明下Hermes的字节码指令:
LoadParam r0, 1
读取第1个参数(也就是obj),保存到r0寄存器。
GetByIdShort r1, r0, 1, "x"
从r0(obj)中读取"x"属性,保存到r1寄存器。这里第3个参数1 是用来加速的缓存索引。
LoadConstUInt8 r0, 1
读取整数1, 保存到r0。 由于上一步已经使用过r0了,所以r0的值没有再保存的必要,这里重新给他赋值为整数1。
Add r0, r0, r1
把r0+r1的值写入r0,也就是obj.x + 1
Ret r0
返回r0的值
对比V8和Hermes的指令,我们可以看到不同的JS引擎生成的指令作用是类似的,只是指令的内容和顺序不太一样。相比而言,V8多了一个累加器,专门用于做数字运算,对于属性名的获取更偏向于使用索引而不是直接用字符串(不确定是不是导出成可阅读格式时填充上去的),操作数也是能少则少。这样做无疑字节码会更精简,但对于阅读来说就比较麻烦了。
参考链接
https://juejin.cn/post/6844904152745639949
网友评论