美文网首页
JS引擎与字节码

JS引擎与字节码

作者: FingerStyle | 来源:发表于2022-01-09 22:02 被阅读0次

    什么是字节码?

    字节码(Byte-code)是一种包含执行程序,由一序列 op 代码/数据对组成的二进制文件,是一种中间码(IR)。是机器码的一种抽象。

    我们常说的字节码一般是Java字节码,但其实很多动态编译解释的语言都有字节码,比如Javascript, python、ruby。

    那么,字节码长什么样?我们用文本编辑器打开对应的文件,可以看到里面都是些二进制的字符

    image-20220109210401562.png

    下图是V8的JS源码、字节码和机器码

    img

    Hermes的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转成字节码再转成机器码要更好,但后面他们发现这样做有一些弊端:

    1. 机器码占空间很大。在V8执行的过程会将js源代码转化成二进制代码并且将二进制代码存储到内存中,退出进程后会将二进制代码存储到硬盘上。将js源码转化成的二进制代码占用的内存空间是非常巨大的,如果说一个js源码的文件大小是1M,那么生成的二进制代码可能就是十几M,而早期手机的内存普遍不高,过度占用会导致性能大大降低。

    2. 代码复杂度太高。上文提到过不同的CPU架构对应的指令集是完全不同的,而市面上CPU架构的种类又非常多,那么将AST转化为二进制代码的Full-Codegen引擎以及优化编译的Crankshaft引擎要针对不同的CPU架构编写代码,这个复杂程度及工作量可想而知,而对字节码进行编译可以大大的减少这个工作量


      v8.png
    1. 重复编译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 加载到累加器中。

    img

    Star r0

    接下来,Star r0 将当前在累加器中的值 1 存储在寄存器 r0 中。

    img

    LdaNamedProperty 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 信息。

    现在寄存器看起来是这样的:

    img

    Add r0, [6]

    最后一条指令将 r0 加到累加器,结果是 43。 6 是反馈向量的另一个索引。

    img

    Return

    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

    https://zhuanlan.zhihu.com/p/28590489

    https://hermesengine.dev/docs/design

    相关文章

      网友评论

          本文标题:JS引擎与字节码

          本文链接:https://www.haomeiwen.com/subject/kpjpcrtx.html