JavaScript 引擎如何实现卓越性能


JavaScript 是一项令人印象深刻的技术。这并不是因为它设计得特别好(其实并不是),也不是因为几乎所有有互联网连接的消费设备都执行过 JavaScript 程序。而是因为,尽管 JavaScript 的几乎每一个特性都使其优化变得极为困难,它依然能够表现得非常快。

想一想,没有类型信息。每个对象在程序的生命周期内都可以增加和删除属性。有六种(!)不同类型的 “假” 值,而每个数字都是 64 位浮点数。如果这些还不够麻烦,JavaScript 还被期望能够快速执行,因此也不能花太多时间分析和优化代码。

然而,JavaScript 的执行速度依然很快。

这到底是怎么做到的呢?

在本文中,我们将仔细看看不同的 JavaScript 引擎用来实现良好运行时性能的一些技术。请记住,我故意略去了一些细节并进行了简化。本文的目标并不是让你完全了解这些技术的工作原理,而是让你理解它们背后的理论,以便你在本系列后续实验中能够理解。

执行系统模型

当浏览器下载 JavaScript 时,首要任务是尽可能快地让代码运行。它通过将代码翻译成字节码——虚拟机指令来实现这一点,然后将这些字节码交给一个解释器或虚拟机执行。

你可能会问,为什么浏览器要将 JavaScript 转换为虚拟机指令,而不是直接转化为机器指令。这是一个很好的问题。事实上,直到最近,V8(Chrome 的 JavaScript 引擎)还一直是直接转换为机器指令。

为特定编程语言设计的虚拟机通常是一个更容易的编译目标,因为它与源语言有更紧密的关系。而实际的机器有一个更通用的指令集,因此将编程语言转换为这些指令需要更多的工作。这使得编译过程变得更长,从而延长了 JavaScript 开始执行的时间。

举个例子,一个理解 JavaScript 的虚拟机也可能理解 JavaScript 对象。因此,像 object.x 这样的语句,所需的虚拟指令可能只是一两条指令。而一台不理解 JavaScript 对象的实际机器,可能需要更多指令才能确定 .x 在内存中的位置并读取它。

虚拟机的问题在于,它是“虚拟的”,并不存在。指令不能被直接执行,必须在运行时解释。解释代码的速度总是会比直接执行代码慢。

这是一种权衡:更快的编译时间对比更快的运行时间。在许多情况下,更快的编译是一个好的权衡。用户不太可能在意单个按钮点击是花了 20 毫秒还是 40 毫秒,尤其是按钮只按了一次时。快速编译 JavaScript 即使导致代码执行稍慢,也能让用户更早地看到并与页面互动。

然而有些情况需要大量计算,比如游戏、语法高亮或计算千个数字的 fizzbuzz 字符串。在这些情况下,编译并执行机器指令的总时间可能会减少总的执行时间。那么 JavaScript 如何处理这些情况呢?

热代码

当 JavaScript 引擎检测到某个函数是“热的”(即被多次执行)时,它会将该函数交给优化编译器处理。这个编译器会将虚拟机指令转换为实际的机器指令。更重要的是,由于该函数已经执行了几次,优化编译器可以基于之前的运行做出一些假设,进而进行推测性优化以生成更快的代码。

如果后来这些推测被证明是错误的怎么办?JavaScript 引擎可以简单地删除这个优化但错误的函数,回退到未优化的版本。一旦该函数再次被多次执行,JavaScript 引擎可以再次将其交给优化编译器处理,这一次它有更多的信息可以用于推测性优化。

现在我们知道,频繁运行的函数在优化过程中会利用之前执行中的信息,接下来要探索的是这些信息究竟是什么。

编译中的问题

几乎所有的 JavaScript 元素都是对象。然而,不幸的是,JavaScript 对象是非常难以让机器处理的复杂事物。让我们来看一下下面的代码:

1
2
3
function addFive(obj) {
return obj.method() + 5;
}

编译一个函数为机器指令相对来说是简单的,返回值同样如此。但是,机器并不知道什么是对象,那么你如何编译对象 objmethod 属性访问呢?

如果我们知道 obj 的结构会很有帮助,但在 JavaScript 中,我们永远无法确定。任何对象都可以在程序运行时添加或删除 method 属性。即使该属性存在,我们也不能确定它是否是一个函数,更不用说调用它会返回什么结果了。

为了理解将其编译为机器指令的过程,我们可以尝试将上述代码翻译成没有对象的 JavaScript 子集。

首先,我们需要一种表示对象的方式。我们还需要一种从对象中检索值的方法。数组在机器代码中是简单支持的,因此我们可以采用这样的表示方式:

1
2
3
4
5
6
7
8
9
10
11
// 一个像 { method: function() {} } 这样的对象
// 可以表示为:
// [ [ "method" ], // 属性名
// [ function() {} ] ] // 属性值

function lookup(obj, name) {
for (var i = 0; i < obj[0].length; i++) {
if (obj[0][i] === name) return i;
}
return -1;
}

有了这个,我们可以尝试实现一个简单的 addFive 函数:

1
2
3
4
5
6
7
8
9
10
function addFive(obj) {
var propertyIndex = lookup(obj, "method");
var property = propertyIndex < 0 ? undefined : obj[1][propertyIndex];

if (typeof(property) !== "function") {
throw NotAFunction(obj, "method");
}
var callResult = property(/* this */ obj);
return callResult + 5;
}

当然,这段代码在 obj.method() 返回非数字时会出错,因此我们需要对其做一些调整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function addFive(obj) {
var propertyIndex = lookup(obj, "method");
var property = propertyIndex < 0 ? undefined : obj[1][propertyIndex];

if (typeof(property) !== "function") {
throw NotAFunction(obj, "method");
}
var callResult = property(/* this */ obj);
if (typeof(callResult) === "string") {
return stringConcat(callResult, "5");
} else if (typeof(callResult) !== "number") {
throw NotANumber(callResult);
}
return callResult + 5;
}

这段代码可以工作了,但显然如果我们能提前知道 obj 的结构以及 method 的类型,那么这段代码可以跳过一些步骤,从而提高执行速度。

隐藏类

所有主流的 JavaScript 引擎都会以某种方式跟踪对象的结构。在 Chrome 中,这个概念被称为隐藏类。我们在本文中也将其称为“隐藏类”。

让我们从以下代码片段开始:

1
2
3
4
var obj = {}; // 空对象
obj.x = 1; // 对象的结构现在发生变化,包含了 `x` 属性
obj.toString = function() { return "TODO"; }; // 结构再次发生变化
delete obj.x; // 结构再次发生变化

如果我们将这段代码翻译为机器指令,如何在添加和删除新属性时跟踪对象的结构呢?如果我们使用之前的数组表示对象的想法,可能会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
var emptyObj__Class = [ 
null, // 没有父类隐藏类
[], // 属性名
[] // 属性类型
];

var obj = [
emptyObj__Class, // `obj` 的隐藏类
[] // 属性值
];

var obj_X__Class = [
emptyObj__Class, // 与空对象包含相同的属性
["x"], // 以及一个名为 `x` 的属性
["number"] // 其中 `x` 是数字类型
];

obj[0] = obj_X__Class; // 结构发生变化
obj[1].push(1); // `x` 的值

var obj_X_ToString__Class = [
obj_X__Class, // 包含与先前结构相同的属性
["toString"], // 以及一个名为 `toString` 的属性
["function"] // 其中 `toString` 是一个函数
];

obj[0] = obj_X_ToString__Class; // 结构变化
obj[1].push(function() { return "TODO"; }); // `toString` 的值

var obj_ToString__Class = [
null, // 删除 `x` 后从头开始
["toString"],
["function"]
];

obj[0] = obj_ToString__Class;
obj[1] = [obj[1][1]];

如果我们生成像这样的虚拟机指令,现在就有了一种方法来随时跟踪对象的结构。然而,单靠这些并不能真正帮助我们。我们需要将这些信息存储在某个有用的地方。

内联缓存

每当 JavaScript 代码对对象进行属性访问时,JavaScript 引擎会将该对象的隐藏类以及查找结果(即属性名到索引的映射)存储在缓存中。这些缓存被称为内联缓存(inline caches),它们有两个重要的作用:

  1. 在执行字节码时,如果对象的隐藏类已在缓存中,内联缓存可以加快属性访问的速度。
  2. 在优化过程中,内联缓存包含了有关访问对象属性时涉及的对象类型的信息,这有助于优化编译器生成适合这些类型的代码。

内联缓存对其存储的隐藏类数量有限制,这既节省了内存,又确保在缓存中进行查找的速度很快。如果从内联缓存中检索索引比从隐藏类中检索花费的时间更长,那么缓存就没有任何作用。

据我所知,至少在 Chrome 中,内联缓存最多会跟踪 4 个隐藏类。在此之后,内联缓存将被禁用,信息将存储在全局缓存中。全局缓存也有大小限制,一旦达到限制,较新的条目将覆盖较旧的条目。

为了最大化利用内联缓存并帮助优化编译器,应该尝试编写只对单一类型对象进行属性访问的函数。如果对象类型过多,生成的代码性能将不尽如人意。

内联(Inlining)

内联是一种独立但重要的优化。简而言之,这种优化通过用被调用函数的实现替换函数调用来进行。以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function map(fn, list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
}

return newList;
}

function incrementNumbers(list) {
return map(function(n) { return n + 1; }, list);
}

incrementNumbers([1, 2, 3]); // 返回 [2, 3, 4]

经过内联后,代码可能会变成如下样子:

1
2
3
4
5
6
7
8
9
10
function incrementNumbers(list) {
var newList = [];
var fn = function(n) { return n + 1; };
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
}
return newList;
}

incrementNumbers([1, 2, 3]); // 返回 [2, 3, 4]

这样做的一个好处是,删除了函数调用。更大的好处是,JavaScript 引擎现在对函数的实际执行有了更多的了解。基于这个新版本,JavaScript 引擎可能会再次进行内联优化:

1
2
3
4
5
6
7
8
9
function incrementNumbers(list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(list[i] + 1);
}
return newList;
}

incrementNumbers([1, 2, 3]); // 返回 [2, 3, 4]

又一个函数调用被移除。而且优化器现在可能会推测 incrementNumbers 只会被传入包含数字的列表作为参数。它还可能决定内联 incrementNumbers([1, 2, 3]) 的调用,并发现 list.length 为 3,这可能进一步导致以下结果:

1
2
3
4
5
6
var list = [1, 2, 3];
var newList = [];
newList.push(list[0] + 1);
newList.push(list[1] + 1);
newList.push(list[2] + 1);
list = newList;

简而言之,内联使得可以跨越函数边界进行一些本来不可能的优化。

然而,内联有其局限性。内联会由于代码重复导致函数变大,从而需要更多的内存。JavaScript 引擎对函数的大小有一个预算限制,如果超出预算,内联将被跳过。

有些函数调用也很难内联,尤其是当函数作为参数传递时。

此外,除非传递的总是同一个函数,否则函数作为参数时内联也会变得困难。虽然这看起来是一个奇怪的做法,但由于内联,最终可能会出现这种情况。

结论

JavaScript 引擎有很多技巧来提高运行时性能,远不止本文所涵盖的内容。然而,本文描述的优化适用于大多数浏览器,并且易于验证它们是否被应用。因此,当我们尝试改进 Elm 的运行时性能时,主要会专注于这些优化。

但在开始优化之前,我们需要一种方法来识别哪些代码可以改进。