前几周我们开始了一个关于深度探索 JavaScript 的系列,和 JavaScript 如何工作:我们想通过已经知道的 JavaScript 内容,把它们组织到一起帮你写出更好的代码和应用。
这个系列的第一篇文章关注了运行时和调用栈的引擎论述。第二篇深度调查了 Google's V8 JavaScript 引擎的内部同时提供一些如何编写更好的 JavaScript 代码。
在第三篇文章中,我们将讨论由于日常使用的编程语言日益成熟和复杂性日益增加而被开发人员忽视的另一个重要主题 - 内存管理。我们也提供一些关于在 JavaScript 中如何处理内存泄漏的建议,我们在 SessionStack 中遵循这些建议,因为我们要确保 SessionStack 不会引起内存泄漏,也不会在我们集成的 web 应用中增加内存开销。
概述
像 C 语言,在底层有原始的内存管理比如:malloc()
和 free()
。这些原始的方法在操作系统中,被开发者用于精确分配和释放内存。
同时,当有东西(对象,字符串等等)被创建时, JavaScript 分配内存,同时“自动地”释放内存当不需要他们时,这个过程被叫做垃圾回收。这个看起来自动释放资源的特征是困惑的来源,它给 JavaScript(和其他高级语言)的开发者一个他们可以选择不关心内存管理的错误的印象。这是个巨大的错误。
即使使用高级语言工作,开发者应该有一个内存管理的理解(至少是最基本的)。有时候一些跟内存管理有关的问题(比如在垃圾收集中的 bug 和一些限定等等)是开发者不得不理解然后合适的处理这些问题(或者用最小的代价和开销找到合适的替代办法)。
内存生命周期
无论你使用什么编程语言,内存生命周期总是十分相似的:
下面是一个周期的每一步发生了什么的概述:
- 分配内存 —— 内存被操作系统分配来允许你的程序使用它们。在低级语言中(比如 C),有一个作为开发者应该处理的明确操作。然而在高级语言中,高级语言为你处理了。
- 使用内存 —— 这时候你的程序实际使用之前分配的内存。由于在你的代码中分配的变量,读写操作就发生了。
- 释放内存 —— 这时候释放你不需要的全部内存,以便它们自由分配和下次使用。作为分配内存的操作,在低级语言中是非常明确的。
要快速了解调用栈和内存堆的概念,你可以读读我们第一篇的主题。
什么是内存?
在直接进入 JavaScript 内存概念前,我们简短讨论下通常意义的内存和一句话概括它是如何工作的。
在硬件层面,计算机内存由大量的触发器组成。每个触发器包含一些晶体管可以储存一个比特位。独立的触发器通过唯一的标识符可访问,所以我们可以读和复写他们。因此,从概念上说,我们考虑的整个计算机内存只是一组巨大的我们可以读写的比特位。
所谓人类,我们并不擅长在比特位中思考和计算。我们组织它们变成更大的群组,可以用来代表数字。8 个比特位称作 1 字节。在字节上就是词组(有时候是16,有时候是 32 比特)。
很多东西在内存中被存储:
- 所有的变量和其他所有程序中使用过的数据。
- 程序代码,包括操作系统。
编译器和操作系统一起为你处理内存管理,但我们推荐你看看在这下面发生了什么。
当你编译你的代码时,编译器检查基本数据类型并且计算将来要使用多少内存。这个要求的数量被分配给程序叫做栈空间。因为作为函数调用,这个变量被分配的空间叫做占空间,它们的内存加在已经存在的内存之上。当它们结束后,在LIFO(后进先出)的规则下被移除。比如,考虑下面的声明:
int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes
复制代码
编译器可以立刻得到代码需要:
4 + 4 X 4 + 8 = 28 bytes 的空间。
对于整型和双精度的当前空间而言是这样工作的。大约 20 年前,整型是典型的 2 bytes,双精度是 4 bytes。你的代码不应该依赖于某个时刻基本数据类型的大小。
编译器会插入代码和操作系统交互,来请求必要的字节数量,为你的变量在栈上存储。
在上面的例子中,编译器知道每个变量的精确地内存地址。事实上,无论什么时候我们写操作变量 n
时,这种操作会在内部翻译成某种比如“内存地址 4127963”。
注意如果我们试图访问 x[4]
,我们将会访问数据关联的 m。这是因为我们访问了一个不存在的数组元素——它的 4 比特位比在 x[3]
数组中真正分配的最后一个元素更远,而且可能结束读(或者复写)m 的比特位。这对于剩下的程序会有一系列意想不到的后果。
当一个方法调用另一个方法时,在调用时每个方法都会得到栈的一部分。它保存了所有的本地变量,而且程序计数器也会记住这个执行的位置。当方法结束时,它的内存块会再次为其他用处可用。
动态分配
不幸的是,有些事不是如此容易,当我们不知道在编译时一个变量需要多少内存。假设我们想去做如下的事情:
int n = readInput(); // 读取用户
...
// 创建一个含有 n 元素的数组
复制代码
在这里编译的时候,编译器不知道在这里需要多少内存,因为它取决于用户输入的值。
因为在这里,不能给变量在栈上分配一块空间。相反的,我们的程序需要确切要求操作系统在运行时有正确的空间大小。这样的内存被分配在了堆空间。下面这个表总结了在静态和动态内存分配的不同。
为了完整理解动态内存分配如何工作,我们需要在这上面花更多时间,这可能更这篇文章的主题有所偏差。如果你感兴趣学到更多,只需在下面评论,我们将会在以后的文章中展示更多细节。
JavaScript 中的分配
现在来看看再 JavaScript 中的第一步(内存分配)是怎么工作的。
JavaScript 减轻了开发者手动分配内存的责任——JavaScript同声明值一样都自己做了处理。
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);
复制代码
一些方法的调用结果也是一个对象:
var d = new Date(); // allocates a Date object
var e = document.createElement('div'); // allocates a DOM element
复制代码
方法可以分配新的值或者对象:
var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不可变的,
// JavaScript 不能决定分配的内存,
// 但可以储存 [0,3] 的范围。
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
// 新的数组有四个元素
// 元素 a1 和 a2
复制代码
在 JavaScript 中使用内存
在 JavaScript 基本地使用内存,意味着读写操作。
它可以是读写一个变量的值或者是一个对象的属性,或者甚至是一个函数的参数。
当内存不需要的时候释放
大多数内存管理的问题都来自这个阶段。
这个困难的部分在于去找出何时已分配的内存不再需要。这经常要求开发者找出不再需要的内存在哪里然后释放它。
高级语言嵌入了一个叫垃圾收集的软件,它的工作是跟踪内存分配同时为了找到不再需要的已分配的内存,它会自动释放它们。
不幸的是,这个过程是一个近似过程。因为通常知道一块内存是否需要是不可决定的(不能通过算法解决)问题。
大多数垃圾收集器通过收集不再访问的内存来工作,比如所有的变量指针离开了当前作用域。然而,可被收集的一系列内存空间是尽可能精确,因为任何内存位置的指针仍然有一个变量指向它的作用域,尽管它从来没有被访问过。
垃圾收集
由于找到“不再需要的”内存是一个不可计算的事实,垃圾收集对这个常见问题实现了一个受约束的方案。这部分解释了理解主要垃圾收集的算法和它们的局限的必要性。
内存引用
引用是垃圾收集算法依赖的其中之一的主要概念。
在上下文的内存管理中,对象被引用于另一个对象,如果形式上对于后者(可能隐式或者显式)可访问。举个实例,一个 JavaScript 对象有一个引用指向 prototype
(这里是隐式引用)同时有一个引用指向它的属性值(显示引用)。
在这个上下文中,“object” 的概念比常规的 JavaScript 对象更广泛,也包括了函数作用域(或者全局词法作用域)。
词法作用域定义了变量名在嵌套函数中如何被保存:内部函数包含父级函数的作用域即使父级函数已经返回。
垃圾收集的引用计数
这是最简单的垃圾收集算范。一个对象如果它的引用指针为零就会被当做 “垃圾可回收的”。
看看下面的代码:
var o1 = {
o2: {
x: 1
}
};
// 2 个对象被创建
// 'o2' 作为 'o1' 的属性被引用
// 没有东西可被垃圾回收
var o3 = o1; // 变量 ‘o3’ 是第二个
// 有个引用指向 'o1'.
o1 = 1; // 现在,这个最初的'o1'对象拥有一个单独引用,被 'o3' 变量包含着
var o4 = o3.o2; // 这个对象引用 'o2' 属性
// 它现在有两个引用,一个作为属性,另一个作为 'o4' 变量
o3 = '374'; // 在 'o1' 中的原始对象现在是零个引用
// 它可以被垃圾回收
// 然而它的 'o2' 属性仍然存在被变量 'o4' 引用,所以不能被释放
o4 = null; // 有 'o2' 属性的原始的'o1'对象有零个引用。
// 它现在可以被垃圾回收It can be garbage collected.
复制代码
循环带来的问题
当讨论循环的时候有个限制。下面的例子,两个对象被创建并且相互引用,因此创建了一个循环。在函数调用后,它们将离开作用域,所以它们事实上应该没有用并且要被释放。然而,引用计数算法认为既然两个对象最后一次相互引用了,它们都不会被垃圾回收。
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用了 o2
o2.p = o1; // o2 引用了 o1. 这里创建了循环.
}
f();
复制代码
标记-清除算法
为了决定哪个对象是需要的,这个算法测试一个对象是否可以访问。
标记-清除算法执行以下 3 步:
-
根节点:通常,根节点在代码中是被引用的全局变量。比如在 JavaScript 中,全局变量作为根节点的表现是 “window” 对象。在 Nodde.js 中叫做 “global” 的对象是完全相同的。根节点的完整列表通过垃圾收集器创建。
-
这个算法检查所有的根节点和孩子结点,然后把它们标记为 “active”(意味着,它们不是垃圾)。根节点不能到达的任何东西被标记为垃圾。
-
最后,垃圾收集释放所有没有被标记为“active”的内存块,并且把内存返回给操作系统。
这个算法要比之前的由于一个“零引用的对象”不能访问的算法更好。这当我们在循环中看到的是不同的。
在2012年的时候,所有的现代浏览器搭载了标记-清除垃圾收集器。所有的在 JavaScript 领域的垃圾收集的改进(世代,增量,并发,平行垃圾收集)超过了去年这个算法(标记-清除)的改进,但没有改进超过垃圾收集算法本身,不论改进的目标是不是一个对象可访问。
在这篇文章中,你可以了解到更多的关于垃圾收集追踪的细节,这些细节包含了标记-清除的优化。
循环不再是问题
在之前的第一个例子中,函数返回之后,两个对象不再从全局对象通过可访问的东西相互引用。 因此,它们通过垃圾回收会发现不在能访问。
即使这两个对象相互引用,它们从根节点不可访问。
垃圾收集的直觉计数行为
尽管垃圾收集是方便的,它们有一些自己的平衡。其中之一叫做无决定。换句话说,GCs 是不可预计的。你不能真正分辨什么时候一个收集将会执行。这就意味着一些程序使用了比它们实际需要的更多的内存。在其他的例子中,短暂停在特殊敏感的应用中会被注意到。尽管无决定意味着不能确定收集什么时候执行,大部分 GC 实施在分配时共享了收集传递的常见模式。如果没有分配被执行,大部分 GCs 保持闲置。考虑下面的场景:
- 一组可测量的分配被执行。
- 这些元素的大部分(或者所有)被标记为不可到达。
- 没有更多的分配可以执行。
在这个场景中,大部分 GCs 将不会运行任何更多的收集传递。换句话说,即使这里有不可到达的引用可供收集使用,它们也不会被收集器声明。这些不是严格的泄漏,但是,结果是高于平常的内存使用。
什么是内存泄漏?
就像是内存一样,内存泄漏是应用中过去不再使用但是没有返回给操作系统或是给自由内存池的内存块。
程序语言喜欢通过不同的方式管理内存。然而,某个内存是否被使用实际上是一个不可确定的问题。换句话说,只有开发者可以搞清楚是否一块内存应该返回给操作系统。
某些程序语言提供一些特性帮助开发者做这件事。另一些语言期望开发者可以完全确定什么时候内存块不再需要。维基百科上有关于手动和自动内存管理的好文章。
常见的 JavaScript 泄漏的四种类型
1. 全局变量
JavaScript 用一种有趣的方式处理未声明的变量:当一个未声明的变量被引用时,全局对象上有一个新的变量,全局对象一般是 window
,也就意味着:
function foo(arg) {
bar = "some text";
}
复制代码
等价于:
function foo(arg) {
window.bar = "some text";
}
复制代码
我们说 bar
的目的只是在 foo 方法中引用一个变量。一个多余的全局变量将被创建,然而,如果你没有使用 var
去声明它。在上面的例子中,它也不会引起很多麻烦。尽管你可以想象更多危害的场景。
你可以使用 this
偶然创建一个全局变量:
function foo() {
this.var1 = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();
复制代码
你可以通过添加
use strict
来避免所有的 this;这个添加在 JavaScript 文件的开始,切换了更为严格的解析 JavaScript,阻止了意外的全局变量创建。
意外的全局某种程度是个问题,然而,更多的是通过定义的确切的全局变量不能通过垃圾收集回收。特别需要注意的是给全局变量临时存储大量的信息。如果当你做的时候必须使用全局变量保存数据,确保一旦你不需要它的时候给它赋值为 null 或者 重新赋值。
2. 被忘却的计时器和回调
我们拿 setInterval
做个例子,它经常在 JavaScript 中被用到。提供观察和其他功能的接受回调的库通常确保所有的回调引用一旦它们的实例不可访问也变得不可访问。像这样,下面的代码并不少见:
var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); //This will be executed every ~5 seconds.
复制代码
上面的代码段展示了使用计时器的后果,引用了一个不再需要的数据或节点。 这个 render
对象可能在某个时候被替换或者移除,这可能使通过计时处理程序封装的块冗余。如果这个发生了,无论是这个处理还是它的依赖可能在计时器需要第一次停止时被收集(记着,它仍然有效)。它呈现了一个事实是 serverData
确定储存和执行了数据加载将也不会被收集。
当使用观察者时,你需要确定创建一个精确的调用去移除一旦你处理过后的东西(不再需要的观察者,和将不再能访问的对象)。
幸运的是,大多数现代浏览器将会为你实现:即使你忘记移除监听,一旦发现一个对象不可访问,它们自动收集观察者的处理。过去一些浏览器不会处理这些东西(优秀的老 IE6)。
尽管如此,一旦对象废弃在当前行中移除观察是最佳实践。看下面的例子:
var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
counter++;
element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// 做点别的事
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 当元素离开作用域时,
// 元素和 onClick 都会被收集即使在老的浏览器中也是这样
// 也没有处理循环
复制代码
当现代浏览器支持合适的检测循环和事件的垃圾收集时,你就不必在一个节点不可访问时去调用 removeEventListener
。
如果你使用过 jQuery
的 API(其他支持 this 的库和框架也可以),你可以在一个节点废弃时用监听移除它们。甚至当应用在旧的浏览器上运行时,这些库也会确保没有内存泄漏。
3. 闭包
JavaScript 开发的另一个方面是闭包:一个内部函数可以访问外部函数的变量。由于 JavaScript 运行时实施了细节,它可能在下面这种方法有内存泄漏:
var theThing = null;
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
从来没有用过,通过 replaceThing
的作用域的外部,someMethod
可以被使用(比如:一些全局的地方)。同时作为 someMethod
和unused
共享了闭包作用域, unused
的引用不得不对 originalThing
强制它保持活跃(在两个闭包之间全部共享的作用域)。这阻止了它的回收。
在上面的例子中,someMethod
闭包创建的作用域共享了 unused
,而 unused
引用了 originalThing
。通过 replaceThing
作用域的外部的 theThing
,someMethod
可以被使用,尽管事实是 unused
从来没有被用过。因为 someMethod
共享了 unused
的作用域,这个事实是 unused
的引用 originalThing
要去它保持活动。
这一切可以当做内存泄漏考虑。你可以期望看到一个内存使用的程度,尤其当上面代码一遍又一遍执行时。当垃圾回收运行时,它的大小不会减少。闭包的链接列表被创建(这个例子中它的根节点是theThing
变量),同时每个闭包作用域加载了一个巨大数组的间接引用。
这个问题是被 Meteor 团队发现的,他们用更多细节描述了问题在这篇文章中。
4. DOM引用的外部
下面的例子是开发者在数据结构中存储 DOM 结构。假设你需要在一张表的其中几行内容中快速更新。如果你在一个字典或者数组中储存了每一行的 DOM 引用,这里有对同一节点的 DOM 的两个引用:一个是 DOM 树,另一个在字典中。如果你需要摆脱这些行,你需要记着使这两个都不能访问。
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'));
// 在这里我们仍能看到在全局对象
// 中的一个对 #button 的引用
// 换句话说,这个 button 元素仍然在内存中,不能被回收
}
复制代码
当谈到内部 DOM 树的叶子节点或者内部引用时,需要考虑额外的条件。如果在你的代码在保持对一个表格的单元格(一个 <td>
标签)的引用并且决定从 DOM 中移除仍然保留的某个单元的的引用,你可以遇见这里会有内存泄漏。你可能认为垃圾回收可以释放一切除了单元格。然而,这不在这里个例子中。因为单元格是表格的孩子,同时孩子节点们对父节点保留引用,对表格单元格的引用将会在内存中保留整个表格。
我们在 SessionStack 中试着寻找编写代码的最佳实践,以便合适的控制内存分配,下面是原因:
一旦你在 web 应用产品中集成了 SessionStack,它开始记录一切:所有的 DOM变化,用户交互, JavaScript 报错,栈追踪,失败的网络请求,调试信息等等。在 SessionStack中,你可以像视频一样重复播放它们然后给你的用户看到一切发生的事情。然后所有的这些对你的 web 应用没有表现上的影响。
因为用户可以重新加载页面或者跳转到你的 APP中,所有的观察者,检查者,变量分配等等不得不适当处理,所以他们不会引起任何内存泄漏,或者在我们集成的 web 应用中增加内存开销。
这里有个免费的计划你可以现在试试。
资源
- 受 www-bcf.usc.edu/~dkempe/CS1… 启发
- 受 blog.meteor.com/an-interest… by David Glasse 启发
- 受 www.nodesimplified.com/2017/08/jav… 启发
- 受 auth0.com/blog/four-t… by Sebastián Peyrott 启发
- 内容复用 from developer.mozilla.org/en-US/docs/… by MDN Web Docs