为什么 array.push 有时比 array[n] = value 快?

IT技术 javascript arrays performance firefox browser
2021-01-21 05:59:43

作为测试一些代码的附带结果,我编写了一个小函数来比较使用 array.push 方法与直接寻址 (array[n] = value) 的速度。令我惊讶的是,推送方法通常显示出更快,尤其是在 Firefox 中,有时在 Chrome 中。只是出于好奇:有人对此有解释吗?您可以在@此页面找到测试(单击“数组方法比较”)

6个回答

各种因素都会起作用,大多数 JS 实现都使用平面数组,如果以后需要,它会转换为稀疏存储。

基本上,变得稀疏的决定是基于正在设置的元素以及为了保持平坦而浪费多少空间的启发式方法。

在您的情况下,您首先设置最后一个元素,这意味着 JS 引擎将看到一个数组,该数组的长度n只需要一个元素。如果n足够大,这将立即使数组成为稀疏数组——在大多数引擎中,这意味着所有后续插入都将采用慢速稀疏数组的情况。

您应该添加一个额外的测试,在其中填充从索引 0 到索引 n-1 的数组——它应该快得多。

为了回应@Christoph 并出于拖延的愿望,这里描述了数组是如何(通常)在 JS 中实现的——具体情况因 JS 引擎而异,但一般原则是相同的。

所有 JS Object(不是字符串、数字、真、假、undefinednull)都继承自基本对象类型——确切的实现各不相同,它可以是 C++ 继承,也可以是在 C 中手动实现(以任何一种方式执行都有好处) ) -- 基本 Object 类型定义了默认的属性访问方法,例如。

interface Object {
    put(propertyName, value)
    get(propertyName)
private:
    map properties; // a map (tree, hash table, whatever) from propertyName to value
}

这个 Object 类型处理所有标准的属性访问逻辑,原型链等。 然后 Array 实现变成

interface Array : Object {
    override put(propertyName, value)
    override get(propertyName)
private:
    map sparseStorage; // a map between integer indices and values
    value[] flatStorage; // basically a native array of values with a 1:1
                         // correspondance between JS index and storage index
    value length; // The `length` of the js array
}

现在,当您在 JS 中创建数组时,引擎会创建类似于上述数据结构的内容。当您将对象插入到 Array 实例中时,Array 的 put 方法会检查属性名称是否为 0 到 2^32 之间的整数(或可以转换为整数,例如“121”、“2341”等) -1(或者可能是 2^31-1,我完全忘记了)。如果不是,则将 put 方法转发到基础 Object 实现,并完成标准的 [[Put]] 逻辑。否则将值放入数组自己的存储中,如果数据足够紧凑,那么引擎将使用平面数组存储,在这种情况下,插入(和检索)只是一个标准的数组索引操作,否则引擎将转换数组稀疏存储,并放置/获取使用地图从 propertyName 获取值位置。

老实说,我不确定在转换发生后是否有任何 JS 引擎当前从稀疏存储转换为平面存储。

Anyhoo,这是对发生的事情的相当高层次的概述,并遗漏了一些更棘手的细节,但这是一般的实现模式。附加存储的具体细节以及 put/get 的调度方式因引擎而异——但这是我能真正描述设计/实现的最清楚的地方。

一个小的补充点,虽然 ES 规范指的propertyName是字符串 JS 引擎也倾向于专门用于整数查找,因此someObject[someInteger]如果您正在查看具有整数属性的对象,则不会将整数转换为字符串,例如。数组、字符串和 DOM 类型(NodeLists 等)。

@Christoph:是的,它们是我在(过长)带注释的答案中提到的 C 风格的实现;JSC、V8 和 KJS 都是 C++ impls,JSC 和 V8 将属性哈希表与对象分开存储,iirc SM 使用树而不是哈希表 - 每个人做同样的事情都不一样
2021-03-13 05:59:43
@olliej:“大多数 JS 实现都使用平面数组,如果以后有必要,它会转换为稀疏存储”- 有趣。那么数组对象有两种存储方式:一种用于常规属性,一种用于数组条目?
2021-03-15 05:59:43
@Christoph:是的——如果你愿意,我可以详细介绍,但它会偏向于 JavaScriptCore/Nitro 实现——一般模型在 SpiderMonkey、V8 和 KJS 中是相同的,但我不知道他们的确切实现细节
2021-03-15 05:59:43
@olliej:谢谢,这是有道理的。我在页面上添加了一个 [0..n] 测试,它更快,我明白为什么。与 push [0..n] 相比,在所有浏览器中都更快。
2021-03-21 05:59:43
@olliej:刚刚检查了 SpiderMonkey 源:JSObject 结构包含一个dslot成员(d 表示动态),只要 JS 数组是密集的,它就会保存一个实际数组;我没有检查稀疏数组或使用非数组索引属性名称时会发生什么
2021-03-30 05:59:43

这些是我通过你的测试得到的结果

在 Safari 上:

  • Array.push(n) 1,000,000 个值:0.124 秒
  • Array[n .. 0] = 值(降序)1,000,000 个值:3.697 秒
  • Array[0 .. n] = 值(升序)1,000,000 个值:0.073 秒

在火狐上:

  • Array.push(n) 1,000,000 个值:0.075 秒
  • Array[n .. 0] = 值(降序)1,000,000 个值:1.193 秒
  • Array[0 .. n] = 值(升序)1,000,000 个值:0.055 秒

在 IE7 上:

  • Array.push(n) 1,000,000 个值:2.828 秒
  • Array[n .. 0] = 值(降序)1,000,000 个值:1.141 秒
  • Array[0 .. n] = 值(升序)1,000,000 个值:7.984 秒

根据您的测试push方法在 IE7 上似乎更好(差异很大),并且由于在其他浏览器上差异很小,因此push方法似乎确实是将元素添加到数组的最佳方式。

但是我创建了另一个简单的测试脚本来检查什么方法可以快速将值附加到数组,结果真的让我感到惊讶,使用 Array.length 似乎比使用 Array.push 快得多,所以我真的不知道是什么再说或想了,我一无所知。

顺便说一句:在我的 IE7 上,你的脚本停止了,浏览器问我是否想让它继续(你知道典型的 IE 消息说:“停止运行这个脚本?......”)我会建议减少一点循环.

push() 是更一般的 [[Put]] 的特例,因此可以进一步优化:

在数组对象上调用 [[Put]] 时,必须首先将参数转换为无符号整数,因为所有属性名称 - 包括数组索引 - 都是字符串。然后必须将它与数组的长度属性进行比较,以确定是否必须增加长度。推送时,无需进行此类转换或比较:只需使用当前长度作为数组索引并增加它。

当然还有其他一些会影响运行时的事情,例如调用push()应该比调用 [[Put]] via 慢,[]因为原型链必须检查前者。


正如 olliej 指出的那样:实际的 ECMAScript 实现将优化转换,即对于数字属性名称,没有完成从字符串到 uint 的转换,而只是简单的类型检查。基本假设应该仍然成立,尽管它的影响将比我最初假设的要小。

所有 JS 引擎实际上都针对整数优化 [[Put]] 假设,如果您使用的是整数,它可能是一种对 Integer 属性名称具有特殊处理程序的类型——例如。数组、字符串以及 DOM 类型(NodeLists、CanvasPixelArray 等)
2021-03-28 05:59:43
错误,完成最后一条评论 - 他们首先假设 Integer,然后通用对象回退会将 Integer 转换为字符串,并再次尝试使用字符串表示。
2021-04-07 05:59:43

这是一个很好的测试平台,它证实直接分配比推送快得多:http : //jsperf.com/array-direct-assignment-vs-push

编辑:显示累积结果数据似乎存在一些问题,但希望它很快得到修复。

我找到了一个 jsperf 并用它替换了我的混乱表格。
2021-03-14 05:59:43
这是一个令人困惑的答案,我同意。这张桌子对我来说是新测量的基础。
2021-03-25 05:59:43
感谢您的评论!是的,你是对的。缓慢的部分是创建一个数组,而不是推送。我更新了答案。
2021-04-07 05:59:43
你的测试存在严重缺陷这两个测试中,您都预先分配了 1,000 个元素的数组。在您的push测试,你再添加其他元素1000使用push通过在您的第一次测试中简单地更改new Array(len)[],我看到的结果更接近,实际上表明push从空数组使用会稍微快一些jsbin.com/epesed/22
2021-04-08 05:59:43
您为什么要发表评论“请忽略下面的测量表。请参阅编辑 2。”?为什么不删除我们应该忽略的表格?你的答案写得非常混乱。没有人关心编辑,他们关心写得好的答案。如果人们确实关心编辑历史,他们就可以使用。
2021-04-08 05:59:43

array[n] = value(升序时)总是比array.push前一种情况下的数组先用长度初始化时快

从检查的JavaScript源代码的网页,你的Array[0 .. n] = value (ascending)测试不初始化事先长度的阵列。

因此,Array.push(n)有时在第一次运行时会领先,但在随后的测试运行中,Array[0 .. n] = value (ascending)实际上始终表现最佳(在 Safari 和 Chrome 中)。

如果代码被修改,那么它会提前初始化一个具有长度的数组,就像var array = new Array(n)thenArray[0 .. n] = value (ascending)显示的那样,它的array[n] = value执行速度比Array.push(n)我对这个特定测试代码的基本运行快 4.5 到 9 倍

这与其他测试一致,如@Timo Kähkönen 报道。具体参见他提到的这个测试的修订版:https : //jsperf.com/push-method-vs-setting-via-key/10

修改后的代码,所以你可以看到我是如何编辑它并以公平的方式初始化数组的(不是不必要地用 array.push 测试用例的长度初始化它):

function testArr(n, doPush){

  var now = new Date().getTime(),
                  duration,
                  report =  ['<b>.push(n)</b>',
                             '<b>.splice(0,0,n)</b>',
                             '<b>.splice(n-1,0,n)</b>',
                             '<b>[0 .. n] = value</b> (ascending)',
                             '<b>[n .. 0] = value</b> (descending)'];
  doPush = doPush || 5;

  if (doPush === 1) {
   var arr = [];
   while (--n) {
     arr.push(n);
   }
  } else if (doPush === 2) {
   var arr = [];
   while (--n) {
    arr.splice(0,0,n);
   }
  } else if (doPush === 3) {
   var arr = [];
   while (--n) {
    arr.splice(n-1,0,n);
   }
  } else if (doPush === 4) {
   var arr = new Array(n);
   for (var i = 0;i<n;i++) {
    arr[i] = i;
   }
  } else {
    while (--n) {
    var arr = [];
      arr[n] = n;
    }
  }
  /*console.log(report[doPush-1] + '...'+ arr.length || 'nopes');*/
  duration = ((new Date().getTime() - now)/1000);
  $('zebradinges').innerHTML +=  '<br>Array'+report[doPush-1]+' 1.000.000 values: '+duration+' sec' ;
  arr = null;
}