JavaScript工作原理:JavaScript V8引擎的优化

js引擎是将JavaScript处理并运行的环境,jvm是java处理并运行的环境,在JavaScript的V8引擎的设计中,大量借鉴了jvm中的一些方法和思想进行设计,着重起到优化的功能

编译性语言的处理过程

对于c或者c++这种编译兴来说,编辑、编译和运行是全部分离的,互相是一个职责链的形势,这个过程对于学过c语言基础或者与此相关的我们来说是非常简单的:

如上图所示,编辑源代码后、经过编译链接、最后得到本地二进制代码,然后交给操作系统运行。

解释性语言的处理过程

对于python、ruby、perl等解释性语言(通常脚本语言都是解释性语言)来说,脚本并不需要进行编译,而是在运行过程中直接被解释器解释的同时调用操作系统资源运行的。旧的JavaScript引擎,也是采用这样的方式编译JavaScript代码的。

如上图所示,比起编译性的语言,解释语言可以不用得到二进制表示,而被直接解释执行。

Java的处理过程

相比上边两种,Java的处理可谓复杂很多。分为了两个阶段:编译与解释阶段。与c++的编译阶段区别是编译阶段生产的不是直接可以运行的二进制代码,而是一种叫做字节码的中间代码。之所以使用这种字节码的中间代码是因为字节码可以不受操作系统和平台的限制,借助jvm实现“Write once, run anywhere”的跨平台功能,这也是Java最大的卖点与久经不衰的原因之一。

另外一个阶段是类似Python的解释阶段,Python不同的是:首先解释阶段将字节码作为了输入然后被解释器运行;加入了JIT的概念,JIT可以将字节码转为本地代码然后执行,这个JIT主要是起到一个优化性能的作用。

V8对JavaScript的处理过程

V8对js的处理过程与Java非常相似,但把Java的两个阶段合并了起来,全部在JavaScript引擎中执行。此外,因为JavaScript作为弱类型语言,本身没有进行类型的标记,如果直接编译对于编译器来说是有性能损耗的,为此v8借用了类型系统在内部构建了隐性类型系统。

多线程优化

V8引擎内部有多个线程进行处理:

  • 主线程:获取代码并优化。
  • 编译线程:获取主线程获取的代码然后编译并执行,此时主线程正在优化。
  • Profiler线程(我翻译为检查器线程):找到会占用大量性能的部分,以便主线程可以优先优化这部分。
  • CG线程(垃圾扫描与回收器):用来进行垃圾回收的线程。

首次执行JavaScript代码时,V8利用full-codegen直接将解析后的JavaScript转换为机器代码而无需任何转换。这使它可以非常快速地开始执行机器代码。请注意,V8不使用中间字节码表示,因此无需解释器。

当代码运行一段时间后,探查器线程已经收集了足够的数据来告诉应该优化哪个方法。

接下来,Crankshaft优化开始于另一个线程。它将JavaScript抽象语法树转换为名为Hydrogen的高级静态单指派(SSA)表示,并尝试优化氢图。大多数优化都是在这个级别完成的。

隐藏类优化

大多数JavaScript解释器使用类似字典的结构(基于散列函数)来存储对象属性值在内存中的位置。这种结构使得在JavaScript中检索属性的值比在Java或C#等非动态编程语言中的计算成本更高。在Java中,所有对象属性都是在编译之前由固定对象布局确定的,并且无法在运行时动态添加或删除。结果、属性值(或指向这些属性的指针)可以作为连续缓冲区存储在存储器中,每个缓冲区之间具有固定偏移量。可以根据属性类型轻松确定偏移的长度,而在运行时可以更改属性类型的JavaScript中,这是不可能的。
由于使用字典在内存中查找对象属性的位置效率非常低,因此V8使用不同的方法:隐藏类。隐藏类的工作方式类似于Java等语言中使用的固定对象布局(类),除非它们是在运行时创建的。现在,让我们看看它们实际上是什么样的:

function Point(x,y){ 
    this.x = x; 
    this.y = y; 
} 
var p1 = new Point(1,2);

一旦new一个新的Point类,V8将创建一个名为“C0”的隐藏类,如图:

此时,尚未定义任何Point的属性,“C0”为空。

一旦执行了第一个语句“this.x = x”(在“Point”函数内),V8将创建一个名为“C1”的第二个隐藏类,它基于“C0”。“C1”描述了可以找到属性x的存储器中的位置(相对于对象指针)。在这种情况下,“x”存储在偏移 0处,这意味着当在存储器中查看点对象作为连续缓冲区时,第一偏移将对应于属性“x”。V8还将使用“类转换”更新“C0”,该类转换指出如果将属性“x”添加到点对象,则隐藏类应该从“C0”切换到“C1”。下面的点对象的隐藏类现在是“C1”。

每次将新属性添加到对象时,旧的隐藏类都会更新为新隐藏类的转换路径。隐藏类转换很重要,因为它们允许在以相同方式创建的对象之间共享隐藏类。如果两个对象共享一个隐藏类并且同一属性被添加到它们中,则转换将确保两个对象都接收相同的新隐藏类以及随其附带的所有优化代码。

执行语句“this.y = y”时重复此过程。

创建一个名为“C2”的新隐藏类,将类转换添加到“C1”,声明如果将属性“y”添加到Point对象(已包含属性“x”),则隐藏类应更改为“C2”,点对象的隐藏类更新为“C2”。

隐藏类转换取决于属性添加到对象的顺序。看一下下面的代码片段:

function Point(x,y){ 
    this.x = x; 
    this.y = y; 
} 
var p1 = new Point(1,2); 
p1.a = 5; 
p1.b = 6; 
var p2 = new Point(3,4); 
p2.b = 7; 
p2.a = 8;

现在,您将假设对于p1和p2,将使用相同的隐藏类和转换。好吧,不是真的。对于“p1”,首先添加属性“a”,然后添加属性“b”。但是,对于“p2”,首先分配“b”,然后是“a”。因此,作为不同转换路径的结果,“p1”和“p2”以不同的隐藏类结束。在这种情况下,以相同的顺序初始化动态属性要好得多,以便可以重用隐藏的类。

内联缓存

除了JIT、多线程、隐藏类外,V8还是用来内联缓存的方式优化js这种动态语言代码。内联缓存依赖于对相同类的对象的重复调用往往发生在相同类的对象上这一常见的现象。即A类的对象往往会重复调用B类的对象,V8会维护一个作为参数传递的对象类型的缓存,并且以B类为假设的对象类型。之后如果判断得到V8猜想正确,那么就可以绕过访问对象属性的过程。

每当在特定对象上调用方法时,V8引擎必须执行对该对象的隐藏类的查找,以确定访问特定属性的偏移量。在将同一方法成功调用两次到同一个隐藏类之后,V8省略了隐藏类查找,只是将属性的偏移量添加到对象指针本身。对于该方法的所有未来调用,V8引擎假定隐藏类未更改,并使用先前查找中存储的偏移直接跳转到特定属性的内存地址。这大大提高了执行速度。

新的管道机制

新的执行管道建立在Ignition,V8的解释器和TurboFan(V8的最新优化编译器)之上。也大大提升了性能。以下两张图是在引入Ignition前后,V8对于管道的使用过程。


参考:

  • chrome官方文档:https://docs.google.com/
    -https://docs.google.com/presentation/d/1chhN90uB8yPaIhx_h2M3lPyxPgdPmkADqSNAoXYQiVE/edit#slide=id.g1ba7f92079_5_29
  • 《webkit技术内幕》
  • https://blog.csdn.net/allen8612433/article/details/80329022
  • https://blog.csdn.net/zhangge3663/article/details/83310757

发表评论