数组应该是在JavaScript最常用的数据结构之一,我们有很多种方式来初始化一个数组。但是 JavaScript 怎样把一个数组的所有元素初始化为0 最有效率?为了得出答案,我们做了一下测试:

我们将在 Node 下(Node.js 版本 v8.4.0)初始化一个长度为 100 万的数组,来对比他们的性能。

不同姿势

姿势 1:
1
2
3
4
5
6
function(sizeofArray) {
let result = [];
for(let i = 0; i < sizeofArray; i++) {
result[i] = 0;
}
}

版本 1 是我们最常见的做法,先声明一个空数组变量,然后用一个 for 初始化数组的每一位。

未预热: 一次 245
预热: 平均 261.9
预热: 总共 23571

姿势 2:
1
2
3
4
5
6
function(sizeofArray) {
let result = [];
for(let i = 0; i < sizeofArray; i++) {
result.push(0);
}
}

版本 2 中我们在版本 1 中的 for 循环的下标赋值变成了数组api push。

未预热: 一次 282
预热: 总共 25233
预热: 平均 280.3666666666667

姿势 3:
1
2
3
4
5
6
function(sizeofArray) {
let result = new Array(sizeofArray);
for(let i = 0; i < sizeofArray; i++) {
result[i] = 0;
}
}

版本 3 中 首先我们先定义Array的大小 然后通过下标进行赋值

未预热: 一次 61
预热: 总共 5048
预热: 平均 56.08888888888889

姿势 4:
1
2
3
function(sizeofArray) {
new Array(sizeofArray).fill(0);
}

版本 4 中 首先定义了 Array 的大小,然后通过 fill 进行初始化

未预热: 一次 250
预热: 平均 59.422222222222224
预热: 总共 5348

姿势 5:
1
2
3
4
5
6
7
8
9
function (sizeofArray) {
let arr = [];
[].length = sizeofArray;
let i = 0;
while (i < sizeofArray) {
arr[i] = 0;
i++;
}
}

版本 6 首先定义一定一个长度为0的数组变量,然后赋值给 length 为要初始化的大小,最后用循环初始化每一个数。

未预热: 一次 57
预热: 平均 61.355555555555554
预热: 总共 5522

姿势 6:
1
2
3
function(sizeofArray) {
Array.prototype.slice.apply(new Uint8Array(sizeofArray));
}

异类 版本5 我们先定义固定大小的 Uint8Array, 这个Type Array 会首先都初始化为0 之后调用Array原型中的slice将其转化为数组

未预热: 一次 1276
预热: 平均 913.7111111111111
预热: 总共 82234

结果对比

具体的结果如下所示,我们可以看到一些很有趣的结果。由于 Version 1 是我们在日常变成中最常见的版本,所以我们以 Version 1 为标准对比其他的版本。

array-init-performance

首先我们可以看到 Version 2 和 Version 1 相比并没有太大的差距,所以我们认为 Version 2 几乎与 Version 1 的写法等价。带来的稍微增多的时间可能是对 push 函数内部的调用所带来的。

而 Version 3 和 Version 1 相比带了了四倍左右的性能提升。而写法上的改变仅仅是因为在初始化的时候发生了改变,即 let arr = [](或者写成)let arr = Array() 变成了 let arr = Array(size)。为啥这个小小的改变能够带来如此的性能提升呢(问题一)?如果具有 C 或者 C++ 背景的的同学应该会找到一点头绪。

Version 3 和 Version 5 基本等价。

对于 Version 4 中在第一次运行的过程中和 Version 1 保持了差不多的性能,但是在运行了多次之后平均的时间变快了。我们知道 JIT 会对运行的代码进行优化,应该是 Version 4被 JIT 优化了。

最后当然最慢的是逗逼的 Version 6, 其实这个在性能上的损失是显而易见的,Uint8Array 通过 slice 发生了数据转换。从 TypeArray 转换到了 Array, 而且 0 在 Array 中是一个 64 位双精度二进制的值,而 Uint8Array 中 0 是一个无符号的 8 位整数。

问题

通过上面的分析,我们总结一下遇到的问题。

  1. let arr = [](或者写成)let arr = new Array() 变成了 let arr = new Array(size)。这个小小的改变能够带来如此的性能提升呢。
  2. arr.length = size 发生了什么

到底发生了什么?

要回答上面的问题要如何下手呢?首先我们先要弄清楚数组在构造, 下标赋值,push, fill, slice 的时候干了什么事情。

ECMA Spec

要具体了解发生了什么事情,我们只能去翻一翻 ECMA 标准中是如何规定这些操作的行为的。具体的可以参阅

1.Array 构造

2.Array的下标赋值描述如下

3.Array fill and push

根据 ECMA 标准,Array 初始的时候let arr = [](或者)let arr = new Array()let arr = new Array(size)没有本质的区别,只是后者的数组实例的 length 的 赋值为 size。但是在写入数组的过程中,我们发现 Version 1 会频繁的修改数组实例的 length 属性,这样势必会带来性能的损失。当然根据经验,这点频繁的修改在实际的过程中可能并不会带来多少的性能损失。那么到底哪里出问题了呢?

没办法只能看内存了

所以我们只能从别的方面来想办法。如果有其他语言编程背景,应该会猜测对于 Version 1 在插入的时候可能会频繁的出现数组长度不够,需要扩展的时候。那么我们猜测是频繁的内存分配导致了性能的损失。但是根据上面的 ECMA 的标准,其中并没有说明是否当时应该给分类内存,初始化的不同仅仅是 length 的不一样。所以并不能确定这一点,有可能 Version 3 也并没有分配内存,也需要发生频繁的内存分配(当然,JavaScript 的 Runtime 不会像我这么傻)。如何确定呢?

所以我们还需要跟准确的分析。所以我们在 Chrome DevTool 分别运行两段代码,在 Performance 的比较如下所示。很明显的看到 Version 1 多发生了两次的 Minor GC。 这就是性能损失的原因。Version 1 中可能初始的内存池比较小,在为数组开辟空间的时候,发生了频繁的内存分配,并且当无法分配的时候就会发生 GC。而 Version 3 预先的为数组留够了空间,从而不会再写入的过程中发生 GC。

array-init-performance

是时候回答问题了

通过上面的分析我们可以回答问题1. 性能的损失主要是在内存的频繁分配和 GC 上。

而问题2 根据 ECMA Spec 很容易的出来。arr.length = size 在 spec 中只是改变了 length 大小。不过如果 size < length, 会删除 size 之后的元素。如果 size > length, 则会形成一个空洞数组(hole).这个新的 size 会告诉 runtime 预留出空间来。

最正确初始化姿势

通过上面的分析我们可以看到如果你要操作大数组,那么最好是预留出空间来。lodosh 的 map 好像就是这么做的。不过通过版本4 可以看到,JIT 经过预热后性能也是最理想的,而且代码长度和可读性也是里面最好的一个版本。

所以自然如果是 es6 的环境下 new Array(sizeofArray).fill(0); 是初始化的最好选择。

当然这个上面都是突发奇想来探究这个问题,优化在点滴,但是又常说不要过早优化。这篇文章就当做提供一个思路来分析我们工作遇到的问题。