【译】JavaScript是如何工作的:V8引擎+5个关于如何编写优化代码的小技巧

前端面试官2019-03-05 11:16:13

JavaScript是如何工作的:V8引擎+5个关于如何编写优化代码的小技巧


如果你真的很赶时间,可以直接跳到倒数第二节,【如何编写优化的JavaScript】

几周前,我们开始了一系列旨在深入研究JavaScript及其实际工作方式的系列文章:我们认为,通过了解JavaScript的构建块以及它们如何一起工作,您将能够编写更好的代码和应用程序。


本系列的第一篇文章重点介绍了引擎,运行时和调用堆栈的概述。 第二篇文章将深入Google V8 JavaScript引擎的内部部分。 我们还会提供一些关于如何编写更好的JavaScript代码的快速技巧 - 我们的SessionStack开发团队在构建产品时遵循的最佳实践。


概览

JavaScript引擎是执行JavaScript代码的程序或解释器。 JavaScript引擎可以作为标准解释器或即时编译器来实现,该编译器以某种形式将JavaScript编译为字节码。


这是一个流行JavaScript引擎项目的列表:


【V8https://en.wikipedia.org/wiki/V8_%28JavaScript_engine%29

开放源码,由Google开发,用C ++编写


【Rhino】https://en.wikipedia.org/wiki/Rhino_%28JavaScript_engine%29

由Mozilla基金会管理,开源,完全用Java开发


SpiderMonkey】https://en.wikipedia.org/wiki/SpiderMonkey_%28JavaScript_engine%29

SpiderMonkey是第一个JavaScript引擎,它在当时支持Netscape Navigator,并且现在支持Firefox


JavaScriptCore】https://en.wikipedia.org/wiki/JavaScriptCore

JavaScriptCore - 开放源代码,作为Nitro销售,由Apple为Safari开发


KJS】https://en.wikipedia.org/wiki/KJS_%28KDE%29

 KDE的引擎最初由Harri Porten为KDE项目的Konqueror网络浏览器开发


Chakra(JScript9)】https://en.wikipedia.org/wiki/Chakra_%28JScript_engine%29

Internet Explorer


【Chakra(JavaScript)】https://en.wikipedia.org/wiki/Chakra_%28JavaScript_engine%29

Microsoft Edge


Nashorn】https://en.wikipedia.org/wiki/Nashorn_%28JavaScript_engine%29

作为OpenJDK的一部分的Nashorn,由Oracle Java语言和工具组编写


【JerryScript】https://en.wikipedia.org/wiki/JerryScript

是物联网的轻量级引擎。


为什么会有V8引擎?

由Google构建的V8引擎是开源的,并以C ++编写。 此引擎在Google Chrome中使用。 然而,与其他引擎不同,V8还用于流行的Node.js运行时。

V8最初设计用于提高Web浏览器内部JavaScript执行的性能。 为了获得速度,V8将JavaScript代码转换为更高效的机器代码,而不是使用解释器。 它通过实现JIT(Just-In-Time)编译器(如SpiderMonkey或Rhino(Mozilla)等许多现代JavaScript引擎)将JavaScript代码编译为机器代码。 这里的主要区别在于V8不生成字节码或任何中间代码。



V8曾经有两个编译器

在V8.5版本出台之前(今年早些时候发布),该引擎使用了两种编译器:


【full-codegen】 一个简单而且速度非常快的编译器,可以生成简单且相对较慢的机器代码。


【Crankshaft】一种更复杂(Just-In-Time)的优化编译器,可以生成高度优化的代码。


V8引擎还在内部使用多个线程:


【1】- 主线程完成你期望的任务:获取你的代码,编译并执行它

【2】- 还有一个单独的线程用于编译,这样主线程可以继续执行,而前者正在优化代码

【3】- 一个Profiler线程,它会告诉运行时我们花费了很多时间以使Crankshaft能够优化它们

【4】- 一些线程处理垃圾收集器扫描


当首次执行JavaScript代码时,V8利用完整代码,直接将解析的JavaScript翻译成机器代码而不需要任何转换。 这使它可以非常快速地开始执行机器代码。 请注意,V8不使用中间字节码表示法,不需要解释器。


当您的代码运行一段时间后,分析器线程已经收集了足够的数据以确定哪种方法应该进行优化。


接下来,Crankshaft优化从另一个线程开始。 它将JavaScript抽象语法树翻译为称为Hydrogen的 high-level static single-assignment(SSA)表示,并尝试优化该Hydrogen图。 大多数优化都是在这个级别完成的。


内联

第一次优化是提前尽可能多地嵌入代码。 内联是将被调用函数的主体替换为调用网站(调用该函数的代码行)的过程。 这个简单的步骤可以让以下优化变得更有意义。



隐藏类

JavaScript是一种基于原型的语言:没有类,使用克隆过程创建对象。 JavaScript也是一种动态编程语言,它意味着属性可以在实例化后轻松添加或从对象中移除。


大多数JavaScript解释器使用字典式结构(基于散列函数)来存储对象属性值在内存中的位置。 这种结构使得检索JavaScript中的属性的值比在Java或C#等非动态编程语言中的计算更昂贵。 在Java中,所有对象属性都是在编译之前由固定的对象布局确定的,并且不能在运行时动态添加或删除(当然,C#的动态类型是另一个主题)。 因此,属性的值(或指向这些属性的指针)可以作为连续缓冲区存储在内存中,每个值之间都有一个固定偏移量。 偏移量的长度可以根据属性类型轻松确定,但在运行时可以更改属性类型的JavaScript中不可行。


由于使用字典查找内存中对象属性的位置效率非常低,因此V8使用不同的方法:隐藏类。 隐藏类的工作方式与Java等语言中使用的固定对象布局(类)类似,除了它们是在运行时创建的。 现在,让我们看看他们实际的样子:

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


一旦“新Point(1,2)”调用发生,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”(同样,在“this.x = x”语句之后的Point函数内部)时,将重复此过程。


创建一个名为“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”以不同的隐藏类结束。 在这种情况下,以相同顺序初始化动态属性好得多,以便隐藏的类可以重用。


内联缓存

V8利用另一种技术来优化称为内联缓存的动态类型化语言。 内联缓存依赖于观察到对相同方法的重复调用倾向于发生在相同类型的对象上。 在这里(https://github.com/sq/JSIL/wiki/Optimizing-dynamic-JavaScript-with-inline-caches)可以找到关于内联缓存的深入解释。


我们将讨论内联缓存的一般概念(如果您没有时间通过上面的深入解释)。


那么它是怎样工作的? V8维护一个对象类型的缓存,这些对象在最近的方法调用中作为参数传递,并使用这些信息来预测将来作为参数传递的对象的类型。 如果V8能够对传递给方法的对象的类型做出很好的假设,那么它可以绕过确定如何访问对象属性的过程,而将先前查找中存储的信息用于对象的 隐藏类。


那么隐藏类和内联缓存的概念如何相关? 无论何时在特定对象上调用方法,V8引擎都必须执行对该对象的隐藏类的查找,以确定访问特定属性的偏移量。 在相同隐藏类的两次成功调用之后,V8省略了隐藏类查找,并简单地将该属性的偏移量添加到对象指针本身。 对于该方法的所有未来调用,V8引擎都假定隐藏的类没有更改,并使用从以前的查找存储的偏移量直接跳转到特定属性的内存地址。 这大大提高了执行速度。


内联缓存也是为什么相同类型的对象共享隐藏类非常重要的原因。 如果您创建两个具有相同类型和不同隐藏类的对象(就像我们之前的示例中那样),V8将无法使用内联缓存,因为即使这两个对象的类型相同,它们对应的隐藏类 为其属性分配不同的偏移量。


这两个对象基本相同,但“a”和“b”属性的创建顺序不同。



编译为机器码

一旦Hydrogen graph被优化,Crankshaft将其降低到称为Lithium的较低级表示。 大部分的Lithium实施都是特定于架构的。 寄存器分配发生在这个级别。


最终,Lithium被编译成机器码。 然后发生其他事情,称为OSR:堆栈替换。 在我们开始编译和优化明显长期运行的方法之前,我们可能会运行它。 V8不会忘记它刚刚缓慢执行的内容,以再次优化版本开始。 相反,它会转换我们拥有的所有上下文(堆栈,寄存器),以便我们可以在执行过程中切换到优化版本。 这是一项非常复杂的任务,考虑到除了其他优化之外,V8最初还是将代码内联。 V8不是唯一能够做到这点的引擎。


垃圾收集

对于垃圾收集,V8采用传统的分代式扫描方式来清理老一代。 标记阶段应该停止JavaScript执行。 为了控制GC成本并使执行更加稳定,V8使用增量标记:不是遍历整个堆,而是试图标记每个可能的对象,它只走过堆的一部分,然后恢复正常执行。 下一个GC停止将从先前堆走过的地方继续。 这允许在正常执行期间非常短的暂停。 如前所述,扫描阶段由单独的线程处理。



Ignition和TurboFan

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


新的执行流程建立在Ignition(https://github.com/v8/v8/wiki/Interpreter),V8的解释器和TurboFan(https://github.com/v8/v8/wiki/TurboFan),V8的最新优化编译器之上。


您可以查看V8团队关于此主题的博客文章(https://v8project.blogspot.bg/2017/05/launching-ignition-and-turbofan.html)。


自从V8.5版本问世以来,V8团队一直在努力跟上新的JavaScript语言功能,而V8团队已经不再使用V8版本的全代码和Crankshaft(自2010年以来服务V8的技术) 这些功能需要进行优化。


这意味着整体V8将有更简单和更可维护的架构。


Web和Node.js基准的优化



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


最后,这里有一些关于如何编写优化的,更好的JavaScript的技巧和窍门。 你可以很容易地从上面的内容中获得这些内容,但是,下面是一个简要的摘要,以方便你:


如何编写优化的JavaScript

【1.对象属性的顺序

始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码。


【2.动态属性

在实例化之后向对象添加属性将强制隐藏类更改并减慢为先前隐藏类优化的所有方法。相反,在其构造函数中分配所有对象的属性。


【3.方法

重复执行相同方法的代码将比仅执行一次的许多不同方法的代码运行得更快(由于内联缓存)。


【4.数组

避免稀疏数组,其中的键不是增量数字。稀疏数组中没有每个元素都是哈希表。这种阵列中的元素访问费用较高。另外,尽量避免预分配大型数组。随着你的成长,成长会更好。最后,不要删除数组中的元素。它使密钥稀疏。


【5.标记值

V8表示32位的对象和数字。由于它的31位,它使用一点来知道它是一个对象(flag = 1)还是一个称为SMI(SMall Integer)的整数(flag = 0)。然后,如果数字值大于31位,V8会将该数字框起来,将其变成双精度值并创建一个新对象以将该数字放入其中。尝试尽可能使用31位有符号数字以避免将昂贵的装箱操作转换为JS对象。


我们在SessionStack尝试在编写高度优化的JavaScript代码时遵循这些最佳实践。 原因是,一旦将SessionStack集成到生产Web应用程序中,它就会开始记录所有内容:所有DOM更改,用户交互,JavaScript异常,堆栈跟踪,失败的网络请求和调试消息。

借助SessionStack,您可以将视频中的问题重播为视频,并查看发生在用户身上的一切。 所有这些都必须在您的网络应用没有性能影响的情况下发生。


有一个免费的计划,可以让你免费开始(https://www.sessionstack.com/signup/)。



相关资源

【1】https://docs.google.com/document/u/1/d/1hOaE7vbwdLLXWj3C8hTnnkpE0qSa2P--dtDvwXXEeD0/pub

【2】https://github.com/thlorenz/v8-perf

【3】http://code.google.com/p/v8/wiki/UsingGit

【4】http://mrale.ph/v8/resources.html

【5】https://www.youtube.com/watch?v=UJPdhx5zTaw

【6】https://www.youtube.com/watch?v=hWhMKalEicY



我不得不说的是,你感觉可能是灵光一闪恍然大悟了,但是要知道很多技巧你已经再用了,要知道很多技巧你可能没什么机会用到,还要知道JS的新语法很可能让v8之前的一些优化没有用了。但是再说回来也还是跟有用的,看完这个系列篇之后知识面的扩展还是很明显的,而且相信没有几个人愿意跟你讨论JS了,因为就在吹牛这方面他们应该都比不过你了




Copyright © 古田计算器虚拟社区@2017