本篇译文,删减了原文中一些无关紧要的内容,可以让大家花更少的阅读时间。 原文地址:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec
这篇文章将讨论日常编程中另一个复杂且容易被忽视的问题 — 内存管理。其中还提供了一些关于如何处理 JavaScript 内存泄露的提示,来防止导致内存泄漏以及不会增加我们 WEB 程序的内存消耗。
像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()
和 free()
。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。
即使使用高级开发语言,开发人员也应该了解内存管理(或至少了解其基础知识)。有时自动内存管理会存在一些问题(如垃圾回收的 bug 和实现限制等)。开发者必须了解相关内存知识才能解决(或找到合适的解决方法,并尽量减少折衷和代码债务)来处理这些问题。
无论使用何种编程语言,内存生命周期几乎都是一样的:
以下概述了周期的每个步骤:
讨论 JavaScript 内存之前,我们先简单地讨论一下内存是什么以及它是如何工作的?
在硬件级别上,计算机内存包含大量触发器(flip flops)。每个触发器包含几个晶体管并且能够存储一位(bit)。单个触发器可通过唯一标识符进行寻址,因此我们可以读取和覆盖它们。从概念上讲,我们可以将整个计算机内存视为我们可以读写的一个巨大的位(bit)数组。
因为人类,不善于做一些 bits 的运算和思考,所以将其组织成更大的组来表示数值。8 bits 为 1 byte,超出 byte的,有单词(16bits 或 32bits)
大量的东西存储在内存中,包括:
虽然编译器和操作系统已经为内存管理做了大量工作,但是还是建议了解一下引擎之下发生了什么。
编译代码时,编译器可以检查原始数据类型,并提前计算它们所需内存。然后将所需的数量分配给调用堆栈中的程序。这些变量分配的空间称为堆栈空间(stack space),因为函数被调用,它们的内存被添加到现有内存(存储器)的顶部。它们终止时,它们将以LIFO(后进先出)顺序被移除。
int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes
编译器可以立即计算diam所有内存空间为: 4 + 4 × 4 + 8 = 28 b y t e s 4 + 4 × 4 + 8 = 28 bytes 4+4×4+8=28bytes
这是现在整数和双精度的工作原理。大约 20 年前,整数通常为 2 字节,双精度为 4 字节。你的代码永远不必依赖于此时基本数据类型的大小。
编译器将插入与操作系统交互的代码,以便在堆栈中请求要存储的变量所需的字节数。
上述示例中,编译器知道每个变量的确切内存地址。实际上,每当我们写入变量时,它就会会内部转为类似”内存地址 4127963“的内容。
注意,如果尝试访问x[4]
,可能会访问到和 m 相关联的数据。这是因为我们访问的元素在数组中并不存在 — 它比数组中最后一个实际分配的元素x[3]
多4个字节,可能会读取(或重写) m 的位。这肯定会对程序产生难以理解的不良影响。
当函数调用其他函数时,每个函数在调用时都会获得自己的堆栈块。它保存所有的局部变量,还有一个程序计数器,可以记录函数的执行位置。当函数执行完成时,其内存块可以再次用于其他目的。
不幸的是,当在编译时不知道变量需要多少内存时,事情就不那么容易了。假设我们想要做类似以下的事情:
int n = readInput(); // reads input from the user
...
// create an array with "n" elements
这种情况下,在编译时,编译器不知道数组需要多少内存空间,因为其由用户输入的值来确定。
因此,它无法为堆栈上的变量分配空间。相反,我们的程序需要再运行时明确询问操作系统是否有适当的空间。此内存是从堆空间(heap space) 分配的。下述为静态分配和动态分配的差异:
Static allocation | Dynamic allocation |
---|---|
编译时内存大小确定 | 编译时内存大小不确定 |
编译阶段执行 | 运行时执行 |
分配给栈(stack space) | 分配给堆(heap stack) |
FILO | 没有特定的顺序 |
现在我们将解释第一步(分配内存 — Allocate memory)如何在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(); // 分配一个 Date 对象
var e = document.createElement('div'); // 分配一个 DOM 元素
方法可以分配新的值或对象:
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中使用内存的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。
高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。不幸的是,这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。
大多数垃圾回收器通过判断内存是否能够被再次访问来工作的,例如:指向它的所有变量都超出了作用域。然而,这只能得到一个近似值。因为在任何一点上,内存位置可能仍然有一个在作用域内指向它的变量,但是它可能将永远不会被再次访问了。
自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。
垃圾回收算法依靠的主要概念就是引用(reference)。 在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。 在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。
词法作用域定义了如何在嵌套函数中解析变量名称:即使父函数已 return,内部函数也可以包含父函数的作用域。
这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。
下述例子,使用了MDN中的例子,而并没有用原文中,因为发现了作者这部分内容完全来自 MDN o(╥﹏╥)o — https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management
var o = {
a: {
b:2
}
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
var o2 = o; // o2变量是第二个对“这个对象”的引用
o = 1; // 现在,“这个对象”的原始引用o被o2替换了
var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa
o2 = "yo"; // 最初的对象现在已经是零引用了
// 他可以被垃圾回收了
// 然而它的属性a的对象还在被oa引用,所以还不能回收
oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了
该算法有个限制:无法处理循环引用的事例。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1
}
f();
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。 标记和扫描算法通过以下3个步骤:
该算法优于“引用计数”算法,因为“一个对象具有零个引用”导致对象不能访问。这与上面循环应用相反。
截止到2012年,所有现代浏览器都提供了标记 - 清除垃圾收集器。在过去几年中,在 JavaScript 垃圾收集(生成/增量/并发/并行垃圾收集)领域所做的所有改进都是该算法的实现改进(mark-and-sweep),但不是对垃圾收集算法本身的改进,也不是决定对象是否可访问的目标。
在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。
尽管对象之间存在引用,但它们无法从根目录(window)访问。
虽然垃圾收集器很方便,但它们还是有自己的权衡取舍。其中之一是非决定论(non-determinism)。换句话说,GC 是不可预测的,无法确定何时会执行收集。这意味着在某些情况下,程序使用的内存要比实际需要的还多。另一个场景是,在特别敏感的应用中,那么一些短暂的暂停会显得特别明显。虽然不确定性意味着回收执行的时间不能被确定,但是大多数 GC 的实现是共享模式 — 在分配内存期间执行回收遍历。如果没有分配执行,大多数 GCs 保持空闲状态。请考虑以下情形:
在这种情况下,大多数GC不会再运行任何收集动作(passes)。换句话说,即使存在对于收集来说,无法访问的引用,收集器也不会声明这些引用(these are not claimed by the collector)。这些并非严格内存泄漏,但仍导致高于平常的内存使用率。
内存泄漏是应用程序过去使用但后续不再需要,尚未返回操作系统或可用内存池的内存块。 编程语言支持不同的内存管理方式。但是,是否使用某段内存实际上是一个不可判定(undecidable problem)的问题。换句话说,只有开发人员才能明确是否可以将内存返回给操作系统。
某些编程语言提供了帮助开发人员执行此操作的功能,希望开发人员完全明确何时不再使用内存。Wikipedia has good articles on manual and automaticmemory management.
JavaScript 以一种有趣的方式处理未声明的变量:当引用未声明的变量时,会在全局对象中创建一个新变量。在浏览器中,全局对象将是 window,这意味着
function foo(arg) {
bar = "some text";
}
等价于
function foo(arg) {
window.bar = "some text";
}
假设 bar
的目的是仅引用 foo
函数中的变量。但是,如果不使用 var
来声明它,则会创建一个冗余的全局变量。在上述情况下,这不会造成太大伤害。你肯定可以想象一个更具破坏性的场景。
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'
来避免这一切;在 JavaScript 文件的开头,它将打开更严格的解析 JavaScript 模式,以防止意外创建全局变量。
意外的全局变量肯定是个问题,但是,通常情况下,您的代码会被显式的全局变量所侵扰,而这些变量根据定义无法被垃圾收集器收集。需要特别注意用于临时存储和处理大量信息的全局变量。如果必须使用全局变量来存储数据,请确保使用完成后将其指定为null或重新分配它。
我们以 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.
这个例子阐述着 timers 可能发生的情况:计时器会引用不再需要的节点或数据。
renderer
可能在将来会被移除,使得 interval
内的整个块都不再被需要。但是,interval handler
因为 interval
的存活,所以无法被回收(需要停止 interval
,才能回收)。如果 interval handler
无法被回收,则它的依赖也不能被回收。这意味着 serverData
— 可能存储了大量数据,也不能被回收。
在观察者模式下,重要的是在他们不再被需要的时候显式地去删除它们(或者让相关对象变为不可访问)。
幸运的是,大多数现代浏览器都能为您完成这项工作:即使您忘记移除侦听器,一旦观察到的对象无法访问,它们也会自动收集观察者处理程序。在过去,一些浏览器无法处理这些情况(旧的IE6)。
尽管如此,一旦对象变得过时,它仍然符合删除观察者的最佳实践。例如:
var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
counter++;
element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers // that don't handle cycles well.
在使节点无法访问之前,您不再需要调用 removeEventListener
,因为现代浏览器支持可以检测这些循环并适当处理它们的垃圾收集器。
如果利用 jQuery API(其他库和框架也支持它),可以在节点过时之前删除侦听器。即使应用程序在较旧的浏览器版本下运行,该库也将确保没有内存泄漏。
JavaScript 开发的一个关键方面就是闭包:一个可以访问外部(封闭)函数变量的内部函数。由于 JavaScript 运行时的实现细节,可以通过以下方式泄漏内存:
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) // a reference to 'originalThing'
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log("message");
}
};
};
setInterval(replaceThing, 1000);
这个代码片段做了一件事:每次调用 replaceThing
时,theThing
都会获得一个新对象,它包含一个大的数组和一个新的闭包(someMethod
)。同时,变量 unused
保留了一个拥有originalThing
引用的闭包(前一次调用 theThing
赋值给了 originalThing
)。已经有点混乱了吗?重要的是,一旦一个作用域被创建为闭包,那么它的父作用域将被共享。
在这个例子中,创建闭包 someMethod
的作用域是于 unused
共享的。unused
拥有 originalThing
的引用。尽管 unused
从来都没有使用,但是 someMethod
能够通过 theThing
在 replaceThing
之外的作用域使用(例如全局范围)。并且由于 someMethod
和 unused
共享闭包作用域,unused
的引用将强制保持 originalThing
处于活动状态(两个闭包之间共享整个作用域),这样防止了垃圾回收。
当这段代码重复执行时,可以观察到内存使用量的稳定增长。当 GC 运行时,也没有变小。实质上,引擎创建了一个闭包的链接列表(root
就是变量 theThing
),并且这些闭包的作用域中每一个都有对大数组的间接引用,导致了相当大的内存泄漏。
This issue was found by the Meteor team and they have a great article that describes the issue in great detail.
有时候,在数据结构中存储 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() {
// The image is a direct child of the body element.
document.body.removeChild(document.getElementById('image'));
// At this point, we still have a reference to #button in the
//global elements object. In other words, the button element is
//still in memory and cannot be collected by the GC.
}
还有一个额外的考虑,当涉及 DOM 树内部或叶子节点的引用时,必须考虑这一点。假设你在 JavaScript 代码中保留了对 table 特定单元格()的引用。有一天,你决定从 DOM 中删除该 table,但扔保留着对该单元格的引用。直观地来看,可以假设 GC 将收集除了该单元格之外所有的内容。实际上,这不会发生的:该单元格是该 table 的子节点,并且 children 保持着对它们 parents 的引用。也就是说,在 JavaScript 代码中对单元格的引用会导致整个表都保留在内存中的。保留 DOM 元素的引用时,需要仔细考虑。