深究 JavaScript 数组 —— 演进&性能
作者:Paul Shan 原文:Diving deep into JavaScript array - evolution & performance
写文章前我要说一下,这篇文章不是讲 JavaScript 数组基础的,也不会教相关的语法和用法。文章更多的是讲数组在内存中的存储方式、优化、不同语法导致的行为差异、性能和最近的改进。
我接触 JavaScript 的时候,已经对 C/C++/C# 等语言相当熟悉了。但和许多 C/C++使用者一样,第一次与 JavaScipt 的「约会」并不愉快。
我不喜欢 JavaScipt 的一个主要原因是它的Array。 JavaScript 的数组通过哈希映射或者字典的方式来实现,所以不是连续的。我觉得这是一门劣等语言:连数组都不能正确的实现。但时至今日, JavaScript 以及我对 JavaScript 的理解都有相当大的变化。
为什么 JavaScript 数组不是真正的数组
在讲 JavaScript 相关的东西前,让我先告诉你什么是 Array。
数组( Array )在内存中用一串连续的区域来存放一些值。注意「连续」一词,它至关重要。
上图表示存储在内存中的一个数组。存储4个4 bit 的元素,一共需要16 bit 存储区域,每个元素顺序保持一致。
假设,我声明了tinyInt arr[4],它占用从1201位置开始的一溜存储区域。当某个时刻我尝试读取a[2],那么只要做简单的计算,找到a[2]的位置就可以了。例如1201+(2X4)然后直接从1209位置读取数据。
在 JavaScript 中,数组是哈希映射。它可以通过多种数据结构实现,其中一种是链表。所以,如果在 JavaScript 中声明var arr = new Array(4);它会产生类似上图的结构。因此,如果你想在程序中某一处读取a[2],它必须从1201位置开始溯寻a[2]的位置。
这就是 JavaScript 数组和真正的数组不同的地方。显然数学计算要比链表遍历花的时间少。遇到长点的数组,日子就不好过了啊。
JavaScript 数组演进
还记得以前如果某个朋友的电脑有 256MB RAM我们多嫉妒吗?但现在,8GB RAM 已经稀松平常了。
跟它一样,JavaScript 这门语言也进化了许多。由于V8 、SpiderMonkey、TC39以及日益增多的 web 用户的努力,世界已经离不开 JavaScript 了。拥有如此巨大的用户群体,性能提升也势在必行。
近些日子, JavaScript 引擎已经在为同种数据类型的数组分配连续的存储空间了。优秀的开发者总是保持数组的数据类型一致,这样即时编译器 (JIT) 就能像 C 编译器一样通过计算读取数组了。
但是,如果你想在同种类型的数组中插入不同类型的元素,JIT 会销毁整个数组然后用以前的办法重建。
所以,如果你没写垃圾代码的话,JavaScript 的Array对象会维护一个真正的数组,这对现代 JS 开发者来说是一件大好事。
另外,在 ES2015/ES6 中, 数组还有其它改进。 TC39 决定在 JavaScript 中引入类型化数组,所以如今我们有 ArrayBuffer了。
ArrayBuffer 会有一大块连续的存储位置,你能用它做任何你想做的事情。不过,直接处理内存涉及非常底层的操作,相当复杂。
所以我们有 Views 来处理 ArrayBuffer。已经有一些可用的 View 了,未来还会加:
var buffer = new ArrayBuffer(8);
var view = new Int32Array(buffer);
view[0]=100;
如果你想知道更多有关类型化数组的信息,可以去看看MDN 文档
类型化数组性能良好且非常高效。WebGL 开发者因为缺少高效处理二进制数据的手段而经常面临性能问题,所以提出了类型化数组。你还可以使用SharedArrayBuffer在多个 web-workers 间共享内存数据来提升性能。
惊讶吗?从简单的哈希映射开始,我们现在已经在讨论SharedArrayBuffer了。
旧数组 vs 类型化数组-性能
我们已经讲了大量 JavaScript 数组的改进了。现在看看类型化数组的好处。我在Mac 上用 Node.js 8.4.0 跑了一些小测试:
旧数组-插入
var LIMIT = 10000000;
var arr = new Array(LIMIT);
console.time('Array insertion time');
for(var i=0;i<LIMIT; i++){
arr[i]=i;
}
console.timeEnd('Array insertion time');
所需时间:55ms
类型化数组-插入
var LIMIT = 10000000;
var buffer = new ArrayBuffer(LIMIT * 4);
var arr = new Int32Array(buffer);
console.time("ArrayBuffer insertion time");
for (var i = 0; i < LIMIT; i++) {
arr[i] = i;
}
console.timeEnd("ArrayBuffer insertion time");
所需时间:52ms
我擦,我看到了啥?旧数组和 ArraryBuffer 的性能一样?不。回顾一下,前面我已经说过了,如今编译器已经在为类型一致的数组分配连续的存储空间了。所以在第一个例子中,即使我用的是new Array(LIMIT),它仍然维护的是一个类型化数组。
让我们把第一个例子改成类型不一致的数组看看性能有没有变化。
旧数组-插入(类型不一致)
var LIMIT = 10000000;
var arr = new Array(LIMIT);
arr.push({a: 22});
console.time('Array insertion time');
for(var i=0;i<LIMIT; i++){
arr[i]=i;
}
console.timeEnd('Array insertion time');
所需时间:1207ms
我在上面第三行插入了一个表达式让数组的类型不一致,其它所有的都跟前面一模一样。但是性能上有了巨大的变化:慢了整整22倍。
旧数组-读取
var arr = new Array(LIMIT);
arr.push({a: 22});
for (var i = 0; i < LIMIT; i++) {
arr[i] = i;
}
var p;
console.time("Array read time");
for (var i = 0; i < LIMIT; i++) {
//arr[i] = i;
p = arr[i];
}
console.timeEnd("Array read time");
所需时间:196ms
类型化数组-读取
var LIMIT = 10000000;
var buffer = new ArrayBuffer(LIMIT * 4);
var arr = new Int32Array(buffer);
console.time("ArrayBuffer insertion time");
for (var i = 0; i < LIMIT; i++) {
arr[i] = i;
}
console.time("ArrayBuffer read time");
for (var i = 0; i < LIMIT; i++) {
var p = arr[i];
}
console.timeEnd("ArrayBuffer read time");
所需时间:27ms
结论
在 JavaScript 中引入类型化数组是一个巨大的进步,Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array 等都是类型化数组 view,按照原生的 byte 数排序。你也可以看看 DataView 创建自己的 view 窗口。希望在将来会有更多 DataView 库方便我们使用 ArrayBuffer。
JavaScript 对数组做的改进很棒。现在它们快速、高效并且在分配内存时足够聪明了。
参考
- Is JavaScript really interpreted or compiled language?
- Create / filter an array to have only unique elements in it
- Object.entries() & Object.values() in EcmaScript2017 (ES8) with examples
- import vs require – ESM & commonJs module differences
- A deep dive into ember routers – Ember.js Tutorial part 5
- Myths and Facts of JavaScript