译者:蒋海涛
JavaScript 对象可以和任何属性有关联。对象属性的名称可以包含任何字符。有趣的是 JavaScript 引擎可以选择名称为纯数字的属性来进行优化,而这个属性其实就是数组 index。
在 V8 中,会特殊处理整数名称的属性(最常见的形式是由 Array 构造函数生成的对象)。尽管很多情况下这些数字索引属性的表现和其他属性一样,但为了优化,V8 将它们和非数字属性分开存储。在内部,V8 甚至给这些属性一个特殊的名称:elements。对象通过properties可以 map 到一些 value ,而数组通过 index 可以 map 到一些子元素。
尽管这些内部细节从来没有直接向 JavaScript 开发人员公开,但它们解释了为什么某些代码模式比其他模式更快。
常见的 elements 类型
在运行 JavaScript 代码时,V8 会追踪每个数组的 elements 的类型。V8 可以根据这些信息,在对拥有这种 elements 类型的数组进行操作时,进行针对性的优化。例如,当在数组上调用 reduce
,map
或 forEach
时,V8 可以根据数组的 elements 类型来优化这些操作。
以这个数组为例:
const array = [1, 2, 3];
这个数组的 elements 类型是什么呢?如果用 typeof
来回答,结果就是这个数组含有 number
类型的数。在语言层面,这就是我们能看到的:JavaScript 不会区分整数(integers),浮点数(floats)和双精度数(doubles),它们都只是数字。但在引擎层面,我们可以做更精确地区分。该数组的 elements 类型为 PACKED_SMI_ELEMENTS
。在 V8 中,术语 Smi
是指用于存储小整数(small integers)的一种特定格式。
然后,向同一个数组中添加浮点数会把这个数组转为更通用的 elements 类型
const array = [1, 2, 3];
// elements 类型: PACKED_SMI_ELEMENTS
array.push(4.56);
// elements 类型: PACKED_DOUBLE_ELEMENTS
向数组中添加字符串将再次改变数组 elements 类型
const array = [1, 2, 3];
// elements 类型: PACKED_SMI_ELEMENTS
array.push(4.56);
// elements 类型: PACKED_DOUBLE_ELEMENTS
array.push('x');
// elements 类型: PACKED_ELEMENTS
到目前为止,我们已经看到了 3 种不同的 elements 类型,以下是基本类型
- Sm all i ntegers,也就是 Smi
- Doubles,用于不能用 Smi 表示的浮点数(floating-point)和整数(integers)
- 常规 elements,用于不能表示为 Smi 或双精度值(doubles)的值
注意,doubles 是 Smi 的一种更通用的变体,常规 elements 是 doubles 之上的另一种泛化。用 Smi 表示的数字集 是 用 double 表示的数字集的子集。
重点是 elements 类型只向一个方向转化,从特殊的(比如: PACKED_SMI_ELEMENTS
) 转向更常规的(比如: PACKED_ELEMENTS
),比如一旦一个数组被标记为是 PACKED_ELEMENTS
,它就不能再转化成 PACKED_DOUBLE_ELEMENTS
类型的了。
目前,我们已经了解到
- V8 会对每个数组赋予一个 elements 类型
- 数组的 elements 类型并不是一成不变的 —— 它可以在运行时改变。之前的例子中有从
PACKED_SMI_ELEMENTS
转向PACKED_ELEMENTS
的 - elements 类型只能从特定类型转向常规类型
PACKED
和 HOLEY
类型
目前我们只谈到了 packed 类型的数组。在数组中创建 holes (使数组变稀疏)会将其 elements 类型降级成它的 "holey" 版本
const array = [1, 2, 3, 4.56, 'x'];
// elements 类型: PACKED_ELEMENTS
array.length; // 5
array[9] = 1; // array[5] 到 array[8] 现在都是 holes
// elements 类型: HOLEY_ELEMENTS
V8 之所以有这种区别,它在优化 packed 类型数组的操作上比 holey 类型数组更积极。在 packed 类型数组上大多数操作都可以有效率地执行。相比之下,在 holey 类型数组上,这些操作就需要在原型链上进行额外的检测,并耗费性能高昂的查询。
到目前为止,我们已经看到每种基本 elements 类型(即 Smis,double 和常规 elements 类型)都有两种: packed 版本和 holey 版本。它们不仅可以从 PACKED_SMI_ELEMENTS
转变成 PACKED_DOUBLE_ELEMENTS
,而且还可以从任何 PACKED
类型转变成其 HOLEY
对应类型。 总结一下:
- 最常见的 elements 类型有
PACKED
类型和HOLEY
类型 - 在 packed 类型数组上的操作比 holey 类型数组更有效率
- elements 类型可以从
PACKED
类型转变成HOLEY
类型
elements 类型 格
这套标记转换系统被 V8 弄成了一个 格。下面是只有几个 elements 类型的简化示意图:
它只能通过格来向下转变。一旦一个 Smis 数组添加了单个浮点数(single floating-point),即便之后使用 Smi 覆盖该浮点数,它都会被标记为 DOUBLE。同样的,一旦一个数组中出现了 hole,即便之后将这个 hole 补上了,它都会被标记为 holey。
V8 目前区分了 21 种不同的 elements 类型,每一种都可能有一堆优化
通常,更特定的 elements 类型支持更细粒度的优化。格中 elements 类型越往下,其对象的操作就会越慢。为了获取最佳性能,避免不太特定的类型的这种不必要的转换,应坚持使用最适合情况的特定 elements 类型。
性能建议
大多数情况下,elements 类型的追踪工作是在底层运行的,没必要考虑得那么细。但为了从系统中获取最大收益,以下几件事情是可以做的。
避免读取超出数组长度的内容
有点出乎意料(鉴于这篇文章的标题)的是,我们的第 1 个性能建议与 elements 类型追踪没有直接联系(尽管背后发生的事情有点像)。读取超过数组长度的数据会对性能产生惊人的影响,例如当 array.length === 5
时去读 array[42]
的数据。这个例子中数组下标 42
已经越界,数组本身就没这属性,JS 引擎就会耗费昂贵的性能去原型链上找。一旦加载遇到这种情况,V8 会记住 "这个加载需要处理特殊情况",而且它的速度再也不会像读取到越界之前那么快了。
不要把循环写成这样:
// 不要这么写!
for (let i = 0, item; (item = items[i]) != null; i++) {doSomething(item);
}
这段代码读取数组中的所有元素,然后再读取一个元素。直到它发现 undefined
的或 null
元素时才结束。(jQuery 在一些地方就这么干的。)
相反,用老方式写循环,并不断迭代,直到到达最后一个元素。
for (let index = 0; index < items.length; index++) {const item = items[index];doSomething(item);
}
如果循环的对象是可迭代的(比如数组和 NodeLists
)就更好了,直接用 for-of
for (const item of items) {doSomething(item);
}
对于特定数组,也可以用内置 forEach
items.forEach((item) => {doSomething(item);
});
现在 for-of
和 forEach
的性能都和老式的 for 循环差不多了。
避免读取超出数组长度的内容!在这种情况下,V8 的边界检查会失败,检查该属性是否存在也就会失败,然后 V8 就要从原型链上找了。如果之后在计算中不小心使用到了这个值(也就是超出数组长度的值),影响会更糟,例如:
function Maximum(array) {let max = 0;for (let i = 0; i <= array.length; i++) { // 糟糕的比较if (array[i] > max) max = array[i];}return max;
}
在这里,最后一次迭代超出了数组长度,返回结果为 undefined
,这既影响了加载,又影响了比较:不再只比较数字,它要处理特殊情况。把终止条件改为正确的 i < array.length
可使本示例的性能提高 6 倍(在有 10,000 个元素的数组上进行测试,迭代次数只减少了 0.01%)。
避免 elements 类型的变化
通常,如果需要在一个数组上执行很多操作,试着只用一种元素类型,尽可能是特定类型,这样 V8 可以尽可能对这些操作进行优化。
这比看上去要难。比如仅向一个 Smi 数组中添加 -0
就能把它变成 PACKED_DOUBLE_ELEMENTS
。
const array = [3, 2, 1, +0];
// PACKED_SMI_ELEMENTS
array.push(-0);
// PACKED_DOUBLE_ELEMENTS
结果就是,之后对该数组的任何操作的优化都与对 Smi 的优化不一样。
避免使用 -0
,除非明确需要在代码中区分 -0
和 +0
。(最好不要这么做)
对于 NaN
和 Infinity
而言都是一样的。它们都被看作是浮点数(doubles),所以在一个 SMI_ELEMENTS
数组中添加一个 NaN
或者是 Infinity
,这个数组就会变成 DOUBLE_ELEMENTS
const array = [3, 2, 1];
// PACKED_SMI_ELEMENTS
array.push(NaN, Infinity);
// PACKED_DOUBLE_ELEMENTS
要对一个整数数组进行大量的操作了,在它初始化时就应考虑下把 -0
变成 0
,NaN
和 Infinity
之类的值就应该过滤掉。这样一来,这个数组才会维持在 PACKED_SMI_ELEMENTS
状态。这种一次性标准化后的开销对于后续优化都是值得的。
实际上,如果要对数字(numbers)数组进行数学操作,可以考虑下 TypedArray。这也有对应的特定的 elements 类型。
优先使用 array 而不是 array-like 的对象
有些 JS 里的对象,特别是 DOM,看起来像是数组但其实它们并不是真正意义上的数组。创建的 array-like 的数组就像下面这样
const arrayLike = {};
arrayLike[0] = 'a';
arrayLike[1] = 'b';
arrayLike[2] = 'c';
arrayLike.length = 3;
这个对象有 length
,也可以通过下标索引访问子元素(就像数组一样!),但它在其原型链上缺少数组方法,比如 forEach
。不过仍可以通过下面的方式在这个对象上调用数组的方法
Array.prototype.forEach.call(arrayLike, (value, index) => {console.log(`${ index }: ${ value }`);
});
// 先打印 '0: a', 然后打印 '1: b', 最后打印 '2: c'.
这段代码调用 array-like 对象上内置的 Array.prototype.forEach
方法,结果符合预期。但这比在真数组上调用 forEach
慢,而后者在 V8 中已被高度优化。要多次在此对象上使用内置的数组方法的话,就应先把它转成真数组再用:
const actualArray = Array.prototype.slice.call(arrayLike, 0);
actualArray.forEach((value, index) => {console.log(`${ index }: ${ value }`);
});
// 先打印 '0: a', 然后打印 '1: b', 最后打印 '2: c'.
这种一次性转换的开销对于后续的优化来讲都是值得的,特别是当对数组执行大量操作时。
arguments
对象是一个 array-like 对象,可以在其上调用数组内置函数,但这种操作不会像对真数组那样做全方位的优化。
const logArgs = function() {Array.prototype.forEach.call(arguments, (value, index) => {console.log(`${ index }: ${ value }`);});
};
logArgs('a', 'b', 'c');
// 先打印 '0: a', 然后打印 '1: b', 最后打印 '2: c'.
ES2015 rest 参数可以在这里帮个忙。它们可以用真数组,而不是优雅地用 array-like 的 arguments
对象。
const logArgs = (...args) => {args.forEach((value, index) => {console.log(`${ index }: ${ value }`);});
};
logArgs('a', 'b', 'c');
// 先打印 '0: a', 然后打印 '1: b', 最后打印 '2: c'.
现在你还有啥借口用 arguments
对象。
所以一般来讲,尽可能避免使用 array-like 的对象,应尽可能使用真数组。
避免多态
如果代码中要处理很多不同的 elements 类型的数组,它可能会导致多态操作,这比只用处理单个 elements 类型的代码要慢。
看如下示例,里面调用了各种 elements 类型的库函数。(注意下这不是原来的 Array.prototype.forEach
方法,除了本文讨论的对特定 elements 类型的优化,这个示例自己也有一套优化。)
const each = (array, callback) => {for (let index = 0; index < array.length; ++index) {const item = array[index];callback(item);}
};
const doSomething = (item) => console.log(item);each([], () => {});each(['a', 'b', 'c'], doSomething);
// `PACKED_ELEMENTS` 调用了 `each` 方法。V8 使用了内联缓存
// (或者说叫 "IC") 记住了这个 `each` 方法是被这个 elements 类型调用的。
// 若不出意外,V8 会乐观地假定在 `each` 方法里访问 `array.length` 和 `array[index]` 时
// 是单一的(比如只接受一种 elements 类型),之后每次调用 `each` 方法,V8 就会去检查这个类型
// 是不是 `PACKED_ELEMENTS`,如果是,V8 会重用之前生成的代码;
// 如果不是,就需要做更多事情了each([1.1, 2.2, 3.3], doSomething);
// `PACKED_DOUBLE_ELEMENTS` 调用了 `each` 方法。 V8 此时看到,在它的内联缓存里面,
// 给 `each` 方法传的是不同的 elements 类型的数组了,那么在 `each` 方法里访问 `array.length` 和 `array[index]` 时就被打上了多态的标记。
// 现在每次在调用 `each` 方法时 V8 都要去做下额外的检查:
// 1. 这个是不是 `PACKED_ELEMENTS`(就像上面说过的)
// 2. 这个是不是 `PACKED_DOUBLE_ELEMENTS`
// 3. 这个还是不是其他的 elements 类型
// 这就会引起性能上的损耗each([1, 2, 3], doSomething);
// `PACKED_SMI_ELEMENTS` 调用了 `each` 方法。这就触发了另一个种程度的多态性。现在在内联缓存中,对于 `each` 方法来说有 3 种不同的 elements 类型。从现在开始每次调用 `each` 方法,就需要另外检查 elements 类型,才能将生成的代码重新用于 `PACKED_SMI_ELEMENTS` 数组,而这都需要以消耗性能为代价才能做的。
内置方法(如 Array.prototype.forEach
)可以更有效地处理这种多态性,因此如果对性能敏感,请考虑使用这些内置方法而不是用户手写的库函数
V8 中关于单态与多态的另一个例子就跟对象的 shape 相关,也就是对象的隐藏类。要了解更多请参考 这篇文章
避免创建 holes
在真正的代码看来,访问 holey 数组和 packed 数组之间的性能差异通常太小,甚至无法测量。如果性能测试表明在优化的代码中保留每一条机器指令是值得的,那么可以尝试把数组维持在 packed 模式。比如说,我们要创建一个数组
const array = new Array(3);
// 此时这个数组是稀疏的,所以它被标记为 `HOLEY_SMI_ELEMENTS`
// 根据当前的信息这就是最可能的结果array[0] = 'a';
// 等等,这是一个字符而不是一个 Smi,所以 elements 类型转成 `HOLEY_ELEMENTS`array[1] = 'b';
array[2] = 'c';
// 此时,数组的 3 个位置都被填满了。所以数组是 packed 了(不再是稀疏的了)。
// 然而现在已经不能把这个数组再转成一个特定类型比如 `PACKED_ELEMENTS` 了。
// elements 类型仍然为 `HOLEY_ELEMENTS`
一旦数组被标记为 holey,它将永远保持在 holey 状态,即便之后数组里面有元素了
创建数组的更好方法是使用如下方式
const array = ['a', 'b', 'c'];
// elements 类型: PACKED_ELEMENTS
如果事先不知道所有的值,可以创建一个空数组,然后将值 push
进去
const array = [];
// …
array.push(someValue);
// …
array.push(someOtherValue);
这种方法确保了数组永远不会转换为 holey elements 类型。因此,V8 可能会为这个数组的某些操作生成更快的优化代码。
调试 elements 类型
为了弄明白啥是对象的 elements 类型,可用 d8 的调试版本运行(通过在 debug 模式下从源码进行构建,或使用 jsvu 弄到预编译的二进制文件)
out/x64.debug/d8 --allow-natives-syntax
这将打开一个 d8 REPL,其中可用 %DebugPrint(object)
等特殊函数。输出的 elements
字段显示了传递给 这个 debug 函数的对象的 elements 类型。
d8> const array = [1, 2, 3]; %DebugPrint(array);
DebugPrint: 0x1fbbad30fd71: [JSArray]- map = 0x10a6f8a038b1 [FastProperties]- prototype = 0x1212bb687ec1- elements = 0x1fbbad30fd19 <FixedArray[3]> [PACKED_SMI_ELEMENTS (COW)]- length = 3- properties = 0x219eb0702241 <FixedArray[0]> {#length: 0x219eb0764ac9 <AccessorInfo> (const accessor descriptor)}- elements= 0x1fbbad30fd19 <FixedArray[3]> {0: 11: 22: 3}
[…]
注意,COW 表示copy-on-write,这是另一个内部优化。
在调试构建中可用的另一个有用的 flag 是 --trace-elements-transitions
。用上它能让 V8 提示你 elements 类型转换是在啥时发生的。
$ cat my-script.js
const array = [1, 2, 3];
array[3] = 4.56;$ out/x64.debug/d8 --trace-elements-transitions my-script.js
elements transition [PACKED_SMI_ELEMENTS -> PACKED_DOUBLE_ELEMENTS] in ~+34 at x.js:2 for 0x1df87228c911 <JSArray[3]> from 0x1df87228c889 <FixedArray[3]> to 0x1df87228c941 <FixedDoubleArray[22]>
注:该文章翻译自https://v8.dev/blog/elements-kindsV8 的官方博客,这是关于解释在 V8 中「elements」的类型都有哪些的一篇文章,文章有翻译的不是很清楚的地方,欢迎各位指正