JavaScript的即时编译 just-in-time (JIT) compilers
本文翻译自 https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers/
JavaScript 一开始很慢,但后来由于有称为 JIT 的东西变得更快了。那么 JIT 是如何工作的呢?
JavaScript 是如何在浏览器中运行的
当您作为开发人员向页面添加 JavaScript 时,您有一个目标和一个问题。
目标:您想告诉计算机该做什么。
问题:你和电脑说不同的语言。
你说的是人类语言,而计算机说的是机器语言。即使您不认为 JavaScript 或其他高级编程语言是人类语言,它们确实是。它们是为人类认知而设计的,而不是为机器认知而设计的。
所以 JavaScript 引擎的工作就是将你的人类语言转化为机器可以理解的东西。
我觉得这就像电影降临,在那里你有人类和外星人试图互相交谈。
在那部电影中,人类和外星人不只是逐字翻译。这两个群体对世界有不同的思考方式。人类和机器也是如此。
那么翻译是如何发生的呢?
在编程中,通常有两种翻译成机器语言的方法。您可以使用解释器或编译器。
使用解释器,这种翻译几乎是逐行进行的,即时进行。
另一方面,编译器不会即时翻译。它会提前创建翻译并将其写下来。
这两种处理翻译的方法各有利弊。
解释器的优缺点
解释器可以快速启动并运行。在开始运行代码之前,您不必完成整个编译步骤。您只需开始翻译第一行并运行它。
因此,解释器似乎很适合 JavaScript 之类的语言。对于 Web 开发人员来说,能够快速开始并运行他们的代码非常重要。
这就是浏览器一开始使用 JavaScript 解释器的原因。
但是当您多次运行相同的代码时,使用解释器的缺点就来了。例如,如果您处于循环中。然后你必须一遍又一遍地做同样的翻译。
编译器优缺点
编译器有相反的权衡。
启动需要多一点时间,因为它必须在开始时经过编译步骤。但是循环中的代码运行得更快,因为它不需要为每次通过该循环重复翻译。
另一个区别是编译器有更多时间查看代码并对其进行编辑,以便它运行得更快。这些编辑称为优化。
解释器在运行时完成它的工作,因此在翻译阶段不会花费太多时间来找出这些优化。
即时编译:两全其美
作为摆脱解释器效率低下(解释器每次执行循环时都必须重新翻译代码)的一种方式,浏览器开始将编译器混入其中。
不同的浏览器以略有不同的方式执行此操作,但基本思想是相同的。他们向 JavaScript 引擎添加了一个新部分,称为监视器(也称为分析器)。该监视器会在代码运行时对其进行监视,并记下它运行了多少次以及使用了哪些类型。
起初,监视器只是通过解释器运行所有内容。
如果相同的代码行运行几次,则该代码段称为暖代码(warm)。如果运行很多,则称为热代码(hot)。
基线编译器
当函数开始变暖(warm)时,JIT 会将其发送出去进行编译。然后它将存储该编译。
函数的每一行都被编译成一个“存根(stub)”。存根由行号和变量类型索引(稍后我将解释为什么这很重要)。如果监控器发现执行再次使用相同的变量类型命中相同的代码,它只会提取其编译版本。
这有助于加快速度。但是就像我说的,编译器可以做更多的事情。找出最有效的做事方式可能需要一些时间……进行优化。
基线编译器将进行其中一些优化(我在下面举了一个例子)。不过,它不想花费太多时间,因为它不想让执行延迟太久。
然而,如果代码真的很热——如果它运行了很多次——那么花额外的时间进行更多优化是值得的。
优化编译器
当代码的一部分非常热时,监视器会将其发送给优化编译器。这将创建另一个更快的函数版本,该版本也将被存储。
为了制作更快的代码版本,优化编译器必须做出一些假设。
例如,如果它可以假设由特定构造函数创建的所有对象都具有相同的形状——也就是说,它们总是具有相同的属性名称,并且这些属性以相同的顺序添加——那么它可以在此基础上偷工减料少做一些事情。
优化编译器使用监视器通过观察代码执行收集的信息来做出这些判断。如果某件事在之前的所有循环中都为真,则它假定它将继续为真。
但是当然对于 JavaScript,从来没有任何保证。您可能有 99 个对象都具有相同的形状,但是第 100 个对象可能缺少一个属性。
所以编译后的代码在运行之前需要检查假设是否有效。如果是,则编译代码运行。但如果不是,JIT 会认为它做出了错误的假设并且丢弃优化后的代码。
然后执行返回到解释器或基线编译版本。此过程称为去优化(或解救)。
通常优化编译器会使代码更快,但有时它们会导致意想不到的性能问题。如果您的代码不断优化然后去优化,它最终会比只执行基线编译版本慢。
大多数浏览器都添加了限制,以便在这些优化/去优化循环发生时打破它们。如果 JIT 已经进行了尝试优化超过 10 次并且最后都要放弃它,它就会停止尝试。
优化示例:类型专业化
有很多不同类型的优化,但我想看一下其中的一种类型,以便您了解优化是如何发生的。优化编译器的最大胜利之一来自称为类型专业化的东西。
JavaScript 使用的动态类型系统需要在运行时做一些额外的工作。例如,考虑这段代码:
function arraySum(arr) {
var sum = 0;
for (var i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
+=
循环中的步骤可能看起来很简单。看起来您可以一步完成计算,但由于动态类型,它需要的步骤比您预期的要多。
假设这arr
是一个包含 100 个整数的数组。一旦代码预热,基线编译器将为函数中的每个操作创建一个存根。所以会有一个存根 for sum += arr[i]
,它将把+=
操作作为整数加法来处理。
但是,sum
和arr[i]
不能保证是整数。因为类型在 JavaScript 中是动态的,所以在循环的后续迭代中,有arr[i]
可能是一个字符串。整数加法和字符串连接是两种截然不同的操作,因此它们会编译成截然不同的机器码。
JIT 处理这个问题的方式是编译多个基线存根(baseline stubs)。如果一段代码是单态的(即,总是以相同的类型调用),它将得到一个存根。如果它是多态的(从一个代码传递到另一个代码,使用不同的类型调用),那么它将为通过该操作的每种类型组合获得一个存根。
这意味着 JIT 在选择存根之前必须询问很多问题。
因为每一行代码在基线编译器中都有自己的一组存根,所以 JIT 需要在每次执行代码行时继续检查类型。因此,对于循环中的每次迭代,它都必须提出相同的问题。
如果 JIT 不需要重复这些检查,代码将执行得更快。这就是优化编译器所做的事情之一。
在优化编译器中,整个函数被一起编译。类型检查被移动,以便它们发生在循环之前。
一些 JIT 甚至进一步优化了这一点。例如,在 Firefox 中,数组有一个特殊的分类,它只包含整数。如果arr
是这些数组之一,则 JIT 不需要检查是否arr[i]
是整数。这意味着 JIT 可以在进入循环之前进行所有类型检查。
结论
简而言之,这就是 JIT。它通过在代码运行时监控代码并发送要优化的热代码路径(hot code paths)来使 JavaScript 运行得更快。这为大多数 JavaScript 应用程序带来了许多倍的性能改进。
但是,即使有了这些改进,JavaScript 的性能仍然无法预测。为了加快速度,JIT 在运行时增加了一些开销,包括:
- 优化和去优化
- 当发生紧急救助(bailout)时,用于监视器的簿记(bookkeeping)和恢复信息的内存,
- 用于存储函数的基线和优化版本的内存
这里有改进的空间:可以消除开销,使性能更可预测。这就是 WebAssembly 所做的事情之一。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步