JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

时间:2022-08-04 14:57:10

注:本文翻译自网上的文章,原文地址:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec

本系列的第一篇文章重点介绍了引擎、运行时和调用堆栈。第二篇文章深入Google的V8 JavaScript引擎内部,并提供了一些关于如何编写更好的JavaScript代码的建议。

在第三篇文章中,我们将讨论另一个越来越被开发人员忽视的重要主题,因为日常使用的编程语言-内存管理,越来越成熟且复杂。我们还将提供一些关于如何处理JavaScript中内存泄漏的技巧。

概述

像C这样的语言具有低级内存管理原语,如malloc()和free()。开发人员使用这些原语来显式分配和释放操作系统内存。

与此同时,当对象、字符串等被创建时,JavaScript分配内存,并在不再使用时自动释放内存,这个过程称为垃圾回收。这种看似“自动”释放资源的特性是引起混淆的一个根源,它给了JavaScript以及其他高级语言开发人员一个错误印象,他们可以不关心内存管理。这是一个大错误。

即使使用高级语言,开发人员也应该理解内存管理(至少最基本的)。有时,自动内存管理存在问题(例如垃圾回收中的错误或实现上的限制等),开发人员必须了解这些问题才能正确处理这些问题,或者以最小代价找到适当的解决方法。

内存生命周期

无论您使用什么编程语言,内存生命周期几乎都是一样的:

JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

以下是对周期中每个步骤的概述:

  • 分配内存 - 内存由操作系统分配,允许程序使用它。在低级语言(如C)中,这是一个开发人员应该处理的显式操作。 然而,在高级语言中已为您处理。

  • 使用内存 - 这是您的程序真正在使用前面分配的内存,读取和写入代码中分配的变量。

  • 释放内存 - 释放不再需要的整个内存,将它变成空闲且可以再次使用。与分配内存操作一样,这个操作在低级语言中是显式的。

如果要快速了解有关调用堆栈和内存堆的概念,您可以阅读我们的第一篇文章

什么是内存

在进入JavaScript中的内存之前,先简要地讨论一下内存的概况以及它是如何工作的。

在硬件层面上,计算机内存由大量的触发器组成。每个触发器包含一些晶体管,能够存储bit位。每个触发器可以通过唯一的标识符来寻址,所以我们可以读取和写入。因此,从概念上讲,我们可以把整个计算机内存看作是我们可以读写的巨大bit位阵列。

但作为人类,我们并不善于将思想和算术按位来考虑,我们把它们组织成更大的分组,它们可以一起用来表示数字。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×4 + 8 = 28个字节。

这是目前的整型和双精度型的大小。大约20年前,整型通常是2个字节,双精度型4字节。您的代码不应该依赖于基本数据类型的大小。

编译器将插入与操作系统进行交互的代码,以在堆栈中申请必要的字节数来存储变量。

在上面的例子中,编译器知道每个变量的确切内存地址。事实上,只要我们写入变量n,内部就会翻译成类似“内存地址4127963”的内容。

注意,如果我们试图访问==x[4]==,我们将访问与==m==关联的数据,这是因为我们在访问数组中的一个不存在的元素 - 它比数组中最后一个元素x[3]的地址远了4个字节,最终读取(或覆盖)==m==中的一些bit位。程序的其余部分肯定会得到不希望的结果。

JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

当函数调用其他函数时,每个函数调用时都会有自己的堆栈块。它保存所有的局部变量,以及一个程序计数器,它记录了当前执行的地址。 当函数结束时,这个内存块可用于其他目的。

动态分配

不幸的是,当我们不知道编译时变量需要多少内存时,事情变得复杂起来。假设我们想要做如下的事情:

int n = readInput(); // reads input from the user
...
// create an array with "n" elements

在编译时,编译器不知道数组需要多少内存,因为它是由用户输入的值决定的。

因此,它不能在堆栈上为变量分配空间,程序需要在运行时明确地向操作系统请求适当的空间。这个内存是从堆空间分配的。下表总结了静态和动态内存分配之间的区别:

JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

静态和动态分配内存之间的区别

为了充分理解动态内存分配是如何工作的,我们需要在指针上多点时间,但这可能与本文的主题偏离太多。如果您有兴趣了解更多信息,请在评论中告诉我们,我们可以在以后的文章中详细介绍指针。

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(); // 分配一个Date对象
var e = document.createElement('div'); // 分配一个DOM元素

方法可以分配新的值或对象:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string
// 因为字符串是不变的,
// JavaScript可能决定不分配内存,
// 仅仅存储[0, 3]区间.

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
// 4个元素的新数组,是a1和a2的元素连接起来的

在JavaScript中使用内存

通常在JavaScript中使用分配的内存,意味着往其中读写,比如读取或写入变量或对象属性的值,或者将参数传递给函数。

当内存不再需要时释放

大部分内存管理问题都出在这个步骤。

最困难的任务在于确定何时不再需要所分配的内存。它通常需要开发人员确定程序中不再需要这些内存,并将其释放。

高级语言嵌入了一个名为垃圾回收器的软件,其工作是跟踪内存分配和使用情况,以便在不再需要所分配内存的时候自动释放之。

不幸的是,这个过程靠预估,因为是否需要某些内存的问题是不可判定的(不能由算法来解决)。

大多数垃圾回收器通过收集不再访问的内存的方式,例如,所有指向它的变量都超出了范围。然而,这是可以收集的内存空间集合的一个近似值,因为在某个内存位置可能仍然有一个范围内变量指向它,但它永远不会再被访问。

垃圾回收

由于发现某些内存是否“不再需要”事实上是不可判定的,因此垃圾回收实现对一般问题的解决方案存在局限。 本节将解释主要的垃圾回收算法及其局限性。

内存引用

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

在内存管理的用户上下文下,如果一个对象访问另一个对象(可以是隐式或显式的),则称该对象引用另一个对象。例如,JavaScript对象具有对其原型(隐式引用)及其属性值(显式引用)的引用。

在这种情况下,“对象”的概念被扩展到比普通JavaScript对象更广泛的范围,并且还包含函数作用域(或全局词法作用域)。

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

引用计数垃圾回收

这是最简单的垃圾回收算法。 如果有零个引用指向它,则该对象被认为是“可回收的”。

看看下面的代码:

var o1 = {
o2: {
x: 1
}
};

// 2个对象被创建.
// 'o2' 被 'o1' 对象作为其属性之一被引用.
// 它们都不能回收

var o3 = o1; // 'o3' 是第二个具有 'o1'指向的对象的引用的变量.

o1 = 1; // 现在,最初的'o1'对象只有一个引用,由 'o3' 变量持有

var o4 = o3.o2; // 引用到对象的 'o2' 属性.
// 现在对象有2个引用:其中一个作为属性
// 另一个作为 'o4' 变量.

o3 = '374'; // 最初的'o1'对象现在有0个引用,可以回收.
// 但是,由于它的 'o2' 属性还在被 'o4' 变量引用
// 所以它还不能释放.

o4 = null; // 最初的'o1'对象的 'o2' 属性没有被引用.
// 可以被垃圾回收.

循环引起问题

当循环形成时,就存在一定的局限性。在下面的例子中,创建两个对象并相互引用,从而形成了循环。 函数调用之后,它们会超出作用域,所以它们实际上是无用的,可以被释放。 然而,引用计数算法认为,由于两个对象中的每一个被引用至少一次,所以两者都不能被垃圾回收。

function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1. 这形成了一个循环.
}

f();

JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

标记清除算法

为了确定对象是否还有用,该算法判断对象是否可到达。

标记清除算法有3个步骤:

  1. 根:通常,根是代码中引用的全局变量。例如,在JavaScript中,可以充当根的全局变量是“window”对象。Node.js中的root对象为“global”。所有根的完整列表由垃圾回收器构建。

  2. 算法然后检查所有根和他们的孩子并且标记他们是活跃的(意思着他们不是垃圾)。任何根不能达到的对象将被标记为垃圾。

  3. 最后,垃圾回收器释放所有未标记为活动的内存块,并将该内存返回给操作系统。

JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

标记清除算法的视觉呈现

这个算法比前一个更好,因为“一个对象有零引用”导致这个对象不可达,而前一个算法在存在循环引用的情况下并非总是正确。

截至2012年,所有现代浏览器都发布了标记清除的垃圾回收器。JavaScript垃圾回收(分代/增量/并发/并行垃圾回收)领域中所做的所有改进都是针对这种算法(标记清除)的实现改进,而不是对垃圾回收算法本身的改进,也不是针对决定一个对象是否可达这个目标。

这篇文章中,您可以详细阅读有关跟踪垃圾回收的更详细信息,也涵盖了标记清除算法以及其优化。

循环不再是问题了

在上面第一个例子中,函数调用返回后,两个对象不再被全局对象可访问的东西引用。因此,它们将被垃圾回收器发现不可达。

JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

即使对象之间有引用,它们从根路径不可达。

抵制垃圾收集器的直观行为

尽管垃圾回收很方便,但他们也有不足。其中之一是不可决定,换句话说,GC是不可预测的。你不能知道什么时候回收。这意味着在某些情况下,程序会使用比实际所需的更多的内存。另外,在特别敏感的应用程序中,短暂的暂停可能影响很大。尽管不可决定意味着不能确定何时回收,但大多数GC都共用一种通用模式,在分配期间执行回收。如果没有执行分配,大多数GC保持空闲状态。考虑以下场景:

  1. 大量的分配被执行。

  2. 大多数元素(或所有这些元素)被标记为不可达(假设我们将一个引用指向我们不再需要的缓存)。

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

在这种情况下,大多数GC不会再进行回收。换句话说,即使有不可达的引用可被回收,回收器也不会去做。这并不是严格意义上的泄漏,但仍会导致内存使用率高于平时。

什么是内存泄漏

内存泄漏是应用程序不再需要曾使用的内存片段,但尚未返还给操作系统或可用内存池。

JavaScript是如何工作的:内存管理 + 4种常见的内存泄漏问题

编程语言有不同的内存管理方式。但是,某一段内存是否在用实际上是一个不可判定的问题。换句话说,只有开发人员清楚是否可以将一块内存返还给操作系统。

某些编程语言提供了这样做功能给开发人员,另一些则希望开发人员能够完全清楚一段内存何时不再被使用。*有关手动自动内存管理的好文章。

四种常见的JavaScript泄漏

1:全局变量

JavaScript以一种有趣的方式处理未声明的变量:当引用未声明的变量时,在全局对象中创建一个新变量。 在浏览器中,全局对象将是==window==,这意味着

function foo(arg) {
bar = "some text";
}

等价于

function foo(arg) {
window.bar = "some text";
}

假设我们的目的只是引用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();

您可以在JavaScript文件的开始添加 ‘use strict’; 来避免这一问题,这将开启一个更加严格的解析JavaScript模式,防止意外创建全局变量。

意外的全局变量当然是一个问题,然而,更多的时候,代码会受到显式全局变量的影响,而这些全局变量在垃圾回收器中是无法收集的。需要特别注意用于临时存储和处理大量信息的全局变量。如果您必须使用全局变量来存储数据,那么确保在使用完后将其分配为空值,或者重新分配

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.

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

==渲染器==对象可能会在某个时候被替换或删除,这会使得定时器处理程序封装的块变得冗余。如果出现这种情况,处理程序及其依赖项都不会被收集,因为定时器需要先停止(请记住,它仍然是活动的)。这一切都导致存储加载数据的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);

// 现在,当element超出生命周期时,
// element和onClick将会被回收,即使在无法处理好循环引用的旧浏览器中.

在节点无法访问前,你不再需要调用==removeEventListener==,因为现代浏览器有可以检测这些循环并处理好它们的垃圾回收器。

如果您利用jQuery API(其他库和框架也支持这一点),您也可以在节点失效之前删除侦听器。 即使应用程序在较旧的浏览器版本下运行,库也会确保没有内存泄漏。

3:闭包

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==)组成。然而,==originalThing==是由==未使用==的变量(这是从之前的调用replaceThing的theThing变量)持有的闭包引用的。需要记住的是,一旦在同一父作用域中为闭包创建了闭包的作用域,作用域就被共享了。

在这种情况下,为闭包==someMethod==创建的范围与==unused==共享。==unused==有==originalThing==的引用。尽管==unused==从未使用过,但是可以通过==replaceThing==范围之外的==theThing==(例如全局某处)使用==someMethod==。由于==someMethod==与==unused==的闭包共享范围,==unused==的引用必须使原始的==theThing==强制保持活跃(两个闭包之间的整个共享范围)。这就阻止了它们被回收。

在上面的例子中,为闭包==someMethod==创建的作用域与==unused==共享,而==unused==引用==originalThing==。==someMethod==可以在==replaceThing==范围外使用==theThing==,尽管事实上==unused==从来没有被使用过。事实上,未用到的对==originalThing==的引用要求它保持活动,因为==someMethod==与==unused==共享闭包范围。

所有这些都可能导致相当大的内存泄漏。当上面的代码片段一遍又一遍地运行时,您可以预期会看到内存使用率的上升。当垃圾回收器运行时,其大小不会缩小。创建了一个闭包的链表(在这种情况下它的根是==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() {
// The image is a direct child of the body element.
document.body.removeChild(document.getElementById('image'));

// 这个点上,我们仍然在全局elements对象中持有#button的引用.
// 换句话说,button元素仍然在内存中,无法被GC回收.
}

在涉及DOM树的内部节点或叶节点时,还有一个额外需要考虑的因素。如果您在代码中保留对表格单元格(\

参考资源