几周前,我们开始写旨在深入挖掘 JavaScript 及其工作机制的一系列文章:我们认为,通过了解 JavaScript 的构造单元以及这些构造单元如何组织在一起,您就能够编写更好的代码和应用程序.
该系列的第一篇文章 重点是提供一个对引擎,运行时和调用栈的概述.这第二篇文章将会深入 Google V8 JavaScript 引擎的内部.我们还将提供如何编写更佳 JavaScript 代码的一些小技巧 - 这也是我们 SessionStack 开发团队在构建产品时遵循的最佳实践.
概述
JavaScript 引擎是一个执行 JavaScript 代码的程序或解释器.JavaScript 引擎可以被实现为标准解释器,或者实现为以某种形式将 JavaScript 编译为字节码的即时编译器.
下面是实现了 JavaScript 引擎的一个热门项目列表:
V8 - 开源,由 Google 开发,用 C++ 编写的
Rhin o - 由 Mozilla 基金所管理,开源,完全用 Java 开发
SpiderMonkey -第一个 JavaScript 引擎,最早用在 Netscape Navigator 上,现在用在 Firefox 上.
JavaScriptCore - 开源,以 Nitro 销售,由苹果公司为 Safari 开发
KJS -KDE 的引擎最初由 Harri Porten 开发,用于 KDE 项目的 Konqueror 浏览器
Chakra (JScript9) - Internet Explorer
Chakra (JavaScript) - Microsoft Edge
Nashorn - 开源为 OpenJDK 的一部分,由 Oracle 的 Java 语言和工具组开发
JerryScript - 是用于物联网的轻量级引擎
创建 V8 引擎的由来
Google 构建的 V8 引擎是开源的,用 C++ 编写的.该引擎被用在 Google Chrome 中.不过,与其他引擎不同的是,V8 还被用作很受欢迎的 Node.js 的运行时.
V8 最初是设计用来提升 web 浏览器中 JavaScript 执行的性能.为了获得速度,V8 将 JavaScript 代码转换为更高效的机器码,而不是使用解释器.它通过实现像很多现代 JavaScript 引擎(比如 SpiderMonkey 或 Rhino)所用的 JIT(即时)编译器,从而将 JavaScript 代码编译成机器码.这里主要区别在于 V8 不会产生字节码或任何中间代码.
V8 曾经有两个编译器
在 V8 的 5.9 版(今年早些时候发布)出现之前,V8 引擎用了两个编译器:
full-codegen - 一个简单而超快的编译器,可以生成简单而相对较慢的机器码.
Crankshaft - 一个更复杂(即时)的优化的编译器,可以生成高度优化的代码.
V8 引擎还在内部使用多个线程:
主线程执行我们想让它干的活:获取代码,编译然后执行它
还有一个单独的线程用于编译,这样在主线程继续执行的同时,单独的线程能同时在优化代码
一个 Profiler 线程,用于让运行时知道哪些方法花了大量时间,这样 Crankshaft 就可以对它们进行优化
几个线程用于处理垃圾收集器清扫
第一次执行 JavaScript 代码时,V8 会利用 full-codegen 直接将解析的 JavaScript 翻译为机器码,而无需任何转换.这就让它能非常快地开始执行机器码.请注意,由于 V8 不会使用中间字节码表示,这样就无需解释器.
代码运行了一段时间后,Profiler 线程已经收集了足够的数据来判断应该优化哪个方法.
接下来, Crankshaft 优化 从另一个线程中开始.它将 JavaScript 抽象语法树翻译为称为 Hydrogen 的高级静态单赋值(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);
一旦 new Point(1, 2) 调用发生了,V8 就会创建一个称为 C0 的隐藏类.
因为还没有给 Point 定义属性,所以 C0 为空.
一旦执行了第一条语句 this.x = x (在 Point 函数中),V8 就会创建一个基于 C0 的第二个隐藏类 C1 . C1 描述了内存中的位置(相对于对象指针),属性 x 在这个位置可以找到.此时, x 存储在偏移地址 0 处,就是说,当将内存中的 point 对象作为连续缓冲器来查看时,第一个偏移地址就对应于属性 x .V8 也会用 "类转换" 来更新 C0 ,指出如果将一个属性 x 添加到点对象,那么隐藏类应该从 C0 切换到 C1 .下面的 point 对象的隐藏类现在是 C1 .
每当向对象添加一个新属性时,旧的隐藏类就被用一个转换路径更新为新的隐藏类.隐藏类转换很重要,因为它们可以让隐藏类在以相同方式创建的对象之间共享.如果两个对象共享一个隐藏类,并且将相同的属性添加到这两个对象中,那么转换会确保两个对象都接收到相同的新隐藏类和它附带的所有优化过的代码.
当执行语句 this.y = y (同样是在 Point 函数内部, this.x = x 语句之后)时,会重复此过程.
这时,又创建一个名为 C2 的新隐藏类,类转换被添加到 C1 ,表示如果将属性 y 添加到 Point 对象(已包含属性 x ),那么隐藏类应更改为 C2 ,同时 point 对象的隐藏类被更新为 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 利用另一种称为内联缓存(inline caching)的技术来优化动态类型语言.内联缓存来自于观察的结果:对同一方法的重复调用往往发生在同一类型的对象上.关于内联缓存的深入解释可以在 这里 找到.
下面我们打算谈谈内联缓存的一般概念(如果您没有时间阅读上面的深入解释的话).
那么它是如何工作的呢?V8 维护在最近的方法调用中作为参数传递的对象类型的缓存,并使用该信息对将来作为参数传递的对象类型做出假设.如果 V8 能够对传递给方法的对象类型做出一个很好的假设,那么它可以绕过算出如何访问对象的属性的过程,转而使用先前查找对象的隐藏类时所存储的信息.
那么隐藏类和内联缓存的概念是如何关联的呢?无论何时在特定对象上调用方法,V8 引擎必须对该对象的隐藏类执行查找,以确定访问特定属性的偏移地址.在对同一个隐藏类的同一方法进行了两次成功的调用之后,V8 就省掉了隐藏类查找,只将属性的偏移地址添加到对象指针本身上.对于所有将来对该方法的调用,V8 引擎都会假定隐藏类没有改变,并使用先前查找中存储的偏移地址直接跳转到特定属性的内存地址.这会大大提高执行速度.
内联缓存也是为什么同一类型的对象共享隐藏类非常重要的原因.如果您创建相同类型的两个对象,但是用的是不同的隐藏类(如前面的示例),那么 V8 将无法使用内联缓存,因为即使两个对象的类型相同,但是它们的对应隐藏类也会为其属性分配不同的偏移地址.
两个对象基本相同,但是 "a" 和 "b" 属性是按照不同的顺序创建的.
编译到机器码
一旦 Hydrogen 图被优化,Crankshaft 将其降低到一个称为 Lithium 的较低级别表示.大多数 Lithium 实现都是针对架构的.寄存器分配发生在这一级.
最后,Lithium 被编译成机器码.然后其他事情,也就是 OSR(当前栈替换,on-stack replacement),发生了.在我们开始编译和优化一个明显要长期运行的方法之前,我们可能会运行它.V8 不会蠢到忘记它刚刚慢慢执行的代码,所以它不会再用优化版本又执行一遍,而是将转换所有已有的上下文(栈,寄存器),以便我们可以在执行过程中间就切换到优化版本.这是一个非常复杂的任务,请记住,除了其他优化之外,V8 最开始时已经内联了代码.V8 并非唯一能够做到这一点的引擎.
有一种称为去优化的保护措施,会作出相反的转换,并恢复为非优化代码,以防引擎的假设不再成立.
垃圾回收
对于垃圾回收来说,V8 采用的是标记,清扫这种传统分代方式来清除旧一代.标记阶段应该停止执行 JavaScript.为了控制 GC 成本,并使执行更加稳定,V8 使用增量式标记:不是遍历整个堆,尝试标记每一个可能的对象,而是只遍历一部分堆,然后恢复正常执行.下一个 GC 停止会从之前的堆遍历停止的地方继续.这就允许在正常执行期间有非常短的暂停.如前所述,清扫阶段是由单独的线程处理.
Ignition 和 TurboFan
随着 2017 年早些时候版本 5.9 的发布,V8 引入了一个新的执行管道.这个新的管道在真实的 JavaScript 应用程序中实现了更大的性能提升和显著的内存节省.
这个新的执行管道建立在 V8 的解释器 Ignition 和 V8 的最新优化编译器 TurboFan 之上.
您可以在 这里 查看 V8 团队关于这个主题的博文.
自从 5.9 版本发布以来,V8 不再用 full-codeget 和 Crankshaft(自 2010 年以来 V8 所用的技术)执行 JavaScript,因为 V8 团队一直在努力跟上新的 JavaScript 语言特性,而这些特性需要优化.
这意味着 V8 整体下一步会有更简单和更易维护的架构.
在 Web 和 Node.js 基准测试上的提升
这些提升仅仅是开始.新的 Ignition 和 TurboFan 管道为进一步优化铺平了道路,这将在未来几年内促进 JavaScript 性能提升,并缩小 V8 在 Chrome 和 Node.js 中所占比重.
最后,这里有一些关于如何编写良好优化,更佳的 JavaScript 的诀窍.当然,从上面的内容不难得到这些诀窍,不过,为了方便起见,这里还是给出一个摘要:
如何编写优化的 JavaScript
对象属性的顺序 :始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码.
动态属性 :在实例化后向对象添加属性会强制修改隐藏类,减慢为之前的隐藏类优化了的方法.所以应该在构造函数中指定对象的所有属性.
方法 :重复执行相同方法的代码将比只执行一次的代码(由于内联缓存)运行得快.
数组 :避免键不是增量数字的稀疏数组.元素不全的稀疏数组是一个 哈希表, 而访问这种数组中的元素更昂贵.另外,尽量避免预分配大数组.最好随着发展而增长.最后,不要删除数组中的元素.它会让键变得稀疏.
标记值 :V8 用 32 位表示对象和数字.它用一位来判断是对象(flag = 1)还是整数(flag=0)(这个整数称为 SMI(SMall Integer,小整数),因为它是 31 位).然后,如果一个数值大于 31 位,V8 将会对数字装箱,将其转化为 double,并创建一个新对象将该数字放在里面.所以要尽可能使用 31 位有符号数字,从而避免昂贵的转换为 JS 对象的装箱操作.
我们在 SessionStack 中试图在编写高度优化的 JavaScript 代码中遵循这些最佳实践.原因是一旦将 SessionStack 集成到产品 web 应用程序中,它就开始记录所有内容:所有 DOM 更改,用户交互,JavaScript 异常,栈跟踪,失败的网络请求和调试消息.用 SessionStack,您可以将 Web 应用中的问题重放为视频,并查看用户发生的一切.而所有这些都是在对您的 web 应用程序的性能不会产生影响的情况下发生的.
资源
https://docs.google.com/document/u/1/d/1hOaE7vbwdLLXWj3C8hTnnkpE0qSa2P-dtDvwXXEeD0/pub
https://github.com/thlorenz/v8-perf
http://code.google.com/p/v8/wiki/UsingGit
http://mrale.ph/v8/resources.html
https://www.youtube.com/watch?v=UJPdhx5zTaw
https://www.youtube.com/watch?v=hWhMKalEicY
来源: http://www.open-open.com/lib/view/open1503924614079.html