JavaScript工作机制:V8 引擎内部机制及如何编写优化代码的5个诀窍

概述

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

下面是实现了JavaScript引擎的一个热门项目列表:

  • V8— 开源,由Google开发,用C++编写的
  • Rhino — 由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

  1. 对象属性的顺序 :始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码。
  2. 动态属性 :在实例化后向对象添加属性会强制修改隐藏类,减慢为之前的隐藏类优化了的方法。所以应该在构造函数中指定对象的所有属性。
  3. 方法 :重复执行相同方法的代码将比只执行一次的代码(由于内联缓存)运行得快。
  4. 数组 :避免键不是增量数字的稀疏数组。元素不全的稀疏数组是一个 哈希表, 而访问这种数组中的元素更昂贵。另外,尽量避免预分配大数组。最好随着发展而增长。最后,不要删除数组中的元素。它会让键变得稀疏。
  5. 标记值 :V8用32位表示对象和数字。它用一位来判断是对象(flag = 1)还是整数(flag=0)(这个整数称为SMI(SMall Integer,小整数),因为它是31位)。然后,如果一个数值大于31位,V8将会对数字装箱,将其转化为 double,并创建一个新对象将该数字放在里面。所以要尽可能使用31位有符号数字,从而避免昂贵的转换为JS对象的装箱操作。

我们在SessionStack中试图在编写高度优化的JavaScript代码中遵循这些最佳实践。原因是一旦将SessionStack集成到产品web应用程序中,它就开始记录所有内容:所有DOM更改、用户交互、JavaScript异常、栈跟踪、失败的网络请求和调试消息。用SessionStack,您可以将Web应用中的问题重放为视频,并查看用户发生的一切。而所有这些都是在对您的web应用程序的性能不会产生影响的情况下发生的。

时间: 2024-08-02 23:01:18

JavaScript工作机制:V8 引擎内部机制及如何编写优化代码的5个诀窍的相关文章

How Javascript works (Javascript工作原理) (一) 引擎,运行时,函数调用栈

个人总结: 这篇文章对JS底层的工作原理进行了介绍. 原文:https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf 一.引擎,运行时,调用堆栈 这是 JavaScript 工作原理的第一章.本章会对语言引擎,运行时,调用栈做一个概述. 事实上,有很多开发者在每天日常开发中都会使用 JavaScript 但是却不了解其底层的知识. 概述 几乎所有人都已经听说过 V8 引擎的概念,并且很多人

V8引擎回收机制、 内存泄露

一.垃圾回收:将内存不在使用的数据进行清理,释放内存空间 v8将内存分为新生代空间和老生代的空间 新生代空间:用于存活较短的对象 :又分为二个空间:from空间和to空间 :Scavenge GC算法:当 from 空间被占满时,启动 GC 算法 存活的对象从 from space 转移到 to space 清空from space from space和to space交互 完成一次新生代GC 老生带空间:用于存活时间较长的对象 从新生代空间 转移到 老生代空间条件 经历过一次以上的Scave

Node.js背后的V8引擎优化技术

Node.js的执行速度远超Ruby.Python等脚本语言,这背后都是V8引擎的功劳.本文将介绍如何编写高性能Node.js代码.V8是Chrome背后的JavaScript引擎,因此本文的相关优化经验也适用于基于Chrome浏览器的JavaScript引擎. V8优化技术概述 V8引擎在虚拟机与语言性能优化上做了很多工作.不过按照Lars Bak的说法,所有这些优化技术都不是他们创造的,只是在前人的基础上做的改进. 隐藏类(Hidden Class) 为了减少JavaScript中访问属性所

V8引擎——详解

前言 JavaScript绝对是最火的编程语言之一,一直具有很大的用户群,随着在服务端的使用(NodeJs),更是爆发了极强的生命力.编程语言分为编译型语言和解释型语言两类,编译型语言在执行之前要先进行完全编译,而解释型语言一边编译一边执行,很明显解释型语言的执行速度是慢于编译型语言的,而JavaScript就是一种解释型脚本语言,支持动态类型.弱类型.基于原型的语言,内置支持类型.鉴于JavaScript都是在前端执行,而且需要及时响应用户,这就要求JavaScript可以快速的解析及执行.

v8引擎详解(摘)

随着Web相关技术的发展,JavaScript所要承担的工作也越来越多,早就超越了“表单验证”的范畴,这就更需要快速的解析和执行JavaScript脚本.V8引擎就是为解决这一问题而生,在node中也是采用该引擎来解析JavaScript. WebKit是由苹果2005年发起的一个开源项目,引起了众多公司的重视,几年间被很多公司所采用,在移动端更占据了垄断地位.更有甚者,开发出了基于WebKit的支持HTML5的web操作系统(如:Chrome OS.Web OS). 2.V8引擎 V8引擎是一

Chrome V8系列--浅析Chrome V8引擎中的垃圾回收机制和内存泄露优化策略[转]

V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制.因此,V8 将内存(堆)分为新生代和老生代两部分. 一.前言 V8的垃圾回收机制:JavaScript使用垃圾回收机制来自动管理内存.垃圾回收是一把双刃剑,其好处是可以大幅简化程序的内存管理代码,降低程序员的负担,减少因 长时间运转而带来的内存泄露问题. 但使用了垃圾回收即意味着程序员将无法掌控内存.ECMAScript没有暴露任何垃圾回收器的接口.我们无法强迫其进 行垃圾回收,更无法干预内存管理 内存管理问题:在浏览器中,Chrom

浅谈Chrome V8引擎中的垃圾回收机制

垃圾回收器 JavaScript的垃圾回收器 JavaScript使用垃圾回收机制来自动管理内存.垃圾回收是一把双刃剑,其好处是可以大幅简化程序的内存管理代码,降低程序员的负担,减少因 长时间运转而带来的内存泄露问题.但使用了垃圾回收即意味着程序员将无法掌控内存.ECMAScript没有暴露任何垃圾回收器的接口.我们无法强迫其进 行垃圾回收,更无法干预内存管理 内存管理问题 在浏览器中,Chrome V8引擎实例的生命周期不会很长(谁没事一个页面开着几天几个月不关),而且运行在用户的机器上.如果

Javascript引擎单线程机制及setTimeout执行原理说明

setTimeout用法在实际项目中还是会时常遇到.比如浏览器会聪明的等到一个函数堆栈结束后才改变DOM,如果再这个函数堆栈中把页面背景先从白色设为红色,再设回白色,那么浏览器会认为DOM没有发生任何改变而忽略这两句话,因此我们可以通过setTimeout把“设回白色”函数加入下一个堆栈,那么就可以确保背景颜色发生过改变了(虽然速度很快可能无法被察觉). 总之,setTimeout增加了Javascript函数调用的灵活性,为函数执行顺序的调度提供极大便利. 然后,我们从基础的层面来看看:理解J

How Javascript works (Javascript工作原理) (五) 深入理解 WebSockets 和带有 SSE 机制的HTTP/2 以及正确的使用姿势

总结: 1.长连接机制--分清Websocket,http2,SSE: HTTP/2 引进了 Server Push 技术用来让服务器主动向客户端缓存发送数据.然而,它并不允许直接向客户端程序本身发送数据.服务端推送只能由浏览器处理而不能够在程序代码中进行处理,意即程序代码没有 API 可以用来获取这些事件的通知. 通过SSE(Server Side Event)来实现服务端向客户端的单向推送,SSE基于HTTP,是单向通信. WebSocket是在服务端和客户端建立双工通信.基于TCP. 这是