【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式
原文链接:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec
更多文章见译者 Github Repo (star 求 ++)
几周前,我们开始了一系列旨在深入挖掘 JavaScript 及其实际工作原理(的研究),我们认为:通过了解 JavaScript 的构建块以及其集成方法,您将能够编写更好的代码和应用程序。
该系列的第一部分重点对于引擎,Runtime(运行时)机制和堆栈调用进行一个简要描述。
第二部分检视了 Google V8 JavaScript 引擎的内部,并提供了一些有关如何编写更好的 JavaScript 代码的提示。
在第三部分中,我们将讨论开发人员越来越忽视的另一个关键主题,由于日常使用的编程语言的日益成熟和复杂而产生的 —— 内存管理。我们还将提供一些有关如何用 SessionStack (原作推荐的工具)处理 JavaScript 内存泄漏的方法,我们确保 SessionStack 不会导致内存泄漏,也不会增加我们集成的 Web 应用程序的内存消耗。
Overview
一些计算机语言 (如 C) 具有低级内存管理基元, 如 malloc()
和 free()
。开发人员使用这些基元显式分配和释放操作系统的内存。
JavaScript 在创建事物(对象,字符串等)时分配内存,并且会在不再使用时 “自动” 释放它们,这个过程被称为垃圾回收。 释放资源的这种看似 “自动” 的性质是混乱的根源,给了 JavaScript(和其他高级语言)开发人员错误的印象:即他们可以选择不关心内存管理。这是一个极大的错误。
即便使用高级语言,开发人员也应该对内存管理(或至少内存管理的基本知识)有所了解。 有时,开发人员必须理解自动内存管理(例如垃圾回收器中的错误或实现限制等)中的问题,以便正确处理它们(或找到适当的、存在最少量的利弊权衡和代码 “坑” 的解决方法)。
内存生命周期
任何编程语言,内存的生命周期几乎总是相同的:
以下是对周期每个步骤发生的情况的概述:
- 分配内存 - 内存由操作系统分配,允许程序使用它。在低级语言(例如C)中,这是您作为开发人员处理的显式操作。 然而,在高级语言中,这是被自动完成的。
- 使用内存 - 这是你的程序实际使用早前分配的内存的时间。你在代码中使用分配的变量时,读写操作正在进行。
- 释放内存 - 现在是释放您不需要的整个内存的时间,以便它可以再次可用并可用。与分配内存操作一样,这种操作在低级语言中是显式的。
要快速了解调用堆栈和内存堆的概念,您可以阅读我们关于该主题的首篇文章。
内存是什么
在直接跳转到 JavaScript 中内存相关问题之前,我们将简要讨论一般内存以及它的工作原理。
在硬件层面上,计算机内存由大量的触发器(flip flops)组成。 每个触发器包含几个晶体管,并且能够存储一个位。 单个触发器可通过唯一的标识符寻址,因此我们可以读取、覆盖它们。 因此,在概念上,我们可以将整个计算机内存看作是我们可以阅读和写入的一大堆数组。
作为人类,我们不是很善长把我们所有的逻辑思考和计算都放在一个个的 bit 里,我们把它们组织成更大的组,它们可以一起用来表示数字。 8 位称为 1 字节(byte)。 除了字节之外,还有字(有时是 16 位,有时是 32 位)。
很多东西都存储在这个内存中:
- 所有程序使用的所有变量和其他数据。
- 程序的代码,包括操作系统本身的代码。
编译器和操作系统共同合作,为大多数内存管理提供帮助,但我们建议您查看更底层下的内容。
编译代码时,编译器可以检查原始数据类型,并提前计算出需要多少内存。然后将所需的数量分配给调用堆栈空间中的程序。这些变量分配的空间称为堆栈空间,随着函数被调用,它们的内存被添加到现有存储器的顶部。当它们(函数)终止时,它们以 LIFO(先入先出)顺序被移除。 例如,考虑以下声明:
|
|
编译器可以立即获取到代码需要 4 + 4 × 4 + 8 = 28 字节。
这是它与当前大小的整数和双精度数字的分配结果。
大约 20 年前,整数通常为 2 个字节,双字节为 4 个字节。当然,您的代码不应该依赖于此时基本数据类型的大小。
编译器将注入与操作系统交互的代码,以便在堆栈中获取存储的变量所需的字节数。
在上面的示例中,编译器知道每个变量的精确内存地址。事实上,每当我们写入变量 n
时,这个内部变换成 “内存地址 4127963”。
请注意,如果我们尝试访问 x[4]
,我们将访问与 m
相关联的数据。这是因为我们正在访问数组中不存在的元素(存在x[0]
、x[1]
、x[2]
、x[3]
) - 比数组中最后一个实际分配的元素(x[3]
)还要多 4 个字节,可能会读取(或覆盖)一些 m
位。这几乎肯定会对其他后续程序产生非常不良的后果。
当函数调用其他函数时,每个函数在调用时都会获得自己的堆栈块。它保存所有的局部变量,还有一个程序计数器,可以记住它在执行中的位置。当函数功能完成时,其内存块再次可用于其他目的。
动态分配
不幸的是,当我们在编译时不知道一个变量需要多少内存时,事情就比较复杂了。假设我们要做如下的事情:
|
|
在编译时,编译器不知道数组需要多少内存,因为它由用户提供的值决定。
因此,它不能为堆栈上的变量分配空间。 相反,我们的程序需要在运行时明确地要求操作系统获得适当的空间量。 这个内存是从堆空间分配的。 静态和动态内存分配的区别如下表所示:
为了充分了解动态内存分配的工作原理,我们需要花更多的时间在指针上,这可能与这篇文章的主题有太多的偏离。 如果您有兴趣了解更多信息,请在评论中通知我们,我们可以在未来的文章中详细介绍指针。
JavaScript 内存回收
现在我们来解释第一步(分配内存)在 JavaScript 中的工作原理。
JavaScript可以缓解开发人员处理内存分配的任务 - JavaScript 本身就是声明值。
|
|
一些函数调用也导致对象分配:
|
|
方法可以分配新的值或对象:
|
|
在 JavaScript 中使用内存
使用 JavaScript 中分配的内存,就意味着要在其中读取和写入。这可以通过读取或写入变量或对象属性的值,甚至将参数传递给函数来完成。
当内存不再需要时释放
大部分的内存管理问题都在这个阶段。
这里最困难的任务是确定何时分配的内存不再被需要。它通常需要开发人员确定程序中哪里不再需要这样的内存,并释放它。
高级语言嵌入了一块名为垃圾回收器(garbage collector)的软件,该工作是跟踪内存分配和使用,以便在一段已分配的内存不再被需要的情况下,找到它、将其自动释放。
不幸的是,这个过程不是完全准确的,因为判断一些存储器是否需要的问题是不可判定的(不能用算法求解)。
大多数垃圾回收器通过回收不再能被访问的内存来工作,即指向它的所有变量都超出了范围。然而,这是可以回收的一组存储器空间的近似值,因为在任何位置,存储器可能仍然具有指向其范围的变量,只不过是它将永远不会被再次访问。
垃圾回收
由于发现判定某些内存是否 “不再需要” 是不准确的,所以垃圾回收对一般问题进行了解决方案上的限制。本节将介绍理解主要垃圾回收算法及其局限性的必要概念。
内存引用
垃圾回收算法所依赖的主要概念参考以下。
在内存管理的上下文中,如果前者具有对后者的访问权限(可以是隐式的或者显式的),则前者对象可被称为另一个对象(后一个对象)的引用。 例如,JavaScript 对象具有对其原型(隐式引用)及其属性值(显式引用)的引用。这种情况下,“对象” 的概念扩展到比常规 JavaScript 对象更广泛的东西,并且还包含函数范围(或全局词法范围)。
引用计数垃圾回收
这是最简单的垃圾回收算法。 如果有零个引用指向它,则对象被认为是“可回收的垃圾”。
看看下面的代码:
|
|
循环引用造成问题
在循环中有一个限制。 在以下示例中,将创建两个对象并引用彼此,从而创建一个循环。在函数调用之后,它们将超出范围,因此它们实际上是无用的,可以被释放。然而,引用计数算法认为,由于两个对象中的每一个至少被引用一次,所以也不能被垃圾回收。
|
|
标记和扫描算法
为了确定是否需要对象,该算法确定对象是否可访问。该算法由以下步骤组成:
- 垃圾回收器构建 “根” 列表。“根” 通常是代码中保留引用的全局变量。在 JavaScript 中,
window
对象是可以作为根的全局变量的示例。 - 所有 “根” 被检查并标记为活动(即不是垃圾)。所有的内部子变量也被递归检查。从根部可访问到的一切(变量)都不被认为是垃圾。
- 所有未被标记为活动的内存现在可以被认为是垃圾。回收器现在可以释放该内存并将其返回到操作系统。
这个算法比前一个更好,因为前一个方法中 “一个对象有零引用” 会导致这个对象被释放(根对象一般不会有引用)。相反,对于引用不为零的情况,我们已经看到了循环引用这种 bad case。
截止到 2012 年,所有的现代浏览器都有标记和扫描垃圾回收器。JavaScript 领域的垃圾回收(代数/增量/并发/并行垃圾回收),及其所有改进都是对该算法(标记和扫描)的实现进行了改进,但并没有对垃圾回收算法本身的改进,其目标都是确定一个对象是否可被访问到。
在本文中,您可以阅读更多关于跟踪垃圾回收的详细信息,这些垃圾回收也包括标记和扫描以及优化。
循环不再是问题了
在上面的第一个例子中,在函数调用返回之后,两个对象不再被全局对象及其可访问的对象引用。因此,垃圾回收器将发现它们无法访问。
即使对象之间有引用,它们也不可从根目录访问,所以会被正确回收。
垃圾回收器的反直观行为
虽然垃圾回收器很方便,但他们自己也有自己的权衡。其中一个是非确定论。换句话说,GC 是不可预测的。你不能真正地告诉你什么时候可回收。
这意味着在某些情况下,程序会使用实际需要的更多内存。其他情况下,特别敏感的应用程序可能会引起短暂暂停。
虽然非确定性意味着在执行集合时无法确定,但大多数 GC 实现共享在分配期间执行收集遍历的常见模式。如果没有执行分配,大多数 GC 保持空闲状态。
考虑以下情况:
执行相当大的一组内存分配。这些元素中的大多数(或全部)被标记为不可访问(假设我们将指向我们不再需要的缓存的引用置空),然后不执行进一步的分配。
在这种情况下,大多数 GC 将不会再运行任何进一步的内存回收。换句话说,即使有变量不可访问到的参考结论,变量可供回收,回收器仍然不会回收。这些严格来说还不算是泄漏,但仍然会导致高于通常的内存使用。
什么是内存泄漏?
实质上,内存泄漏可以被定义为应用程序不再需要的内存,由于某种原因,不会返回到操作系统或可用内存池。
编程语言有便于管理内存的各种不同方法。然而,是否使用某种内存实际上是一个不可判定的问题。 换句话说,只有开发人员可以清楚一个内存是否可以返回到操作系统。
某些编程语言提供了帮助开发者执行此操作的功能,其他则期望开发人员完全明确何时使用一块内存。 维基百科有关于手动和自动内存管理的好文章。
四种常见的 JavaScript 内存泄漏
1:全局变量
JavaScript 以有趣的方式处理未声明的变量:对未声明变量的引用在全局对象内创建一个新变量。 在浏览器的情况下,全局对象是窗口对象(window
)。 换一种说法:
|
|
相当于:
|
|
如果bar
被认为仅仅在foo
函数的范围内持有对变量的引用,并且您忘记使用var
来声明它,则会创建一个额外的全局变量。
在这个例子中,泄漏一个简单的字符串不会有太大的伤害,但肯定会相对糟一些。
可以通过以下面另一种方法避免创建意外的全局变量:
|
|
为了防止这些错误的发生,添加'use strict'
; 在您的 JavaScript 文件的开头。 这使得更严格的解析 JavaScript 模式能够防止意外的全局变量。 详细了解这种 JavaScript 执行模式。
除了我们意外定义的全局变量,很多代码依然充满了明确的全局变量。这是通过定义产生的不可回收变量(除非指定为零或重新分配)。特别是,用于临时存储和处理大量信息的全局变量是令人关注的。如果你必须使用一个全局变量来存储大量的数据,一定要在你用完它后,将它显式指定为零或重新分配。
2:被遗忘的计时器或回调
在 JavaScript 中使用 setInterval
是很常见的。大多数提供观察者和其他模式的回调函数库都会在自己的实例变得无法访问之后对其收到的任何引用进行处理。然而,在setInterval
的情况下,这样的代码很常见:
|
|
此示例说明了定时器可能发生的情况:计时器引用不再需要的节点或数据。
render
所代表的对象可能会在将来被删除,从而使整个块在 setInterval
处理程序中不再执行。
但是,由于setInterval
仍然有效,因此无法回收(需要停止间隔才能发生)。如果无法回收 setInterval
,则不能回收其依赖。这意味着无法回收 serverData
(可能存储大量数据)。
在观察者的情况下,重要的是进行显式调用,以便在不再需要时删除它们(或者相关对象即将无法访问)。
过去,特别重要的是某些浏览器(旧 IE 6)无法管理好循环引用(有关更多信息,请参见下文)。如今,大多数浏览器一旦观察到的对象变得无法访问,就能回收观察者处理程序,即使侦听器没有被明确删除。但是,在处理对象之前,明确删除这些观察者仍然是一个很好的做法。例如:
|
|
如今,现代浏览器(包括 Internet Explorer 和 Microsoft Edge)使用现代垃圾回收算法,可以检测这些计时器并正确处理它们。 换句话说,在使节点无法访问之前,不必要调用removeEventListener
。
一些框架和库(如 jQuery)在处理节点之前(在为其使用特定的 API 时)会删除侦听器。 这是由库内部处理的,这也确保没有泄漏,即使在有问题的浏览器下运行,如…IE 6。
闭包
JavaScript 语言开发的一个关键方面是闭包:一个可以访问外部(封闭)函数变量的内部函数。 由于 JavaScript 运行时的实现细节,可以通过以下方式泄漏内存:
|
|
这个代码片段做了一件事:每次调用replaceThing
时,TheThing
都会获得一个新对象,它包含一个大的数组和一个新的闭包(someMethod
)。同时,unused
变量保留一个引用了originalThing
的闭包(来自前一次调用replaceThing的Thing
)。
已经有点混乱了吗?重要的是,一旦为同一个父范围内的闭包创建了一个范围,该范围将被共享。
在这种情况下,为闭包someMethod
创建的范围与unused
共享。unused
对originalThing
引用。即使unused
未被使用,一些方法也可以通过replaceThing
范围之外的theThing
(例如全局某处)使用。并且由于someMethod
与unused
共享闭包范围,unused
的引用必须强制originalThing
保持活动(两个闭包之间的整个共享范围)。这样可以防止其回收。
当这个代码段重复运行时,可以观察到内存使用量的稳定增长。当 GC 运行时,这不会变小。实质上,创建了一个关闭的链接列表(其根源以theThing
变量的形式),并且这些闭包的范围中的每一个都对大阵列进行间接引用,导致相当大的泄漏。
这个问题由Meteor
团队发现,他们有一篇伟大的文章,详细描述了这个问题。
4. 超出DOM引用
有时将 DOM 节点存储在数据结构中可能是有用的。 假设要快速更新表中的几行内容。 存储对字典或数组中每个 DOM 行的引用可能是有意义的。 当发生这种情况时,会保留对同一 DOM 元素的两个引用:一个在 DOM 树中,另一个在字典中。 如果将来某个时候您决定删除这些行,则需要使两个引用不可达。
|
|
还有一个额外的考虑,当涉及对 DOM 树内部或叶节点的引用时,必须考虑这一点。假设您在 JavaScript 代码中保留对表格特定单元格(
我们在 SessionStack 尝试遵循这些最佳做法来编写正确处理内存分配的代码,这就是为什么:将 SessionStack 集成到生产网络应用程序中后,它会开始记录所有内容:所有 DOM 更改,用户交互,JavaScript 异常,堆栈跟踪,网络请求失败,调试消息等。
使用 SessionStack,您可以将 Web 应用中的问题重现为视频,并查看用户发生的一切。所有这一切都将发生,对您的网络应用程序没有性能影响。
由于用户可以重新加载页面或导航您的应用程序,因此所有观察者,拦截器,可变分配等都必须正确处理,因此不会导致任何内存泄漏或不增加网络应用程序的内存消耗。我们整合了一个免费的计划,所以你可以试试看。