原文连接 How JavaScript works: an overview of the engine, the runtime, and the call stack by Alexander Zlatkov
做个广告:欢迎大家访问我们持续维护的React组件库react.kingdee.design/
几周之前我们开始了一系列的文件旨在深入了解JavaScript和它是如何运行的:我们认为,通过了解JavaScript的组成部分以及它们如何一起发挥作用,你能够编写出更好的代码和应用。
这一系列的第一部提供了引擎,运行时和调用栈的概览。第二篇将深入了解V8引擎。我们还将提供一些有关如何编写更好的JavaScript代码的快速提示,这也是我们SessionStack(作者所在公司)开发团队在构建产品时遵循的最佳做法。
概述
JavaScript引擎是执行JavaScript代码的程序或解释器。 JavaScript引擎可以实现为标准解释器,也可以作为即时编译器将JavaScript编译为某种形式的字节码。
以下列表是实现了JavaScript引擎的流行项目
- 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的一些技巧。你可以从上文中轻松得到这些内容,但是,为方便起见,以下是摘要:
- 给对象属性定义一个顺序: 始终以相同的顺序实例化对象属性,以便于可以共享隐藏类和后续优化的代码
- 动态属性(Dynamic properties): 在实例化对象之后给对象添加属性将会强制更改隐藏类和并减慢为先前的隐藏类优化的所有方法的执行速度。最好,在对象的构造函数中分配其所有属性。
- Methods: 重复执行相同的代码比执行不同的代码各一次要快的多(由于内联缓存)。
- 数组: 避免那些键不是增量数字的稀疏数组。没有填满元素的稀疏数组是一个哈希表。这种数组中的元素访问起来代价更昂贵。另外,请尝试避免预先分配大数组。随你的需求增长最好。最后,不要删除数组中的元素。它使键稀疏。
- 标签值(Tagged values): V8用32位表示对象和数字。它使用一个位来知道它是一个对象(flag= 1)还是一个称为SMI(小整数)的整数(flag= 0),因为它有31位。然后,如果数值大于31位,则V8会将封装该数字,将其变成双精度并创建一个新对象以将数字放入其中。尽可能使用31位带符号的数字,以避免对JS对象进行昂贵的封装操作。
九. 本系列其他文章
- 关于引擎,运行时,调用栈的概述
- 深入了解V8引擎 & 如何写出最优代码的5个提示
- 内存管理 & 如何处理4种常见的内存泄露
- 事件循环机制和异步编程的兴起 & 通过async/await更好的编码的5种方法
- 通过SSE深入了解WebSockets和HTTP2 & 如何选择正确的路径
- 对比WebAssembly & 为什么某种情况下它要优于JavaScript
- Web Workers的构 & 你需要用都它的5种情况
- Service Workers,它的生命周期和使用案例
- Web Push Notifications的机制
- 通过MutatioinObserver跟踪DOM的变化
- 渲染引擎和优化技巧
- 深入了解网络层 & 性能优化和安全性
- 理解CSS和JS动画的内部原理 & 性能优化
- 解析,抽象语法树(ASTs) & 如何优化解析时间
- 类和继承的内部原理 & Babel和TypeScript转义(transpiling)
- Storage 引擎 & 如何选择合适的存储API
- Shadow Dom 的内部原理 & 如何构建独立的组件
- WebRTC和对等网络机制