【译】JavaScript工作原理:V8编译器的优化

简介: 【译】JavaScript工作原理:V8编译器的优化

640.jpg原文链接:https://blog.logrocket.com/how-javascript-works-optimizing-the-v8-compiler-for-efficiency/

原文标题:How JavaScript works: Optimizing the V8 compiler for efficiency


(正文开始)

理解JavaScript的工作原理是写出高效JavaScript代码的关键。

忘记那些无关紧要的毫秒级改进:错误地使用对象属性可能导致简单的一行代码速度降低7倍。


考虑到JavaScript在软件堆栈所有级别中的普遍性,即使不是所有级别的基础设施,也可能会出现微不足道的减速,而不仅仅是网站的菜单动画。


有许多的方法来编写高效的JavasScript代码,但在这篇文章里面,我们将着重介绍编译器友好的优化方法,这意味着源代码使编译器优化变得简单有效。


我们将把讨论范围缩小到V8,即支持electron、node.js和google chrome的JavaScript引擎。为了理解编译器友好的优化,我们首先需要讨论JavaScript是如何编译的。

JavaScript在V8中的执行可以分为三个阶段:


  • 源代码到抽象语法树:解析器将源代码生成抽象语法树(AST)
  • 抽象语法树到字节码:V8的解释器Ignition从抽象语法树生成字节码。请注意,生成字节码这一步在2017年以前是没有的。
  • 字节码到机器码:V8的编译器TurboFan从字节码生成一个图,用高度优化的机器代码替换字节码的部分。


第一个阶段超出了本文的范围,但是第二个和第三个阶段对编写优化的JavaScript有直接的影响。


我们将讨论这些优化方法以及代码如何利用(或滥用)这些优化。通过了解JavaScript执行的基础知识,您不仅可以理解这些性能方面的建议,还可以学习如何发现自己的一些优化点。


实际上,第二和第三阶段是紧密耦合的。这两个阶段在即时(just-in-time,JIT)范式中运行。为了理解JIT的重要性,我们将研究以前将源代码转换为机器代码的方法。


Just-in-Time (JIT) 范式


为了执行任意一段程序,计算机必须将源代码转换成机器可以运行的代码。

有两种方法可以进行转换。


第一种选择是使用解释器。解释器可以有效地逐行翻译和执行。

第二种方法是使用编译器。编译器在执行之前立即将所有源代码转换为机器语言。

下面,我们将阐述两种方法的优点和缺点。


解释器的优点、缺点


解释器使用read-eval-print loop (REPL,交互式解释器)的方式工作 —— 这种方式有许多的优点:


  • 易于实现和理解
  • 及时反馈
  • 更合适的编程环境


然而,这些好处是以缓慢执行为代价的:

(1)eval的开销,而不是运行机器代码。

(2)无法跨程序的对各个部分进行优化。

更正式地说,解释器在处理不同的代码段时不能识别重复的工作。如果你通过解释器运行同一行代码100次,解释器将翻译并执行同一行代码100次,没有必要地重新翻译了99次。


总结一下,解释器简单、启动快,但是执行慢。


编译器的优点、缺点


编译器会在执行前翻译所有的源代码。

随着复杂性的增加,编译器可以进行全局优化(例如,为重复的代码行共享机器代码)。这为编译器提供了比解释器唯一的优势 —— 更快的执行时间。

总结一下,编译器是复杂的、启动慢,但是执行快。


即时编译(JIT)


即时编译器尝试结合了解释器和编译器的优点,使代码转换和执行都变得更快。

基本思想是避免重复转换。首先,探查器会通过解释器先跑一遍代码。在代码执行期间,探查器会跟踪运行几次的热代码段和运行很多次的热代码段。

JIT将热代码片段发送给基线编译器,尽可能的复用编译后的代码。


JIT同时将热代码片段发送给优化编译器。优化编译器使用解释器收集的信息来进行假设,并且基于这些假设进行优化(例如,对象属性总是以特定的顺序出现)。

但是,如果这些假设无效,优化编译器将执行 去优化,丢弃优化的代码。

优化和去优化的过程是昂贵的。由此产生了一类JavaScript的优化方法,下面将详细描述。

JIT需要存储优化的机器代码和探查器的执行信息等,自然会引入内存开销。尽管这一点无法通过优化的JavaScript来改善,但激发了V8的解释器。


V8的编译


V8的解释器和编译器执行以下功能:


  • 解释器将抽象语法树转换为字节码。字节码队列随后会被执行,并且通过内联缓存收集反馈。这些反馈会被解释器本身用于随后的解析,同时,编译器会利用这些反馈来做推测性的优化。
  • 编译器根据反馈将字节码转换为特定于体系结构的机器码,从而推测性地优化字节码。


V8的解释器 - Ignition


JIT编译器显示了开销内存消耗。Ignition通过实现三个目标来解决这个问题:减少内存使用、减少启动时间和降低复杂性。


这三个目标都是通过将AST转换为字节码并在程序执行期间收集反馈来实现的。


  • 字节码被当做源代码对待,省去了在编译期间重新解析JavaScript的需要。这意味着使用字节码,TurboFan的去优化过程不再需要原始的代码了。
  • 作为基于程序执行反馈的优化示例,内联缓存允许V8优化对具有相同类型参数的函数的重复调用。具体来说,内联缓存存储函数的输入类型。类型越少,需要的类型检查就越少。减少类型检查的数量可以显著提高性能。

AST和字节码都会暴露给TurboFan。


V8的编译器 - TurboFan


在2008年发布时,V8引擎最初直接将源代码编译为机器代码,跳过了中间字节码表示。在发布时,V8就比竞争对手快了10倍。


然而,到今天,TurboFan接受了Ignition的字节码,比它发布的时候快了10倍。V8的编译器经过了一系列的迭代:


  • 2008 – Full-Codegen
  • 具有隐藏类和内联缓存,快速遍历AST的编译器
  • 缺点:无优化的即时编译
  • 2010 – Crankshaft
  • 使用类型反馈和去优化,优化即时编译器。
  • 缺点: 不能扩展到现代JavaScript,严重依赖去优化,有限的静态类型分析,与Codegen紧密耦合,高移植开销
  • 2015 – TurboFan
  • 用类型和范围分析优化即时编译器


根据Google慕尼黑技术讲座(Titzer,3月16号),TurboFan优化了峰值性能、静态类型信息使用、编译器前端、中间和后端分离以及可测试性。最终沉淀出一个关键的贡献:"节点海"。


在节点海中,节点表示计算,边表示依赖关系。

与控制流图(CFG)不同的是,节点海可以放宽大多数操作的评估顺序。与CGF一样,有状态操作的控制边和效果边在需要时会约束执行顺序。


Titzer进一步完善了这个定义,使之成为一个节点汤,其中控制流子图进一步放宽。这提供了许多优点—例如,这避免了冗余代码的消除。


通过自下而上或自上而下的图转换,图缩减被应用于这一系列节点。

TurboFan遵循4个步骤将字节码转换为机器码。请注意,以下管道中的优化是根据Ignition的反馈执行的。


  • 将程序表示为JavaScript操作符。(例如:JSADD)
  • 将程序表示为中间运算符。(虚拟机级别的操作符;不可知的数字表示,例如:NumberAdd)
  • 将程序表示为机器操作符。(与机器操作符相对应,例如:Int32Add)
  • 使用顺序约束安排执行顺序。创建一个传统的控制流图。


TurboFan的在线JIT风格的编译和优化意味着 V8从源代码到机器代码的转换 结束了。


如何优化你的JavaScript


TurboFan的优化通过减轻糟糕的JavaScript的影响来提高JavaScript的网络性能。然而,了解这些优化可以提供进一步的加速。


下面是利用V8中的优化来提高性能的7个技巧。前四个重点是减少去优化。


Tip1: 在构造函数中声明对象属性


更改对象属性会产生新的隐藏类。以google i/o 2012中的以下示例为例。

class Point {
 constructor(x, y) {
   this.x = x;
   this.y = y;
}
}
var p1 = new Point(11, 22);  // hidden class Point created
var p2 = new Point(33, 44);
p1.z = 55;  // another hidden class Point created

正如你所见,p1和p2现在有不同的隐藏类了。这阻碍了TurboFan的优化尝试:具体来说,任何接受Point对象的方法现在都是去优化的。


所有这些函数都使用两个隐藏类重新优化。对对象形状的任何修改都是如此。

Tip2:  保持对象属性不变


更改对象属性的顺序会导致新的隐藏类,因为对象形状中是包含顺序的。

const a1 = { a: 1 };  # hidden class a1 created
a1.b = 3;
const a2 = { b: 3 };  # different hidden class a2 created
a2.a = 1;

上面的代码中,a1和a2有不同的隐藏类。修复顺序允许编译器重用同一个隐藏类。因为添加的字段(包括顺序)用于生成隐藏类的id


Tip3:修复函数参数类型


函数根据特定参数位置的值类型更改对象形状。如果此类型发生更改,则函数将去优化并重新优化。


在看到四种不同的对象形状后,该函数会变成megamorphic,TurboFan将不会再尝试优化这个函数。


看下面这个例子:

function add(x, y) {
 return x + y
}
add(1, 2);  # monomorphic
add("a", "b");  # polymorphic
add(true, false);
add([], []);
add({}, {});  # megamorphic

第9行过后,TurboFan将不会再优化add这个函数。


Tip4:在脚本作用域中声明类


不要在函数作用域中声明类。以下面这个例子为例:


function createPoint(x, y) {
 class Point {
   constructor(x, y) {
     this.x = x;
     this.y = y;
  }
}
 return new Point(x, y);
}
function length(point) {
 ...
}

每一次createPoint这个函数被调用的时候,一个新的Point原型会被创建。

每一个新的原型都对应着一个新的对象形状,所以每一次length函数都会看到一个新的point的对象形状。


跟之前一样,当看到4个不同的对象形状的时候,函数会变得megamorphic,TurboFan将不会再尝试优化length函数。


在脚本作用域中声明class Point,我们可以避免每一次调用createPoint的时候,生成不同的对象形状。


下一个tip是V8引擎里的奇淫巧技。


Tip5:使用for…in


这是V8引擎中的一个怪异行为。这一特性之前包含在最初的Crankshaft里面,后来被移植到了Ignition and Turbofan.


for…in循环比函数迭代、带箭头函数的函数迭代和for循环中的object.keys快4-6倍。

接下来两个Tip是对之前两种说法的反驳。由于现代V8引擎的改变,这两种说法已经不成立了。


Tip6:无关字符不影响性能


Crankshaft过去是使用一个函数的字节数来决定是否内联一个函数的。而TurboFan是建立在AST上的,他使用AST节点的数量来决定函数的大小。


因此,无关的字符,比如空白,注释,变量名长度,函数签名等,不会影响函数的性能。

Tip7:Try/catch/finally 不是毁灭性的


Try代码块以前容易出现高昂的优化-去优化的周期。如今,当在Try块中调用函数时,turbofan不再显示出显著的性能影响。


结论


总之,优化方法通常集中在减少去优化和避免不可优化的megamorphic函数上。

通过对V8引擎框架的理解,我们还可以推断出上面没有列出的其他优化方法,并尽可能重用方法来利用内联。现在您已经了解了JavaScript编译及其对日常JavaScript使用的影响。

正文结束


写在后面


原文中有很多的链接,内容都很好,不过公众号里对外链不友好,感兴趣的小伙伴可以打开原文查看。

相关文章
|
6月前
|
监控 负载均衡 JavaScript
有哪些有效的方法可以优化Node.js应用的性能?
有哪些有效的方法可以优化Node.js应用的性能?
341 69
|
5月前
|
机器学习/深度学习 JavaScript 前端开发
JS进阶教程:递归函数原理与篇例解析
通过对这些代码示例的学习,我们已经了解了递归的原理以及递归在JS中的应用方法。递归虽然有着理论升华,但弄清它的核心思想并不难。举个随手可见的例子,火影鸣人做的影分身,你看到的都是同一个鸣人,但他们的行为却能在全局产生影响,这不就是递归吗?雾里看花,透过其间你或许已经深入了递归的魅力之中。
227 19
|
6月前
|
监控 算法 JavaScript
公司局域网管理视域下 Node.js 图算法的深度应用研究:拓扑结构建模与流量优化策略探析
本文探讨了图论算法在公司局域网管理中的应用,针对设备互联复杂、流量调度低效及安全监控困难等问题,提出基于图论的解决方案。通过节点与边建模局域网拓扑结构,利用DFS/BFS实现设备快速发现,Dijkstra算法优化流量路径,社区检测算法识别安全风险。结合WorkWin软件实例,展示了算法在设备管理、流量调度与安全监控中的价值,为智能化局域网管理提供了理论与实践指导。
168 3
|
5月前
|
JavaScript
JS代码的一些常用优化写法
JS代码的一些常用优化写法
96 0
|
9月前
|
前端开发 JavaScript Java
JavaScript闭包深入剖析:性能剖析与优化技巧
JavaScript 闭包是强大而灵活的特性,广泛应用于数据封装、函数柯里化和事件处理等场景。闭包通过保存外部作用域的变量,实现了私有变量和方法的创建,提升了代码的安全性和可维护性。然而,闭包也可能带来性能问题,如内存泄漏和执行效率下降。为优化闭包性能,建议采取以下策略:及时解除对不再使用的闭包变量的引用,减少闭包的创建次数,使用 WeakMap 管理弱引用,以及优化闭包结构以减少作用域链查找的开销。在实际开发中,无论是 Web 前端还是 Node.js 后端,这些优化措施都能显著提升程序的性能和稳定性。
226 70
|
6月前
|
人工智能 监控 前端开发
基于 Next.js 的书法字体生成工具架构设计与 SSR 优化实践
本项目是一款书法字体生成工具,采用 Next.js 14(App Router)与 Tailwind CSS 构建前端,阿里云 Serverless 部署后端。通过混合渲染策略(SSG/SSR/CSR)、Web Worker 异步计算及 CDN 字体分片加载优化性能。服务端借助阿里云函数计算处理计算密集型任务,将平均耗时从 1200ms 降至 280ms,支持 1000+ QPS。动态路由与 ARMS 监控提升工程化水平,未来计划引入 WebGPU 和 AI 字体风格迁移技术,进一步优化用户体验。
|
8月前
|
前端开发 JavaScript
【Javascript系列】Terser除了压缩代码之外,还有优化代码的功能
Terser 是一款广泛应用于前端开发的 JavaScript 解析器和压缩工具,常被视为 Uglify-es 的替代品。它不仅能高效压缩代码体积,还能优化代码逻辑,提升可靠性。例如,在调试中发现,Terser 压缩后的代码对删除功能确认框逻辑进行了优化。常用参数包括 `compress`(启用压缩)、`mangle`(变量名混淆)和 `output`(输出配置)。更多高级用法可参考官方文档。
597 11
|
8月前
|
JavaScript 前端开发 算法
JavaScript 中通过Array.sort() 实现多字段排序、排序稳定性、随机排序洗牌算法、优化排序性能,JS中排序算法的使用详解(附实际应用代码)
Array.sort() 是一个功能强大的方法,通过自定义的比较函数,可以处理各种复杂的排序逻辑。无论是简单的数字排序,还是多字段、嵌套对象、分组排序等高级应用,Array.sort() 都能胜任。同时,通过性能优化技巧(如映射排序)和结合其他数组方法(如 reduce),Array.sort() 可以用来实现高效的数据处理逻辑。 只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
|
10月前
|
前端开发 JavaScript
【02】v1.0.1更新增加倒计时完成后的放烟花页面-优化播放器-优化结构目录-蛇年新年快乐倒计时领取礼物放烟花html代码优雅草科技央千澈写采用html5+div+CSS+JavaScript-优雅草卓伊凡-做一条关于新年的代码分享给你们-为了C站的分拼一下子
【02】v1.0.1更新增加倒计时完成后的放烟花页面-优化播放器-优化结构目录-蛇年新年快乐倒计时领取礼物放烟花html代码优雅草科技央千澈写采用html5+div+CSS+JavaScript-优雅草卓伊凡-做一条关于新年的代码分享给你们-为了C站的分拼一下子
270 14
【02】v1.0.1更新增加倒计时完成后的放烟花页面-优化播放器-优化结构目录-蛇年新年快乐倒计时领取礼物放烟花html代码优雅草科技央千澈写采用html5+div+CSS+JavaScript-优雅草卓伊凡-做一条关于新年的代码分享给你们-为了C站的分拼一下子
|
8月前
|
JavaScript 前端开发 Java
深入理解 JavaScript 中的 Array.find() 方法:原理、性能优势与实用案例详解
Array.find() 是 JavaScript 数组方法中一个非常实用和强大的工具。它不仅提供了简洁的查找操作,还具有性能上的独特优势:返回的引用能够直接影响原数组的数据内容,使得数据更新更加高效。通过各种场景的展示,我们可以看到 Array.find() 在更新、条件查找和嵌套结构查找等场景中的广泛应用。 在实际开发中,掌握 Array.find() 的特性和使用技巧,可以让代码更加简洁高效,特别是在需要直接修改原数据内容的情形。 只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一