Javascript 如何工作:内存管理 + 如何处理4种常见的内存泄漏

前言
翻译自How JavaScript works: memory management + how to handle 4 common memory leaks

几个星期前,我们开展了一系列针对深入研究 Javascript 和它是如何工作的:我们认为通过了解 Javascript 的构建模块和它们是如何在一起运行的话,你就可以编写更好的代码和程序。

这个系列的第一篇文章是关注于提供 引擎、运行时机制(runtime)、调用堆栈的概述,第二篇文章仔细研究了 Google 的 V8 引擎的内部实现,V8 引擎内部实现和如何编写更好的代码的 5 条 tips

在这第三篇文章里,我们会讨论更重要的问题以至于越来越多的程序员忽视这个问题——内存管理,因为我们日常使用的编程语言正变得越来越成熟和复杂。我们同样会提供几种方法来解决 Javascript 中的内存泄漏,这是我们在开发 SessionStack 中所遵循的,这能令 SessionStack 不会发生内存泄漏或者在我们所继承的网页应用中导致内存消耗增加。

概括

比如 C 语言这样的编程语言,会提供底层的内存管理函数,比如 malloc()free()。这些函数能够让开发者明确的从系统中分配和释放内存。

对比之下,Javascript 在目标(对象,字符等)被创建时分配,并且“自动”的在它们不再被使用的时候释放内存,这个过程称为垃圾回收。这看似“自动”的释放资源的性质带来了混乱,给 Javascript(或者其他高级语言)的开发者带来错误的印象——他们可以不考虑内存管理,这错的离谱。

即使使用高级语言进行开发,开发者都应该拥有内存管理的认识(至少是最基本的理解)。有时他们提出一些内存管理方面的问题(例如是 Bug 或者是垃圾回收器的实现限制之类的)当他们需要正确的理解和处理内存管理问题(或者是为了找到一种最低成本或者最少代码债务的正确方式来处理它)。

内存的生命周期

无论你使用什么开发语言,内存的生命周期基本上都是一样的:

1
分配内存 -> 使用内存 -> 释放内存

下面是关于每个阶段发生了什么的概述:

  • 分配内存——内存是由操作系统分配给你的程序来使用的。在低级语言(如C)中,这是一项开发者需要处理的明确操作。然而在高级语言为了照顾开发者,它会自动处理这件事。
  • 使用内存——就是你的正在使用刚刚系统为你分配的内存的时候。当你在使用代码中定义的变量的时候,实际执行的是变量对应的内存的读写操作。
  • 释放内存——这时候你应该释放那些你不需要的内存来解除它们的占用以至于这些内存能够被复用。跟分配内存相似的是,释放内存在低级语言中也是一项明确的操作。

想快速获得调用堆栈和内存堆的概念概括的话,可以阅读我们的 第一篇文章

什么是内存?

在直接跳跃到讲 Javascript 的内存之前,我们会简要的讨论一下内存一般来说指的是什么,和它是如何运作的。

在硬件层级,计算机内存是由大量的触发器(flip flops)组成的,每个触发器包含若干的晶体管并且能够存储一比特(1 bit)的数据。每个触发器都可以被一个唯一的识别符作为寻址依据,所以我们才能读写它们。所以从概念上我们可以把计算机的所有内存抽象为一个超长的由比特(bits)组成的数组,我们可以在这数组上进行读写操作。

作为人类,我们没有概念也不擅长在比特维度上进行计算,我们将它聚合成更高一级的维度——能够用来保存数字的字节(8 比特组成 1 字节)在字节之上是单词(有些单词需要16字节,有些需要32位字节)。

这些内存上面保存了很多东西:

1.所有程序所使用到的变量或者是其他数据。
2.程序的代码,包括操作系统的。

编译器和操作系统负责了大部分的内存管理工作,但我们建议你还是需要了解一下在底层发生了些什么。

当你编译你的代码的时候,编译器会检查你所声明的数据类型并事前计算你需要使用多少内存。程序所需被分配的内存数量被称为堆栈空间。在堆栈空间中需要添加被函数被调用到的变量的时候,它们会被添加到已经存在的内存的最上面,当它们被释放的时候,遵循 LIFO 原则(Last In First Out 后进先出),例如下面的运算:

1
2
3
int n;  // 4字节
int x[4]; // 长度为4的数组,每个元素4字节
double m; // 8字节

编译器可以马上计算出代码需要:
4 + 4 × 4 + 8 = 28 bytes 的内存

这是按照现在的 integers 和 doubles 的尺寸规定所计算出的结果,在大概 20 年前,integers 一般是 2 字节的,而 double 是 4 字节的。你的代码是不需要依赖于现在的基本数据类型的尺寸规定的。

编译器会插入与操作系统进行相互作用的代码,并请求必要数量的堆栈来保存你的变量。

在上面的例子,编译器知道每一个变量在内存中的地址。事实上,当我们写入变量 n 的时候,在内部会被翻译为比如内存地址 4127963之类的内存地址。

注意当我们尝试去访问 x[4] 的时候,我们会访问到一部分分配到 m 中的数据内容。这是因为我们访问了一个不存在数组中的数据————在数组的最后一个元素 x[3] 后面 4 bytes 的 x[4] 地址,这会访问到(或者写覆盖)到部分 m 的 bits,这会给后面的程序运行带来一些不可预知的结果。

内存分布示意图

当函数调用另一个函数的时候,每个函数都会在堆栈中创建属于他们的块。用来保存它们的局部变量,同时有一个程序计数器,记录它在执行时的位置。

动态分配

不幸的是,当我们不知道在编译时一个变量需要分配多少内存的时候,事情看上去并非这么简单。比如我们想要进下下面这样的操作的时候:

1
2
3
4
5
int n = readInput(); // 读取用户输入

...

// 创建包括 n 的元素的数组

在这种情况下,编译器并不知道要为 n 分配多少内存因为这取决于运行时用户输入的值。

因此无法从堆栈为这个变量分配空间,所以我们的程序需要在运行的时候向操作系统请求正确的内存空间。这些空间是从系统的堆空间中分配出来的。静态分配和动态分配这两种内存的区别总结基本总结在下面的表格中:

静态分配 动态分配
- 编译时要分配的尺寸必须是已知的
- 编译的时候执行
- 从栈(stack)中分配
- FIFO(First in,Last out)
- 编译时要分配的尺寸必须是未知的
- 运行的时候执行
- 从堆(heap)中分配
- 没有特殊的分配方式

想要全面了解如何动态分配内存,我们需要花多点时间在指针上面,这会稍微偏离我们这篇文章的主题。如果你想要学习指针相关的知识的话,可以在评论里面反映,我们会在将来的文章中增加关于指针的知识。

Javascript 中的内存分配

现在我们会解释 Javascript 如何执行第一步(分配内存)。

Javascript 减轻了开发者内存分配的责任——Javascript 自己完成变量分配,伴随着变量声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var n = 374; // 为数字分配内存
var S = 'sessionstack'; // 为字符分配内存

var o = {
a: 1,
b: null,
}; // 为对象及其包含的值分配内存

var a = [1, null, 'str']; // (类似对象)为数组及其包含的值分配内存

function f(a) {
return a + 3;
} // 分配函数空间(能被调用的对象)

// 函数表达式同样被视为对象进行内存分配
someElement.addEventListener('click', function() {
someElement.style.backgroundColor = 'blue';
}, false);

一些调用回调为对象的变量同样也会进行分配:

1
2
3
var d = new Data(); // 分配 Date 对象

var e = document.createElement('div'); // 分配 DOM 元素

函数可以分配为新值或者新对象:

1
2
3
4
5
6
7
8
9
10
11
var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 是一个新字符串
// 因为字符串是不会变化的
// Javascript 可能不会分配新的内存
// 只保存 [0, 3] 的范围

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = al.concat(a2);
// 由四个元素组成的新数组
// 由 a1 和 a2 的元素所组成

Javascript 中的内存使用

Javascript 中通过读取和写入来使用已分配的内存。

它发生在读取或者是写入变量的时候,或者是某些对象的属性乃至你为函数传入的变量。

释放那些不再被需要的内存

大部分关于内存管理的问题是在这个阶段出现的。

最艰难的任务是如何分辨某个已分配内存不会再被使用。这通常需要开发者在代码中明确提出某部分内存不会再使用而可以被释放,

高等语言会内嵌一种名为垃圾回收器的程序,他的任务是追踪内存分配而且判断这些已分配的内存在什么时候不会再被使用,从而释放这段内存。

不幸的是,这个过程只能得出一个近似值,因为判断某部分内存在什么时候被需要这个问题是不可解的(无法通过一个算法来解决)

大部分垃圾回收器通过手机那些不会再被访问到的内存来达到目的。例如,所有指向此处的变量都超出了使用范围。然而,这只是可被回收的内存空间的一个近似值。因为在任何时候,一个内存地址都可能还有变量仍然指向此处,但这些变量可能不会再被范围而已。

垃圾回收

由于寻找某个变量空间“不再被需要”的问题是不可判定的。垃圾回收是对一般情况实现的一种解决方案。这一章节会解释一些理解垃圾回收的算法和局限性所必需的的知识。

内存引用

垃圾回收的算法所依赖的主要概念之一是内存引用

在内存管理中,如果一个对象访问另个一对象,则称前者引用另一个对象(可以是显式的或者是隐式的)。举个例子,一个 Javascript 对象会引用它的 原型属性(隐式引用)和它的属性的值(显式引用)。

在我们讨论的这些情况下,“对象”的含义从传统意义上的“Javascript 对象”扩展到更广泛的概念上,包括函数领域乃至词法范围

词法范围定义了如何在嵌套函数中解析变量名称:即使父函数已返回,内部函数也包含父函数的作用域。

引用计数和垃圾回收

这是最简单的垃圾回收算法。一个对象是根据它的“可回收性”来决定是否要被回收——如果没有任何单位引用它。

分析一下下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var o1 = {
o2: {
x: 1
}
};

// 两个对象被创建
// o2 被 o1 在它的第一个属性被引用
// 没有任何垃圾可以被回收

var o3 = o1; // o3 是第二个对 o1 所指向的对象进行引用的变量

o1 = 1; // 现在,原来被 o1 所引用的对象只剩下了一个引用,也就是上面的 o3

var o4 = o3.o2; // 引用对象的 o2 属性,这个对象现在有 2 个引用,一个作为属性,一个被变量所引用

o3 = '374'; // 原来被 o3 所引用的那个对象现在被引用数为 0,它现在可以被清理掉了,即使如此,它的属性 o2 仍然被 o4 所引用,所以这个对象现在还不能被清理。

o4 = null; // 带有 o2 这个对象为属性的对象,现在被引用数为 0,这可以被清理了。

循环带来的问题

在面临循环的时候,垃圾回收算法可能会面临局限。在下面的例子中,两个对象先后创建,并且相互引用,形成了一种循环。它们会在函数调用完毕之后其实已经离开作用域,是无用且可被释放的变量。然而,垃圾回收算法中的引用计数器这时候判断它们至少还仍有一个引用,两者都不能被释放。

1
2
3
4
5
6
7
8
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1
}

f();

引用关系图

标记和扫描算法

为了判断对象是否被需要,这个算法判断对象是否可达。

标记和扫描算法一般经历以下三个步骤:

1.根:通常来说,根是那些定义为全局变量并被引用的变量。举个例子在 Javascript 中,能被视为根变量的是 window 对象。类似的在 NodeJS 中的根变量为 global。垃圾回收器会建立一个列表来记录所有根变量。
2.算法随后会扫描所有根变量以及它们的属性,将它们标记为激活状态(意味着它们不是垃圾)。任何在根级别但不被访问到的变量将被标记为垃圾。
3.最后,垃圾回收器将所有标记为垃圾(没有被标记为激活状态的变量)的变量空间释放返回给系统。

扫描过程示意图

这个算法相比于前一个更好,因为判断原因由“对象为零引用”推广到这个对象不能被访问。明显的一个反例是循环引用的情况。

截止到 2012 年,所有的现在浏览器都装载了具备标记和扫描算法的垃圾回收器。在过去几年里在 Javascript 领域里面关于垃圾回收(代码、增量、并发、并行垃圾收集)的改进都是对该算法的改进(标记扫描算法),但没有改进垃圾收集算法本身,也没有该变判断一个对象是否可达这个目标。

在这篇文章中,你可以跟进有关垃圾回收的更详细信息,也包括标记和扫描算法以及其优化。

循环不再是问题

在上面最近一个例子中,在函数调用完之后,两个互相引用的对象不被根级别的任何对象所引用。所以,它们会被垃圾回收器视为不可达的对象。

扫描关系示意图

所以即使这两个对象还在相互引用,它们相对于根是不可达的。

垃圾收集器的直观行为

虽然垃圾收集器很方便,但它们也是有由于设计目的所限制的局限之处。其中之一是“非确定性”,换言之,垃圾收集器是不可预料的。你无法告诉它什么时候执行垃圾回收。这意味着有时候程序会占用比它实际所需要的更多的内存。在这种情况下,尤其是那些对流畅性要求很高的程序,短暂停会尤其明显。尽管非决定论意味着人们无法确定何时进行收集,大多数垃圾收集实现共享在分配过程中执行收集过程的通用模式。如果没有分配动作需要执行,大部分垃圾收集器会保持空闲状态,同时考虑以下情况:

1.一大组分配被执行。

2.大部分这些元素(或者是全部)被标记为不可达(假设我们将这些变量链接到内存地址的引用标记为 null)

3.没有进一步的分配被执行。

在这种情况下,大部分垃圾收集器不会再进行深一步的内存收集。换言之,尽管可能还存在可收集的不可达的引用,不过收集器并不会发现这些引用。这些并非内存泄漏但仍然导致了高于预期的内存占用的表现。

什么是内存泄漏

内存泄漏指的是某个应用曾经使用过但在之后不会被使用到的一段内存空间,这段内存空间没有被返回给系统或者是可用内存池。

编程语言支持以不同的方式来进行内存管理。然后,某段内存是否被占用是一个不可判定的问题。换言之,只有开发者能够明确说明某个变量所占用的内存空间是否能够释放。

某些编程语言提供了可帮助开发人员执行此操作的功能。其他开发语言则希望开发人员能够完全清楚一段内存何时未被使用。维基百科有关手动自动内存管理的好文章。

Javascript 常见的四种内存泄漏

1.全局变量

Javascript 以一种有趣的方式来对待未声明的变量:当一个未声明的变量被引用时,这个变量会在全局对象中被创建。在浏览器中,全局对象是 window,这意味着:

1
2
3
function foo() {
bar = 'some text';
}

相当于:

1
2
3
function foo() {
window.bar = 'some text';
}

bar 变量的预期作用是一个在 foo 函数里面使用的函数,不过却导致了一个冗余的全局变量被定义了,这只是由于你忘记了使用 var 来进行变量的声明。上面这种情况带来的后果可能不那么糟糕,但你应该能想象到比这糟糕得多的情况。

你也可以通过使用 this 来意外的创建一个全局变量:

1
2
3
4
5
6
function foo() {
this.var1 = '潜在的意外全局性变量';
}

// foo 被调用,此时的 this 指向的是全局变量 window 而不是 undefined
foo();

你可以通过在 Javascript 的文件开头添加 use strict; 来避免这种情况发生,这能够将 Javascript 转换到更严格的编译模式,防止意外的全局变量的创建。

变量意外全局化当然是个问题,然而,更多的时候,你的代码会被显式定义的全局变量污染,这些变量根据定义不能被垃圾收集器所收集。特别要注意那些用来存储临时变量和处理大量信息的全局变量。如果你必须这么做的话你大可以使用全局变量来存储这些数据,不过记得要在使用完这些数据之后将它们分配为空或者是重新分配它们

2.被遗忘的定时器或者是回调

我们以经常在 Javascript 中使用到的 setInterval 为例。

提供观察者和接受回调的其他工具的库通常会确保所有对回调的引用在其实例无法访问时变得无法访问。看看下面这个不怎么罕见的例子:

1
2
3
4
5
6
7
var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if (renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); // 每 5 秒会执行一次

上面的代码片段显示了使用引用不再需要的节点或数据的定时器的后果。

renderer 对象可能会在某些点被替换或删除,这会使得间隔处理程序封装的块变得冗余。如果发生这种情况,处理程序及其依赖物都不会被当做垃圾被收集,因为间隔需要先停止(请记住,它仍然处于活动状态)。事实是 serverData 确实被存储了,加载数据的进程也不会被回收。

在使用观察者模式时,您需要确保你在调用完它们的时候提供一个明确的调用来将其删除(令观察者不再需要,或者该对象将变得无法到达)。

辛运的是,大部分现代浏览器会帮你完成这个工作:即使您忘记删除侦听器,一旦观察到的对象变得无法访问,它们会自动收集观察者处理程序。低版本的一些浏览器无法处理这些情况。(辣鸡IE6)

尽管如此,最好的做法是一旦对象变得过时,就移除它的监听器。看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
counter += 1;
element.innerHTML = 'text' + counter;
}

element.addEventListener('click', onClick);

// 其他操作

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

// 现在,当 element 离开作用域时,即使是在低版本浏览器中,element 和 onClick 都会当做垃圾收集,

在那些可以检测周期并适当地处理监听器的现代浏览器中,你不再需要在节点不可达之前调用 removeEventListener

如果你使用的是 jQuery 的 API 的话(其他库或者框架也支持)你也可以在节点过时之前删除侦听器。即使应用程序在较旧的浏览器版本下运行,该库也会确保没有内存泄漏。

3.闭包

Javascript 开发的其中一个关键点是闭包:一个可以访问外部(封闭)函数变量的内部函数。由于JavaScript运行时的实现细节,有可能以下列方式泄漏内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) // originalThing 的引用
console.log('hi');
};

theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log("message");
}
};
};

setInterval(replaceThing, 1000);

replaceThing 被调用,theThing 会获得一个由长数组和和一个新闭包(someMethod)组成的对象。然而,originalThing 被变量 unused 所引用的闭包函数所引用了(之前调用的 replaceThing 函数里面的 theThing 变量)。需要记住的是:一旦在同一个父范围内为闭包创建了闭包范围,范围将被共享。

在这种情况下,为 someMethod 创建的闭包范围会和 unused 的闭包范围共享。unused 拥有对 originalThing 的引用。即使 unused 从未被调用过,someMethod 会通过 theThingreplaceThing 以外的空间被调用(全局空间的某处)。而且当 someMethodunused 共享闭包范围的时候,originalThing 引用着 unused 使它保持激活状态(两个关闭包之间的整个共享闭包范围)。这阻止了 unused 被收集。

在上面的例子中,为 someMethod 创建的闭包空间和 unused 共享了,而 unused 引用了 originalThingsomeMethod 可以通过 theThingreplaceThing 作用域之外被调用,即使事实上 unused 从未被调用过。事实上由于作用域共享,在 someMethod 被调用时,unused 也进行了 originalThing 的引用。

所有这些都可能导致相当大的内存泄漏。当上面的代码片段一遍又一遍地运行时,您可能会发现内存使用量激增。当垃圾收集器运行时,其大小不会缩小。一个闭包的关联链表被创建(在这个例子中根变量是 theThing 变量),并且每个闭包范围都会间接引用大数组。

Meteor团队发现了这个问题,他们有一篇很好的文章,详细描述了这个问题。

4.DOM 以外引用

有些情况下开发人员在数据结构中存储 DOM 节点。假设你想快速更新表格中几行的内容。如果您在字典或数组中存储对每个DOM行的引用,将有两个对同一个 DOM 元素的引用:一个在 DOM 树中,另一个在字典中。如果你决定移除这些行,你需要记住使两个引用无法访问。

1
2
3
4
5
6
7
8
9
10
11
12
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
// image 是 body 下直属的子元素
document.body.removeChild(document.getElementById('image'));
// 在这时候,在全局变量 elements 中依然保存着对 #button 的引用,换言之,这个 button 元素依然存在于内存中不能被垃圾回收掉
}

在涉及DOM树内的内部节点或叶节点时,还有一个额外考虑因素需要考虑。如果您在代码中保留对表格单元格(<td>标记)的引用,然后你决定从 DOM 中删除该表格并保留对该特定单元格的引用,你可能会发生一个严重的内存泄漏。你可能会认为垃圾回收器会释放除该单元格外的所有内容。然而,情况并非如此。由于单元格是表格的子节点,并且子节点保持对其父母的引用,对表格单元格的这种单一引用会将整个表格保留在内存中。

我们在 SessionStack 尝试遵循这些最佳实践来编写正确处理内存分配的代码,原因如下:

当您将 SessionStack 集成到您的生产Web应用程序,它开始记录一切:所有 DOM 更改,用户交互,JavaScript 异常,堆栈跟踪,网络请求失败,调试消息等。通过 SessionStack,您可以将发生的问题作为视频重播,并查看发生在用户身上的一切。所有这些都必须在您的网络应用程序没有额外性能影响的情况下进行。

由于用户可以重新加载页面或导航您的应用,所有观察员,拦截器,变量分配等。都必须正确处理,所以 SessionStack 不应该导致任何内存泄漏,或者不会增加我们集成的 Web 应用程序的内存消耗。

有一个免费的计划,所以你可以尝试一下

资源