不变性是否会损害JavaScript的性能?


88

JavaScript中似乎有一种趋向于将数据结构视为不变的趋势。例如,如果您需要更改一个对象的单个属性,则最好使用新属性创建一个全新的对象,然后从旧对象复制所有其他属性,然后对旧对象进行垃圾回收。(那是我的理解。)

我最初的反应是,这听起来会降低性能。

但是后来,像Immutable.jsRedux.js这样的库是由比我更聪明的人编写的,并且似乎对性能有很强的关注,因此使我怀疑我对垃圾的理解(及其对性能的影响)是否错误。

我所缺少的不变性是否具有性能优势,它们是否超过了创建大量垃圾的弊端?


8
他们非常关注性能,部分原因是不可变性(有时)会降低性能成本,并且他们希望尽可能降低性能成本。从某种意义上说,不变性本身仅具有性能优势,因为它使编写多线程代码变得更加容易。
罗伯特·哈维

8
以我的经验,性能仅是两种情况下的有效关注点:一种是在一秒内执行一次30次以上的操作,另一种是每次执行后效果都会提高(Windows XP曾经发现一个错误,导致Windows Update花费了很多时间)O(pow(n, 2))对于其历史记录中的每次更新。)大多数其他代码是对事件的立即响应。单击,API请求或类似请求,并且只要执行时间恒定,那么清除任何数量的对象就无关紧要。
Katana314 2015年

4
另外,请考虑存在不可变数据结构的有效实现。也许这些方法没有可变的方法有效,但是可能比幼稚的实现方法更有效。参见例如克里斯·冈崎(Chris Okasaki)的“纯粹的功能数据结构”
Giorgio

1
@ Katana314:对我来说,超过30次仍然不足以证明担心性能。我将编写的小型CPU仿真器移植到node.js,然后node在20MHz左右(每秒2000万次)执行虚拟CPU。因此,如果我每秒执行1000次以上的操作,我只会担心性能(即使那样,我也不会真正担心,直到每秒执行1000000次操作,因为我知道一次可以轻松执行10次以上) 。
slebetman 2015年

2
@RobertHarvey“从某种意义上说,不可变性本身仅具有性能上的优势,因为它使得编写多线程代码更加容易。” 事实并非完全如此,不变性允许非常普遍的共享,而没有实际后果。在可变环境中这是非常不安全的。这使您认为像O(1)数组切片并O(log n)插入到二叉树中,同时仍然可以自由使用旧的树,另一个例子是tails,列表的所有尾部tails [1, 2] = [[1, 2], [2], []]仅占用O(n)时间和空间,但O(n^2)在元素数方面
分号

Answers:


59

例如,如果您需要更改一个对象的单个属性,则最好使用新属性创建一个全新的对象,然后从旧对象复制所有其他属性,然后对旧对象进行垃圾回收。

没有不变性,您可能必须在不同范围之间传递对象,并且事先不知道是否以及何时更改对象。因此,为避免产生不必要的副作用,您可以开始创建对象的完整副本,以防万一,即使事实证明根本不需要更改任何属性,也可以传递该副本。与您的情况相比,这将留下更多的垃圾。

这说明-如果创建正确的假设方案,则可以证明一切,尤其是在性能方面。但是,我的示例并不像听起来那样假想。上个月,我在一个程序上工作,我们偶然发现了这个问题,因为我们最初决定不使用不可变的数据结构,后来又犹豫要重构它,因为它似乎不值得麻烦。

因此,当您从一个较旧的SO帖子中查看类似情况时,您问题的答案可能就很清楚了- 这取决于。在某些情况下,不变性会损害性能,在某些情况下,情况恰恰相反,在很多情况下,这取决于实现的智能程度,在更多情况下,差异可以忽略不计。

最后一点:您可能会遇到的现实问题是,您需要尽早决定是否支持某些基本数据结构的不变性。然后,您在此基础上构建了大量代码,几周或几个月后,您将看到该决定是好是坏。

对于这种情况,我个人的经验法则是:

  • 如果您设计的数据结构仅具有基于基本类型或其他不可变类型的几个属性,请首先尝试不可变性。
  • 如果要设计一种数据类型,其中包含大(或未定义)大小的数组,涉及随机访问和内容更改的情况,请使用可变性。

对于这两个极端之间的情况,请使用您的判断。但是YMMV。


8
That will leave a lot more garbage than in your case.更糟糕的是,您的运行时可能将无法检测到毫无意义的重复,因此(与没有人使用的过期不可变对象不同),它甚至没有资格进行收集。
Jacob Raihle 2015年

37

首先,您对不可变数据结构的描述不精确。通常,大多数数据结构不会复制,而是共享,并且仅复制更改的部分。它称为持久数据结构。大多数实现大多数时候都可以利用持久性数据结构。该性能足够接近可变数据结构,功能程序员通常认为它可以忽略不计。

其次,我发现很多人对典型命令式程序中对象的典型生存期的想法不太准确。也许这是由于内存管理语言的流行。坐下来一段时间,看看与真正的长期数据结构相比,您创建了多少个临时对象和防御性副本。我认为您会对比例感到惊讶。

我曾在函数式编程类中让人们评论过,我教过一个算法会产生多少垃圾,然后我会展示相同算法的典型命令式版本,它会产生同样多的垃圾。出于某些原因,人们不再注意到它了。

通过鼓励共享和不鼓励创建变量,直到您要输入有效值,不变性往往会鼓励更简洁的编码实践和更长寿的数据结构。根据算法的不同,这通常会导致相当程度的垃圾,如果不是更低的话。


8
“ ...然后,我展示了相同算法的典型命令式版本,它创建了同样多的命令。” 这个。同样,对这种风格不熟悉的人,尤其是通常对功能风格不熟悉的人,最初可能会产生次优的功能实现。
wberry

1
“区分创建变量”不是仅对默认行为是赋值/隐式构造中进行复制的语言有效吗?在JavaScript中,变量只是标识符。它本身不是对象。它仍然占据某个地方的空间,但是可以忽略不计(尤其是,据我所知,大多数JavaScript实现仍然使用堆栈进行函数调用,这意味着除非您进行大量递归操作,否则最终只会在大多数情况下重用相同的堆栈空间临时变量)。不变性与这方面无关。
2015年

33

后来这个问答已经有了不错的答案,但是我想作为一个外国人习惯于从较低级别的内存中查看位和字节的观点。

我对不变的设计感到非常兴奋,即使是从C的角度来看,也从寻找新方法有效地编程我们如今拥有的这种野兽硬件的角度都感到兴奋。

较慢/较快

关于它是否会使事情变慢的问题,将是一个机器人的答案yes。在这种非常技术性的概念水平上,不变性只会使事情变慢。硬件在不偶尔分配内存的情况下表现最佳,而只能修改现有的内存(为什么我们有类似时间局部性的概念)。

一个实际的答案是maybe。在任何非平凡的代码库中,性能仍然基本上是生产力指标。即使我们忽略了这些错误,我们通常也不会发现在竞争条件下绊倒的可怕的可维护代码库是最有效的。效率通常是优雅和简单的功能。微观优化的高峰可能会有些冲突,但通常是为最小和最关键的代码部分保留的。

转换不可变的位和字节

从低级别的观点来看今后,如果我们x射线的概念,如objectsstrings等等,在它的心脏只是在具有不同的速度/大小特性的各种形式的存储器(速度和存储器的硬件的尺寸通常为位和字节互斥)。

在此处输入图片说明

当我们重复访问相同的内存块时(例如上图),计算机的内存层次结构会喜欢它,因为它将经常访问的内存块保持为最快的内存形式(L1高速缓存,例如,几乎与寄存器一样快)。我们可能会重复访问完全相同的内存(多次重用)或重复访问该块的不同部分(例如:循环访问一个连续块中的元素,这些元素重复访问该存储块的各个部分)。

如果修改该内存最终想要在侧面创建一个全新的内存块,我们最终会在该过程中投入一把扳手,如下所示:

在此处输入图片说明

在这种情况下,访问新的内存块可能需要强制执行页面错误和高速缓存未命中,以将其移回到最快的内存形式(一直到寄存器中)。那可能是真正的性能杀手。

但是,有一些方法可以缓解这种情况,即使用已经涉及的预分配内存的保留池。

大骨料

从更高层次的观点引起的另一个概念性问题是简单地批量复制非常大的聚合。

为了避免过于复杂的图表,让我们想象一下这个简单的内存块在某种程度上是昂贵的(在难以置信的有限硬件上可能是UTF-32字符)。

在此处输入图片说明

在这种情况下,如果我们想将“ HELP”替换为“ KILL”并且该存储块是不可变的,则我们将必须完整地创建一个全新的块以创建唯一的新对象,即使其中只有一部分已更改:

在此处输入图片说明

扩展我们的想象力,仅仅为了使一小部分变得独特,对其他所有内容的这种深层复制可能会非常昂贵(在现实情况下,此存储块会大得多,从而造成问题)。

然而,尽管有这样的花费,但这种设计将趋向于不易发生人为错误。任何使用过具有纯函数功能语言的人都可能会对此感到赞赏,尤其是在多线程情况下,我们可以在无需关心的情况下对此类代码进行多线程处理。通常,人类程序员倾向于跳过状态变化,尤其是那些导致外部副作用导致当前函数范围之外的状态变化的程序员。在这种情况下,即使混合中的外部状态发生可变的变化,即使从外部错误(异常)中恢复也非常困难。

减轻这种冗余复制工作的一种方法是使这些存储块成为字符的指针(或引用)的集合,如下所示:

不好意思,我没意识到L在制作图表时我们不需要做得唯一。

蓝色表示复制的数据浅。

在此处输入图片说明

...不幸的是,这将使每个字符的指针/参考成本变得非常昂贵。此外,我们可能会将字符的内容分散到整个地址空间中,并最终以大量页面错误和缓存未命中的形式为它付出代价,这很容易使此解决方案比复制整个内容更糟糕。

即使我们谨慎地连续分配这些字符,也可以说机器可以将8个字符和8个指向某个字符的指针加载到高速缓存行中。我们最终像这样加载内存以遍历新字符串:

在此处输入图片说明

在这种情况下,我们最终需要加载价值7个不同的高速缓存行的连续内存来遍历此字符串,理想情况下,我们只需要3个。

整理数据

为了缓解上述问题,我们可以应用相同的基本策略,但使用8个字符的粗略级别,例如

在此处输入图片说明

结果需要加载4个高速缓存行的数据(1个用于3个指针,3个用于字符)以遍历该字符串,这仅比理论上的最佳值短1个。

因此,这还算不错。有一些内存浪费,但是内存充足,如果多余的内存只是不经常访问的冷数据,那么消耗更多内存并不会减慢速度。它仅适用于热的连续数据,减少的内存使用和速度通常是并行的,我们希望将更多的内存放入单个页面或缓存行中,并在逐出之前对其进行访问。这种表示非常易于缓存。

速度

因此,利用上述表示形式可以实现相当不错的性能平衡。不可变数据结构的最关键性能用途可能是修改大块数据并使它们在此过程中唯一,同时浅层复制未修改的数据。这也确实暗示了在多线程上下文中安全引用浅表复制段的原子操作的一些开销(可能正在进行一些原子引用计数)。

但是,只要这些粗略的数据片段以足够粗糙的水平表示,那么许多此类开销就会减少甚至可能变得微不足道,同时仍然为我们提供安全性和便捷性,并且无需编写外部代码就能以纯净的形式编码和多线程处理更多函数效果。

保留新旧数据

从性能的角度(从实际意义上来说),不变性可能是最有帮助的地方,当我们可以尝试制作大数据的完整副本,以使其在可变的环境中变得唯一时,目的是从中产生新的东西。当我们可以通过精心设计的不可变设计使其中的一点点与众不同时,就已经存在了一种我们想保留新旧的方式。

示例:撤消系统

一个示例是撤消系统。我们可能会更改数据结构的一小部分,并希望保留我们可以撤消的原始格式和新格式。通过这种不变的设计,仅使数据结构的较小的,经过修改的部分成为唯一的,我们可以简单地将旧数据的副本存储在撤消条目中,而只需支付添加的唯一部分数据的存储成本。这在生产率(使撤消系统的实施成为小菜一碟)和性能之间提供了非常有效的平衡。

高级界面

上述情况仍然有些尴尬。在局部函数上下文中,可变数据通常是最简单,最直接的修改方法。毕竟,修改数组的最简单方法通常是遍历数组并一次修改一个元素。如果我们有大量的高级算法可以选择来转换数组,并且必须选择合适的算法以确保在修改了部分的同时制作所有这些大块浅副本,那么我们最终可能会增加知识开销。变得独一无二。

在这些情况下,最简单的方法可能是在函数上下文内本地使用可变缓冲区(它们通常不会使我们绊倒),该可变缓冲区原子地将更改提交给数据结构以获取新的不可变副本(我相信某些语言会调用这些“瞬变”)...

...或者我们可以简单地对数据上越来越高级的转换函数建模,以便我们可以隐藏修改可变缓冲区并将其提交给结构的过程,而无需涉及可变逻辑。无论如何,这还不是一个被广泛研究的领域,如果我们更多地拥抱不可变的设计,并为如何转换这些数据结构提供有意义的接口,那么我们的工作就被淘汰了。

数据结构

这里出现的另一件事是,在性能至关重要的上下文中使用的不变性可能会希望将数据结构分解为块状数据,在这些数据块中,块的大小不要太小,也不要太大。

链接列表可能需要进行一些更改以适应此情况,并转变为展开列表。大的连续数组可能会变成一个指针数组,这些指针变成具有用于随机访问的模索引的连续块。

它可能会以一种有趣的方式改变我们看待数据结构的方式,同时推动这些数据结构的修改功能看起来更笨重,从而掩盖了在此处浅层复制某些位并使其他位唯一的额外复杂性。

性能

无论如何,这是我对该主题的较低层次的看法。从理论上讲,不变性的代价可能从非常大到很小。但是,非常理论上的方法并不总是使应用程序快速运行。它可能使它们具有可伸缩性,但现实世界中的速度通常需要拥抱更实际的思维方式。

从实践的角度来看,诸如性能,可维护性和安全性之类的质量往往会变得一团糟,尤其是对于非常大的代码库。虽然从某种意义上说,性能会因不变性而降低,但很难说它对生产率和安全性(包括线程安全性)的好处。随着这些的增加,通常可以提高实际性能,这仅仅是因为开发人员有更多的时间来调整和优化他们的代码而不会被错误所困扰。

因此,我认为从这种实际意义上讲,不变的数据结构实际上可能在很多情况下有助于提高性能,听起来似乎很奇怪。理想的世界可能会寻求两者的混合体:不可变的数据结构和可变的数据结构,可变的数据结构通常在非常局部的范围内(例如:函数的局部区域)非常安全地使用,而可变的结构可以避免外在的影响直接执行,并将对数据结构的所有更改转换为原子操作,从而生成新版本,而不会出现竞争情况。


11

ImmutableJS实际上非常有效。如果我们举一个例子:

var x = {
    Foo: 1,
    Bar: { Baz: 2 }
    Qux: { AnotherVal: 3 }
}

如果将上述对象设置为不可变的,则可以修改“ Baz”属性的值:

var y = x.setIn('/Bar/Baz', 3);
y !== x; // Different object instance
y.Bar !== x.Bar // As the Baz property was changed, the Bar object is a diff instance
y.Qux === y.Qux // Qux is the same object instance

这为深度对象模型创建了一些非常酷的性能改进,您只需要在根目录路径上的对象上复制值类型。对象模型越大,所做的更改越小,因为不可变数据结构最终共享许多对象,因此它们的内存和CPU性能越好。

正如其他答案所言,如果将此与尝试将防御性复制提供给x可以操纵它的功能之前的防御性复制来提供相同的保证相比,则性能会明显提高。


4

直线上,不可变代码具有创建对象的开销,这比较慢。但是,在许多情况下,可变代码变得很难有效管理(导致大量防御性复制,这也很昂贵),并且有许多巧妙的策略可减轻“复制”对象的成本,就像别人提到的那样。

如果您有一个对象(例如计数器),并且每秒增加很多次,则使该计数器不变是不值得的。如果您的对象被应用程序的许多不同部分读取,并且每个对象都希望拥有自己的稍微不同的对象克隆,那么您将可以轻松地以一种高效的方式来整理对象,方法是使用不变对象的实现。


4

要添加到这个(已经很好回答的)问题上:

简短的回答是 ; 这将损害性能,因为您仅创建对象而不是对现有对象进行突变,从而导致更多的对象创建开销。


但是,长答案并不是真的

从实际运行时的角度来看,在JavaScript中您已经创建了很多运行时对象-函数和对象文字在JavaScript中无处不在,似乎没有人考虑使用它们。我认为对象创建实际上是很便宜的,尽管我对此没有引用,所以我不会将其用作独立参数。

对我而言,最大的“性能”提高不是运行时性能而是开发人员性能。在开发Real World(tm)应用程序时,我学到的第一件事是,可变性确实很危险且令人困惑。我花了很多时间来追踪执行线程(而不是并发类型),试图找出导致该死的错误的原因,而这实际上是该死应用程序的另一面的变种!

使用不变性使事情很多容易推理。您可以立即知道X对象在其生存期内不会更改,并且更改的唯一方法是克隆它。我更重视这一点(尤其是在团队环境中),而不是可变性可能带来的任何微观优化。

有例外,最值得注意的是如上所述的数据结构。我很少遇到这样的情况:我想在创建后更改一个映射(尽管我承认我是在谈论伪对象文字映射而不是ES6映射),与数组相同。当您处理更大的数据结构时,可变性可能会有所回报。请记住,JavaScript中的每个对象都是作为引用而不是值传递的。


就是说,上面提到的一点是GC及其无法检测到重复项。这是一个合理的问题,但是在我看来,只有在内存是问题时才是问题,而且有很多更简单的方法可以将自己编码到角落—例如,闭包中的循环引用。


最终,我宁愿有一个不变的代码库非常少(如果有的话),可变截面和比具有可变性到处略少高性能。如果出于某种原因,不变性成为性能问题,您以后可以随时进行优化。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.