几周之前, 我们开始了一系列旨在深入挖掘 JavaScript 及其实际工作原理的文章: 我们认为通过了解 JavaScript 的构建块以及它们如何共同发挥作用, 你将能够编写更好的代码和应用程序.
第一篇文章集中于提供引擎, 运行时和调用堆栈的概述. 第二篇文章将深入探讨谷歌 V8 JavaScript 引擎的内部. 我们还将提供一些关于如何编写更好的 JavaScript 代码的小建议 - 最佳实践就是我们的团队构建的 SessionStack https://www.sessionstack.com/ , 在开发时我们就遵循了这些 tips.
概述
JavaScript 引擎是执行 JavaScript 代码的程序或解释器. JavaScript 引擎可以实现为标准解释器, 或以某种形式将 JavaScript 编译为字节码的即时编译器.
这是一个实现 JavaScript 引擎的热门项目列表:
- 开源, 由谷歌开发, C++ 编写
- 由 Mozilla Foundation 管理, 开源, 完全用 Java 开发
- 第一个 JavaScript 引擎, 过去由 Netscape Navigator 管理, 现在由 Firefox 管理
https://en.wikipedia.org/wiki/JavaScriptCore - 开源, 作为 Nitro 销售, 由 Apple 为 Safari 开发
https://en.wikipedia.org/wiki/KJS_(KDE) -KDE 的引擎最初是由 Harri Porten 为 KDE 项目的 Konqueror web 浏览器开发的
Chakra(JScript9)-IE 浏览器
Chakra(JavaScript)-Microsoft Edge
, 作为 OpenJDK 的一部分开源, 由 Oracle Java Languages and Tool Group 编写
https://en.wikipedia.org/wiki/JerryScript - 是一个物联网的轻量级引擎.
V8 引擎是如何产生的?
由谷歌构建的 V8 引擎是开源的, 用 C ++ 编写. 此引擎在 Google Chrome 中使用. 然而, 与其他引擎不同, V8 也用于流行的 Node.JS 运行时.
V8 最初是被设计来提高 JavaScript 在 Web 浏览器中执行的性能的. 为了提高速度, V8 将 JavaScript 代码转换为更高效的机器代码, 而不是使用解释器. 它通过实现 JIT(Just-in-Time) 编译器在执行时将 JavaScript 代码编译成机器代码来实现的, 就像大多数现代 JavaScript 引擎所做的那样, 比如 SpiderMonkey or Rhino (Mozilla). 主要区别是 V8 不产生字节码或任何中间代码.
V8 曾经有两个编译器
full-codegen- 一个简单而快速的编译器, 可以生成简单且相对较慢的机器代码.
Crankshaft - 一种更复杂的 (即时) 优化编译器, 可生成高度优化的代码.
V8 引擎内部还使用了几个线程:
主线程完成你的期望: 获取代码, 编译代码然后执行它
还有一个单独的线程用于编译, 以保证主线程可以在前者优化代码的同时能够继续执行.
一个 Profiler 线程, 它将告诉运行时哪一个方法花费了大量时间, 以便 Crankshaft 可以优化它们
一些线程来处理垃圾收集器的扫描.
当第一次执行 JavaScript 代码的时候, V8 利用全代码生成器, 直接将解析后的 JavaScript 翻译为机器代码而不做其他任何的转换. 这使它可以非常快速地开始执行机器代码. 请注意, V8 不使用中间字节码的这种方式使其不需要解释器这种东西.
当代码运行一段时间后, 探查线程 (profiler thread) 已经收集了足够的数据来告诉 (Crankshaft ) 应该优化哪个方法.
接下来, Crankshaft 优化 开启了另一个线程. 它将 JavaScript 抽象语法树转换为名为 Hydrogen 的高级静态单指派 (SSA) 表现(a high-level static single-assignment (SSA) representation), 并尝试优化氢图(Hydrogen graph). 大多数优化都是在这个级别完成的.
内联(Inlining)
第一次优化是尽可能提前内联更多的代码. 内联就是一个用函数体替换函数调用点 (函数被调用的代码行) 的过程. 这个简单的步骤使接下来的优化更有意义.
Hidden class(隐藏类)
JavaScript 是一门基于原型的语言: 没有类和对象是使用克隆过程创建的. JavaScript 也是一种动态编程语言, 这意味着在实例化后可以轻松地在对象中添加或删除属性.
大多数 JavaScript 解释器使用类似字典的结构 (基于 hash function http://en.wikipedia.org/wiki/Hash_function ) 来存储对象属性值在内存中的位置. 这种结构使得在 JavaScript 中检索属性的值比在 Java 或 C#等非动态编程语言中的计算成本更高. 在 Java 里, 所有对象属性都是在编译之前由固定对象布局确定的, 并且无法在运行时动态添加或删除 (当然, C# 也有动态类型, 这又是另一个话题了.) 结果是, 属性值 (或指向这些属性的指针) 可以以两两之间有一个固定的偏移量 (fixed-offset ) 作为连续缓冲区存储在内存中. 这些偏移 (offset ) 的长度可以根据属性类型轻松确定, 而这在运行时可以更改属性类型的 JavaScript 中是不可能的.
由于使用字典来查找内存中对象属性的位置是非常低效的, V8 使用了一个不同的方法来代替: hidden classes(隐藏类). 隐藏类的工作方式类似于 Java 等语言中使用的固定对象布局(类), 除非它们是在运行时创建的. 现在, 让我们看看它们实际上是什么样的:
- function(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 将创建一个名为 "C1" 的第二个隐藏类, 它基于 "C0"."C1" 描述了可以找到属性 x 的存储器中的位置(相对于对象指针). 在这种情况下,"x" 存储在偏移 0 处, 这意味着当在存储中查看作为连续缓冲区存储的 Point 对象时, 第一个偏移将对应于属性 "x".(这句我觉得翻译可能有不准的地方, 原句是: In this case, "x" is stored at offset 0, which means that when viewing a point object in the memory as a continuous buffer, the first offset will correspond to property "x".)
V8 也会用 "类转换"("class transition" )更新 "C0", 该类转换指出如果将属性 "x" 添加到 Point 对象, 则隐藏类应该从 "C0" 切换到 "C1". 下面的 Point 对象的隐藏类现在是 "C1".
每次将新属性添加到对象时, 旧的隐藏类都会更新为新隐藏类的转换路径. 隐藏类转换很重要, 因为它们允许在以相同方式创建的对象之间共享隐藏类. 如果两个对象共享一个隐藏类并且同一属性被添加到它们中, 则转换要确保两个对象都接收相同的新隐藏类以及随其附带的所有优化代码.
执行语句 "this.y = y"(在 Point 函数内, 在 "this.x = x" 语句之后)时重复此过程.
一个新的隐藏类 "C2" 被创建了, 一个新的声明了如果将属性 "y" 添加到 Point 对象(已包含属性 "x"), 则隐藏的类应更改为 "C2" 的类转换被添加给 C1, 并且 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" 以不同的隐藏类结束. 在这些情况下, 以相同的顺序初始化动态属性要好得多, 以便可以重用隐藏的类.
内联缓存(Inline caching)
V8 利用另一种技术优化动态类型语言, 称为内联缓存. 内联缓存依赖于观察到对相同类型的对象的重复调用倾向于发生在相同类型的对象上.
可以在这里找到有关内联缓存的深入说明.
我们将讨论内联缓存的一些基础概念(如果你没有时间浏览上面的深入解释的文章).
那么它是怎么工作的呢? V8 维持一个在最近的方法调用中作为参数传递的对象类型的缓存, 并使用此信息来假设将来作为参数传递的对象类型. 如果 V8 能够对将传递给方法的对象类型做出很好的假设, 它可以绕过弄清楚如何访问对象属性的过程, 而是使用先前查找中存储的信息到对象的隐藏类.
那么隐藏类和内联缓存的概念之间有什么关系呢? 每当在特定对象上调用方法时, V8 引擎必须执行对该对象的隐藏类的查找, 以确定访问特定属性的偏移量. 在将同一方法两次成功调用到同一个隐藏类之后, V8 省略了隐藏类查找, 只是简单地将属性的偏移量添加到对象指针本身. 对于该方法接下来的调用, V8 引擎假定隐藏类没有改变, 然后使用先前查找中存储的偏移量直接跳转到特定属性的内存地址. 这大大提高了执行速度.
内联缓存也是为什么相同类型的对象共享隐藏类非常重要的原因. 如果你创建两个相同类型且具有不同隐藏类的对象(如前面示例中所做的那样),V8 无法使用内联缓存, 因为即使两个对象属于同一类型, 其对应的隐藏类也会为其属性分配不同的偏移量.
这两个对象基本相同, 只是 "a" 和 "b" 属性创建的顺序不同.
编译到机器代码
氢图 (Hydrogen graph) 优化后, Crankshaft 将其降低到称为锂 (Lithium) 的低级别表示. 大多数 Lithium 实现都是特定于某种结构的. 寄存器的分配 (Register allocation) 就发生在此级别.
在最后, Lithium 被编译成机器代码. 然后发生了一些其他的叫做 OSR 的事情: 堆栈替换. 在我们开始编译和优化一个明显长期运行的方法之前, 我们可能正在运行它. V8 不会忘记它只是慢慢执行以便再次使用优化版本. 相反, 它将转换我们拥有的所有上下文(堆栈, 寄存器), 以便我们可以在执行过程中切换到优化版本. 这是一项非常复杂的任务, 请记住, 除了其他优化之外, V8 最初还内联了代码. V8 并不是唯一能够做到这一点的引擎.
有一种称为去优化的保护措施可以进行相反的转换, 并在引擎的假设不再适用的情况下恢复到非优化代码.
垃圾收集(Garbage collection)
在垃圾处理这一块, V8 使用传统的标记和扫描方式来清除垃圾. 标记阶段应该停止执行 JavaScript. 为了控制 GC 成本并使执行更稳定, V8 使用增量标记: 它只走部分堆后便恢复正常执行, 而不是走遍整个堆, 试图标记每个可能的对象. 下一次 GC 将从上一个堆行走停止的位置继续. 这样可以只在正常执行期间进行短暂的暂停. 如前所述, 扫描阶段由单独的线程处理.
Ignition and TurboFan
在 2017 年早些时候发布 V8 5.9 版本中, 引入了新的执行管道. 这个新的管道在实际的 JavaScript 应用程序中实现了更大的性能提升和显著的内存节省.
这个新的执行管道建立在 Ignition https://github.com/v8/v8/wiki/Interpreter ,V8 的解释器和 https://github.com/v8/v8/wiki/TurboFan (V8 的最新优化编译器)之上.
你可以在此处查看 V8 团队关于此主题的文章.
自 V8 版本 5.9 问世以来, 全代码生成 ( full-codegen) 和 Crankshaft(自 2010 年以来为 V8 服务的技术)不再被 V8 用于执行 JavaScript, 因为 V8 团队需要跟上 JavaScript 新的语言功能以及这些功能所需的优化.
这意味着 V8 总体来说将会拥有更简单, 更易维护的架构.
Web 和 Node.JS 基准测试的改进
这些改进只是一个开始. 新的 Ignition 和 TurboFan 管道为进一步优化铺平了道路, 这些优化将在未来几年内提升 JavaScript 性能并缩小 V8 在 Chrome 和 Node.JS 中的占用空间.
最后, 这里有一些关于如何编写优化良好的 JavaScript 的提示和技巧. 你当然可以从上面的内容轻松地推导出这些内容, 但是, 这里有一个方便的总结:
How to write optimized JavaScript
对象属性的顺序: 始终以相同的顺序实例化您的对象属性, 以便可以共享隐藏的类和随后优化的代码.
动态属性: 在实例化之后向对象添加属性会强制隐藏类改变并减缓为先前隐藏类优化的任何方法. 因此, 在其构造函数中分配所有对象的属性.
方法: 重复执行相同方法的代码将比仅执行一次不同方法的代码运行得更快(由于内联缓存).
数组: 避免键不是增量数的稀疏数组. 其中没有每个元素的稀疏数组是一个哈希表(hash table). 这种数组中的元素访问起来更加昂贵(费时, 麻烦). 另外, 尽量避免预先分配的大数组. 随着需要增长其长度更好. 最后, 不要删除数组中的元素. 这会使键变得稀疏.
标记值 (Tagged values):V8 的对象和数字都用 32 位来表示. 它使用一个位来知道它是一个对象(flag = 1) 还是一个称为 SMI(SMall Integer)的 31 位的整数(flag = 0). 然后, 如果数值大于 31 位, V8 会将数字打包, 将其变为双精度并创建一个新对象以将数字放入其中. 尝试尽可能使用 31 位带符号的数字, 以避免对 JS 对象进行昂贵的装箱操作.
我们在 SessionStack 中尝试遵循这些做法以写出高度优化的 JavaScript 代码. 原因是, 一旦你将 SessionStack 集成到你的 Web 应用程序中, 它将开始记录一切: 所有 DOM 更改, 用户交互, JavaScript 异常, 堆栈跟踪, 失败的网络请求和调试消息.
使用 SessionStack, 你可以将网络应用中的问题作为视频重播, 并查看用户发生的所有事情. 并且所有的这一切都不会影响你的 Web 应用性能.
这里可以免费试用. get started for free.
- Resources
- 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
来源: https://juejin.im/post/5c983ee75188257a4d3633ec