本文就所有 JavaScript 引擎中常见的一些关键基础内容进行了介绍——这不仅仅局限于 V8 引擎。作为一名 JavaScript 开发者,深入了解 JavaScript 引擎是如何工作的将有助于你了解自己所写代码的性能特征。
在
前一篇文章
中,我们讨论了 JavaScript 引擎是如何通过 Shapes 和 Inline Caches 来优化访问对象与数组的。本文将介绍引擎在优化流程中的一些权衡与取舍点,并对其在优化原型属性访问方面的工作进行介绍。
本文中会涉及 JavaScript 引擎中 Inline Caches 和 Shapes 的概念使用,如果你想了解其中更多细节可以移步上一篇译文
JavaScript 引擎基础:Shapes 和 Inline Caches
查看更多。
原文
JavaScript engine fundamentals: optimizing prototypes
,作者
@Benedikt
和
@Mathias
,译者
hijiangtao
,你也可以在
知乎专栏
查看此文。以下开始正文。
如果你倾向看视频演讲,请移步
YouTube
查看更多。
一、优化层级与执行效率的取舍
前一篇文章
介绍了现代 JavaScript 引擎通用的工作流程:
我们也指出,尽管从高级抽象层面来看,引擎之间的处理流程都很相似,但他们在优化流程上通常都存在差异。为什么呢?
为什么有些引擎的优化层级会比其他引擎多一些?
事实证明,在快速获取可运行的代码与花费更多时间获得最优运行性能的代码之间存在一些取舍与平衡点。
解释器可以快速生成字节码,但字节码通常效率不高。 相比之下,优化编译器虽然需要更长的时间进行处理,但最终会产生更高效的机器码。
这正是 V8 在使用的模型。它的解释器叫 Ignition,(就原始字节码执行速度而言)是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan,最终由它生成高度优化的机器码。
我们往往需要在启动延迟与执行速度之间做出一些取舍,而这便是一些 JavaScript 引擎决定是否在流程中加入优化层的原因所在。例如,SpiderMonkey 在解释器和完整的 IonMonkey 优化编译器之间添加了一个 Baseline 层:
解释器可以快速生成字节码,但字节码执行起来相对较慢。Baseline 生成代码需要花费更长的时间,但能提供更好的运行时性能。最后,IonMonkey 优化编译器花费最长时间来生成机器码,但该代码运行起来非常高效。
让我们通过一个具体的例子,看看不同引擎中的优化流程都有哪些差异。这是一些在循环中会经常出现的代码。
let result = 0;
for (let i = 0; i < 4242424242; ++i) {
result += i;
}
console.log(result);
V8开始在 Ignition 解释器中运行字节码。从某些方面来看,代码是否足够
hot
由引擎决定,引擎还负责调度 TurboFan 前端,它是 TurboFan 中负责处理集成分析数据和构建代码在机器层面表示的一部分。这部分结果之后会被发送到另一个线程上的 TurboFan 优化器被进一步优化。
当优化器运行时,V8 会继续在 Ignition 中执行字节码。 当优化器处理完成后,我们获得可执行的机器码,执行流程便会继续下去。
SpiderMonkey 引擎也开始在解释器中运行字节码。但它有一个额外的 Baseline 层,这意味着比较 hot 的代码会首先被发送到 Baseline。 Baseline 编译器在主线程上生成 Baseline 代码,并在完成后继续后面的执行。
如果 Baseline 代码运行了一段时间,SpiderMonkey 最终会激活 IonMonkey 前端,并启动优化器 - 这与 V8 非常相似。当 IonMonkey 进行优化时,代码在 Baseline 中会一直运行。当优化器处理完成后,被执行的是优化后的代码而不是 Baseline 代码。
Chakra 的架构与 SpiderMonkey 非常相似,但 Chakra 尝试通过并行处理更多内容来避免阻塞主线程。Chakra 不在主线程上运行编译器,而是将不同编译器可能需要的字节码和分析数据复制出来,将其发送到一个专用的编译器进程。
当代码准备就绪,引擎便开始运行 SimpleJIT 代码而不是字节码。 对于 FullJIT 来说流程同样如此。这种方法的好处是,与运行完整的编译器(前端)相比,复制所产生的中断时间通常要短得多。但其缺点在于这种
启发式复制
可能会遗漏某些优化所需的某些信息,因此它在一定程度上是用代码质量来换时间的消耗。
在 JavaScriptCore 中,所有优化编译器都与主 JavaScript 执行
完全并发运行
;根本没有复制阶段!相反,主线程仅仅是触发了另一个线程上的编译作业。然后,编译器使用复杂的加锁方式从主线程中获取到要访问的分析数据。
这种方法的优点在于它减少了主线程上由 JavaScript 优化引起的抖动。 缺点是它需要处理复杂的多线程问题并为各种操作付出一些加锁的成本。
我们已经讨论过在使用解释器快速生成代码或使用优化编译器生成可高效执行代码之间的一些权衡。但还有另一个权衡:
内存使用
!为了说明这一点,来看一个简单的两数相加 JvaScript 函数。
function add(x, y) {
return x + y;
}
add(1, 2);
这是我们使用 V8 中的 Ignition 解释器为
add
函数生成的字节码:
StackCheck
Ldar a1
Add a0, [0]
Return
不要在意这些字节码 - 你不需要了解细节。关键在于它只是
四条指令!
当代码变得
hot
,TurboFan 便会开始处理以生成如下高度优化的机器码:
leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xe88]
jna StackOverflow
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
movq rdx,rbx
shrq rdx, 32
movq rcx,rax
shrq rcx, 32
addl rdx,rcx
jo Deoptimize
shlq rdx, 32
movq rax,rdx
movq rsp,rbp
pop rbp
ret 0x18
这么
一大堆
代码,这比四行要远远超出更多!通常来说,字节码比机器码更紧凑,特别是对比优化过的机器码。但另一方面,字节码需要解释器才能执行,而优化过机器码可以由处理器直接执行。
这就是为什么 JavaScript 引擎不简单粗暴”优化一切”的主要原因之一。正如我们之前所见,生成优化的机器码也需要很长时间,而最重要的是,我们刚刚了解到优化的机器码也需要更多的内存。
小结:JavaScript 引擎之所以具有不同优化层,就在于使用解释器快速生成代码或使用优化编译器生成高效代码之间存在一个基本权衡。通过添加更多优化层可以让你做出更细粒度的决策,但是以额外的复杂性和开销为代价。此外,在优化级别和生成代码所占用的内存之间也存在折衷。这就是为什么 JavaScript 引擎仅尝试优化比较 hot 功能的原因所在。
二、原型属性访问优化
之前的文章
解释了 JavaScript 引擎如何使用 Shapes 和 Inline Caches 优化对象属性加载。回顾一下,引擎将对象的
Shape
与对象值分开存储。
Shapes 可以实现称为 Inline Caches 或简称 ICs 的优化。通过组合,Shapes 和 ICs 可以加快代码中相同位置的重复属性访问速度。
2.1 Class 和基于原型的编程
既然我们知道如何在 JavaScript 对象上快速进行属性访问,那么让我们看一下最近添加到 JavaScript 中的特性:class(类)。JavaScript 中的类语法如下所示:
class Bar {
constructor(x) {
this.x = x;
}
getX() {
return this.x;
}
}
尽管它看上去是 JavaScript 中的一个全新概念,但它仅仅是基于原型编程的语法糖:
function Bar(x) {
this.x = x;
}
Bar.prototype.getX = function getX() {
return this.x;
};
在这里,我们在
Bar.prototype
对象上分配一个
getX
属性。这与其他任何对象的工作方式完全相同,因为原型只是 JavaScript 中的对象!在基于原型的编程语言(如 JavaScript)中,方法通过原型共享,而字段则存储在实际的实例上。
让我们来实际看看,当我们创建一个名为
foo
的
Bar
新实例时,幕后所发生的事情。
const foo = new Bar(true);
通过运行此代码创建的实例具有一个带有属性
“x”
的 shape。
foo
的原型是属于 class
Bar
的
Bar.prototype
。
Bar.prototype
有自己的 shape,其中包含一个属性
'getX'
,取值则是函数
getX
,它在调用时只返回
this.x
。
Bar.prototype
的原型是
Object.prototype
,它是 JavaScript 语言的一部分。由于
Object.prototype
是原型树的根节点,因此它的原型是
null
。