【译】JavaScript 运行原理(二): 了解V8引擎 & 如何优化代码的5个技巧

原文链接 How JavaScript works: an overview of the engine, the runtime, and the call stack by Alexander Zlatkovjavascript

几周以前咱们开始了一系列的文件旨在深刻了解JavaScript和它是如何运行的:咱们认为,经过了解JavaScript的组成部分以及它们如何一块儿发挥做用,你可以编写出更好的代码和应用。html

这一系列的第一部提供了引擎,运行时和调用栈的概览。第二篇将深刻了解V8引擎。咱们还将提供一些有关如何编写更好的JavaScript代码的快速提示,这也是咱们SessionStack(做者所在公司)开发团队在构建产品时遵循的最佳作法。java

概述

JavaScript引擎是执行JavaScript代码的程序或解释器。 JavaScript引擎能够实现为标准解释器,也能够做为即时编译器将JavaScript编译为某种形式的字节码。git

如下列表是实现了JavaScript引擎的流行项目github

  • V8 — 开源,由Chrome 开发,使用C++编写
  • Rhino — 由Mozilla 基金会管理,开源,使用java编写
  • SpiderMonkey — 第一个JavaScript 引擎,过去支持Netscape Navigator,如今支持FireFox
  • JavaScriptCore — 开源, 由Nitro销售,由Apple为Safari开发
  • KSJ — Harde Porten最初为KDE项目的Konqueror网络浏览器开发的KDE引擎
  • CHakra(JScript9) — Internet Explore
  • CHakra(JavaScript) — Microsoft Edge (新版的基于Chromium, 也就是JavaScript 引擎使用的是V8)
  • Nashorn — 由OpenJDK部分开源,由Oracle Java and Tool 组编写
  • JerryScript — 物联网轻量级引擎

一. 为何要开发V8引擎?

V8引擎是由Google创建的开源工程,使用C++编写。该引擎在Chrome内部使用。和其余引擎不同,V8也被用于流行的Node.js中。编程

V8最初旨在提升Web浏览器中JavaScript执行的性能。为了提高速度,V8将JavaScript代码转换为更有效的机器代码,而不是使用解释器。它经过像许多现代JavaScript引擎(例如SpiderMonkey或Rhino(Mozilla))同样实现JIT(即时)编译器,在执行时将JavaScript代码编译为机器代码。这里的主要区别是V8不会产生字节码或任何中间码。

二. V8 使用的两种编译器

在V8的5.9版本出来以前,引擎使用两种编译器(后续版本中已经使用TurboFan 替代了前二者, 这里终于原文进行翻译,最新的编译器你们请点前面的链接来学习):数组

  • full-codegen — 一个简单可是快速的编译器,产生了简单的相对慢的机器码
  • GrankShaft — 一个复杂的(Just-In-time)优化的编译器,产生了高度优化的代码

V8引擎在内部使用了多线程:浏览器

  • 主线程作那些你指望的任务:拉去你的代码,编译和执行
  • 另外有一个独立的线程用于编译,所以线程正在优化代码的时候主线程任然能够继续执行
  • 探查器线程(Profiler threads)能够告诉运行时(runtime)哪些方法会花大量的时间以便于Grankshaft优化它们
  • 一些线程来处理垃圾回收器(GarBage Collector)的清扫工做线程正在优化代码

当首次执行JavaScript代码的时候,V8的full-codegen直接原模原样的转义解析了的JavaScript代码为机器码。这使得它能快速的执行机器码。请注意,V8不会以这种方式使用中间字节码表示,从而无需解释器。缓存

当你的代码执行了一段时间,探查器线程聚集了足够的数据肯定那个方法须要被优化安全

下一步,Grankshaft 开始在另一个线程进行优化。它将JavaScript抽象语法树转换为称为Hydrogen的高级静态单分配(SSA)表示形式,并尝试优化该Hydrogen图。大多数优化都在此级别上完成。

图片地址: v8.dev/blog/igniti…

三. 内联

第一个优化是尽量的提早内联尽量多的代码。内联是将调用部分(调用函数的代码行)替换为被调用函数的主体的过程。这个简单的步骤可以使后续优化变得更有意义。

四. 隐藏类

JavaScript是基于原型的语言:没有使用克隆建立的类和对象。 JavaScript仍是一种动态编程语言,这意味着能够在实例化对象后轻松地添加或删除属性。

大多数JavaScript解释器使用类字典的结构(基于哈希函数)将对象属性值的位置存储在内存中。与非动态编程语言(例如Java或C#)相比,在JavaScript使用这种结构检索属性的值在计算上更加昂贵。在Java中,全部对象属性都是在编译以前由固定的对象结构肯定的,而且没法在运行时动态添加或删除(C#具备动态类型,这是另外一个主题)。结果就是,属性的值(或指向这些属性的指针)能够做为连续缓冲区存储在内存中,而且在每一个缓冲区之间具备固定偏移量。能够根据属性类型轻松肯定偏移量的长度,可是在JavaScript中这是不可能的,由于JavaScript在运行时能够更改属性类型。

因为使用字典查找对象属性在内存中的位置效率很低,所以V8改用了另外一种方法:隐藏类。隐藏类的工做方式相似于Java之类的语言中使用的固定的对象结构(类),但它们是在运行时建立的。如今,让咱们看看它们的实际外观:

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

一旦"new Point(1,2)" 执行了,V8将会建立一个隐藏的类称之为“C0”

C0类上尚未任何属性,因此"C0"是空的。

第一条语句“this.x = x”执行(在Point 函数内部),V8将会基于“ C0”建立第二个隐藏类“C1”,“C1”描述了能够找到X属性的内存位置(至关于对象指针)。在这个例子中,“x”能够存储的偏移量是0,也就是说能够把在内存中的Point对象看为一个连续的缓冲区,第一个偏移值就与“x”相对应。若是属性"x"添加到对象"point"中,V8也会经过“类转换(class transition)”更新"C0",将隐藏类从“C0”转换到“C1”。如今隐藏类变成了“C1”。

每次新属性添加到对象中,旧的隐藏类将会转换为新的隐藏类。隐藏类转换很是重要,由于它们容许隐藏类在以相同方式建立的对象之间进行共享。若是两个对象共享一个隐藏类,而且向它们两个都添加了相同的属性,则类转换将确保两个对象都接收到相同的新隐藏类以及后续的优化代码。

当语句“this.y = y” (在Point 函数内部,this.x=x后面)被执行的时候,上诉步骤会重复执行。

新的隐藏类“C2”被建立,“类转换”将被应用于“C1”,此时属性“y”被加入Point对象中(该对象已经包含了属性x)。隐藏类从“C1“转换到“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是不一样的隐藏类和不一样的类转换。在这种状况下,最好以相同的顺序初始化动态属性,以便复用隐藏类。

五. 内联缓存

V8充分利用另外一种称为“内联缓存”的优化动态语言的技术。内联缓存依赖于这种情形:对相同方法的调用每每发生在相同类型的对象上。

内联缓存的深刻解释请参考这里

咱们将介绍内联缓存的通常概念(以防你没有时间了解上述的深刻说明)

它是如何工做的?V8维护了在最近的方法调用中做为参数传递的对象类型的缓存。使用此信息能够对未来做为参数传递的对象类型作出假设。若是V8可以很好地假设将来将传递给方法的对象的类型,则它能够绕开找出如何访问对象属性的过程,而可使用先前查找到的对象的隐藏类的存储信息(该信息能够经过偏移量访问属性)。

隐藏类和内联缓存是如何相关联的?当在某个对象上调用方法的时候,V8引擎会查询对象的隐藏类去决定是否使用偏移量访问对象的属性。若是两次调用了一样的方法到一样的隐藏类。在对相同的隐藏类成功调用同一方法两次以后,V8会省略了隐藏类查找,只是将属性的偏移量添加到对象指针自己。对于之后使用该方法的全部调用,V8引擎都假定隐藏类未更改,并使用之前查找中存储的偏移量直接跳转到特定属性的内存地址。这大大提升了执行速度。

举例(注:由于做者这一段说的比较模糊,因此参考了其余文章添加了一个例子,原文中并无该示例):

function getX(o) {
	return o.x;
}
复制代码

若是是JSC(JavaScriptCore和V8同样是JS引擎,见上文),会生成一下字节码。

其中函数getX的第一个指令 get_by_id从第一个参数arg1加载属性"x",而且把它存储到结果“loc0”中,第二个指令返回存储的“loc0”。

JSC还将内联缓存嵌入到get_by_id指令中,该指令由两个未初始化的插槽组成。其中Shape和上文提到的内联类同样,只是表达方式不同,它属于SpiderMonkey。

如今假设咱们使用对象{x:'a'}调用getX。该对象的Shape具备属性“x”,而且Shape存储该属性x的偏移量和属性。首次执行该函数时,get_by_id指令查找属性“x”并发现该值存储在偏移量0处。

对于后续运行,内敛缓存仅须要比较Shape,若是与之前相同,则只需从存储的偏移量加载值便可。

内联缓存也是为何同类型的对象共享隐藏类如此重要的缘由。若是你建立了两个相同类型的对象可是却有不一样的隐藏类(咱们以前提到过的)。即便两个类具备相同的类型,V8将不会使用内联缓存,由于他们的隐藏类为同一个属性分配了不一样的偏移量。

六. 编译为机器码

一旦Hydrogen图被优化,Crankshaft将其下降为较低级别的表示形式称为Lithium。大多数的Lithium的实现是特定结构。寄存器分配也发生在此级别。

最终,Lithium编译为机器码。 而后称之为OSR的开始执行堆叠替换(on-stack replacement)。在咱们编译和优化明显长的方法的时候,会先运行它。V8不会忘记重启优化后的代码的时候执行会变慢。因此,它会转换咱们全部的上下文(堆,寄存器),以便咱们能够在执行过程当中切换到优化版本。这是一个很是复杂的任务,请记住,在全部的优化中,V8已经在最开始内联了代码。V8不是惟一可以作到这一点的引擎。

有一种称为反优化的保护措施,能够进行相反的转换,并在假设引擎再也不适用的状况下还原为未优化的代码。

七. 垃圾回收

对于垃圾收集,V8使用标记清除的传统世代方法来清理旧一代的对象。标记阶段应该中止JavaScript执行。为了控制GC成本并使执行更加稳定,V8使用了增量标记:不是遍历整个堆而是尝试标记每一个可能的对象,而是遍历堆的一部分,而后恢复正常执行。下一个GC将从上一个堆遍历中止的位置继续。这容许在正常执行期间很是短的暂停。如前所述,清除阶段由单独的线程处理。

八. Ignition and TurboFan

随着2017年初V8 5.9的发布,引入了新的执行管道。这个新的管道在实际的JavaScript应用程序中实现了更大的性能改进并显著节省了内存。

新的执行管道基于Ignition,V8解释器,TurboFan,V8最新优化的编译器。

你能够从V8团队的文章中查看。

自从V8 5.9发布以来full-codegen和Crankshaft (从2010年开发服务v8的技术)不在被v8用于Javascript的执行。 v8团队一直在努力跟上新的JavaScript语言功能以及这些功能所需的优化。

这意味着整个V8未来将具备更加简单和可维护的体系结构。

这些提升仅仅是开始, 新的Ignition和TurboFan管道为进一步优化铺平了道路,这些优化将在将来几年内提升JavaScript性能并缩小V8在Chrome和Node.js中的占用空间。

最后,这是有关如何编写通过优化的JavaScript的一些技巧。你能够从上文中轻松获得这些内容,可是,为方便起见,如下是摘要:

  1. 给对象属性定义一个顺序: 始终以相同的顺序实例化对象属性,以便于能够共享隐藏类和后续优化的代码
  2. 动态属性(Dynamic properties): 在实例化对象以后给对象添加属性将会强制更改隐藏类和并减慢为先前的隐藏类优化的全部方法的执行速度。最好,在对象的构造函数中分配其全部属性。
  3. Methods: 重复执行相同的代码比执行不一样的代码各一次要快的多(因为内联缓存)。
  4. 数组: 避免那些键不是增量数字的稀疏数组。没有填满元素的稀疏数组是一个哈希表。这种数组中的元素访问起来代价更昂贵。另外,请尝试避免预先分配大数组。随你的需求增加最好。最后,不要删除数组中的元素。它使键稀疏。
  5. 标签值(Tagged values): V8用32位表示对象和数字。它使用一个位来知道它是一个对象(flag= 1)仍是一个称为SMI(小整数)的整数(flag= 0),由于它有31位。而后,若是数值大于31位,则V8会将封装该数字,将其变成双精度并建立一个新对象以将数字放入其中。尽量使用31位带符号的数字,以免对JS对象进行昂贵的封装操做。

九. 本系列其余文章

  1. 关于引擎,运行时,调用栈的概述
  2. 深刻了解V8引擎 & 如何写出最优代码的5个提示
  3. 内存管理 & 如何处理4种常见的内存泄露
  4. 事件循环机制和异步编程的兴起 & 经过async/await更好的编码的5种方法
  5. 经过SSE深刻了解WebSockets和HTTP2 & 如何选择正确的路径
  6. 对比WebAssembly & 为何某种状况下它要优于JavaScript
  7. Web Workers的构 & 你须要用都它的5种状况
  8. Service Workers,它的生命周期和使用案例
  9. Web Push Notifications的机制
  10. 经过MutatioinObserver跟踪DOM的变化
  11. 渲染引擎和优化技巧
  12. 深刻了解网络层 & 性能优化和安全性
  13. 理解CSS和JS动画的内部原理 & 性能优化
  14. 解析,抽象语法树(ASTs) & 如何优化解析时间
  15. 类和继承的内部原理 & Babel和TypeScript转义(transpiling)
  16. Storage 引擎 & 如何选择合适的存储API
  17. Shadow Dom 的内部原理 & 如何构建独立的组件
  18. WebRTC和对等网络机制
相关文章
相关标签/搜索