[译文] JavaScript工作原理:内存管理+如何处理4种常见的内存泄露

1,747 阅读21分钟

原文 How JavaScript works: memory management + how to handle 4 common memory leaks

几周前我们开始了一个系列博文旨在深入挖掘 JavaScript 并弄清楚它的工作原理:我们认为通过了解 JavaScript 的构建单元并熟悉它们是怎样结合起来的,有助于写出更好的代码和应用。

本系列的第一篇文章着重提供一个关于引擎、运行时和调用栈的概述。第二篇文章深入分析了 GoogleV8 引擎的内部实现并提供了一些编写更优质 JavaScript 代码的建议。

在第三篇的本文中,我们将会讨论另一个非常重要的主题,由于日常使用的编程语言的逐渐成熟和复杂性,它被越来越多的开发者忽视——内存管理。我们还会提供一些在 SessionStack 中遵循的关于如何处理 JavaScript 内存泄露的方法,我们必须保证 SessionStack 不会发生内存泄漏,或导致整合进来的应用增加内存消耗。

概述

C 这样的语言,具有低水平的内存管理原语如 malloc()free(),这些原语被开发者用来显式地向操作系统分配和释放内存。

同时,JavaScript 在事物(对象、字符串等)被创建时分配内存,并在它们不再需要用到时自动释放内存,这个过程称为垃圾收集。这个看似自动释放资源的特性是困惑的来源,造成 JavaScript(和其他高级语言)开发者错误的印象,认为他们可以选择不必关心内存管理。这是个天大的误解。

即便在使用高级编程语言时,开发者也应该了解内存管理(至少最基本的)。有时会遇到自动内存管理的问题(如垃圾收集器的BUG和实现限制等),开发者应该了解这些问题才能合理地处理它们(或找到适当的解决方案,用最小的代价和代码债)。

内存生命周期

无论使用哪种编程语言,内存的生命周期几乎总是相同的:

内存生命循环

下面是周期中每个步骤发生了什么的概览:

  • 分配内存——内存由允许程序使用的操作系统分配。在低级编程语言(如 C)中这是一个作为开发人员应该处理的显式操作。而在高级编程语言中是由语言本身帮你处理的。
  • 使用内存——这是程序实际上使用之前所分配内存的阶段。读写操作发生在使用代码中分配的变量时。
  • 释放内存——现在是释放不需要的整个内存的时候了,这样它才能变得空闲以便再次可用。与分配内存一样,在低级编程语言中这是一个显式操作。

想要快速浏览调用栈和内存堆的概念,可以阅读我们关于这个主题的第一篇文章

什么是内存?

在直接介绍 JavaScript 中的内存之前,我们会简要讨论一下内存是什么及它是怎样工作的。

在硬件层面,计算机内存由大量的触发器组成。每个触发器包含几个晶体管能够存储一个比特(译注:1位)。可以通过唯一标识符来访问单个触发器,所以可以对它们进行读写操作。因此从概念上,我们可以把整个计算机内存想象成一个巨大的可读写的比特阵列。

作为人类,我们并不擅长使用字节进行所有的思考和算术,我们把它们组织成更大的组合,一起用来表示数字。8比特称为1个字节。除字节之外,还有其他词(有时是16比特、有时是32比特)。

很多东西存储在内存中:

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

编译器和操作系统一起工作来处理大部分的内存管理,但我们还是建议你了解一下底层发生的事情。

编译代码时,编译器可以检测到原始数据类型然后提前计算出需要多少内存。随后给栈空间中的程序分配所需额度。分配变量的空间被称为栈空间是因为当函数调用时,它们被添加到已有内存的顶部。当它们终止时,根据后进先出的原则被移除。例如,考虑如下声明:

int n; // 4 bytes 4字节
int x[4]; // array of 4 elements, each 4 bytes 含有四个元素的数组,每个4字节
double m; // 8 bytes 8字节

编译器能够立即看出这段代码需要4+4*4+8=28字节。

这是现今处理整型和双精度浮点数的大小。20年以前,整型通常是2字节,双精度是4字节。代码永远不应该依赖当前基本数据类型的大小。

编译器将会插入代码与操作系统交互,请求栈上存储变量所需的字节数。

在上面的例子中,编译器知道每个变量的精确内存地址。实际上,每当写入变量 n,它都会在内部被转换成类似“内存地址4127963”的东西。

注意,如果试图在这里访问 x[4],将会访问到与 m 关联的数据。这是因为我们在访问数组中一个不存在的元素——比数组中最后实际分配的成员 x[3] 要远4个字节,这可能最终会读取(或写入)一些 m 中的比特。这必将会使程序其余部分产生非常不希望得到的结果。

变量内存分配

当函数调用其他函数时,每个函数都会在被调用时得到属于自己的一块栈。这里不仅保存了所有的局部变量,还保存着记录执行位置的程序计数器。当函数结束时,它的内存单元再次变得空闲可供他用。

动态分配

不幸的是,当我们在编译时无法得知变量需要多少内存的时候事情就没那么简单了。假设我们要做如下的事情:

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

这在编译时,编译器无法知道数组需要多少内存,因为它取决于用户提供的值。

因此无法为栈中的变量分配空间。相反,我们的程序需要在运行时显式向操作系统请求合适的空间。这种内存由堆空间分配。静态和动态内存分配的区别总结为下表:

静态内存分配与动态内存分配的区别

要充分理解动态内存分配的原理,我们需要在指针上多花些时间,但这已经偏离了本文的主题。如果有兴趣学习更多,请在评论里留言告诉我们,我们可以在以后的文章中讨论更多关于指针的细节。

JavaScript 中的分配

现在我们将解释第一步(分配内存)如何在 JavaScript 中工作。

JavaScript 将开发者从内存分配的责任中解放出来——在声明变量的同时它会自己处理内存分配。

var n = 374; // allocates memory for a number 为数值分配内存
var s = 'sessionstack'; // allocates memory for a string 为字符串分配内存
var o = {
  a: 1,
  b: null
}; // allocates memory for an object and its contained values  为对象及其包含的值分配内存
var a = [1, null, 'str'];  // (like object) allocates memory for the
                           // array and its contained values (与对象一样)为数组及其包含的值分配内存
function f(a) {
  return a + 3;
} // allocates a function (which is a callable object) 分配函数(即可调用对象)
// function expressions also allocate an object 函数表达式同样分配一个对象
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 分配一个DOM元素

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

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string s2是一个新字符串
// Since strings are immutable, 由于字符串是不可变的
// JavaScript may decide to not allocate memory, JavaScript可能会决定不分配内存
// but just store the [0, 3] range. 而仅仅存储[0, 3]这个范围
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
// new array with 4 elements being 含有四个元素的数组
// the concatenation of a1 and a2 elements 由a1和a2的元素的结合

JavaScript 中使用内存

JavaScript 中使用分配的内存基本上意味着在其中进行读写操作。

这可以通过读取或写入变量的值或对象属性、甚至向函数传参数的时候实现。

在不需要内存时将其释放

大多数内存管理问题出现在这个阶段。

最大的难题是弄清楚何时不再需要分配的内存。通常需要开发者来决定这块内存在程序的何处不再需要并且释放它。

高级编程语言嵌入了一个叫做垃圾收集器软件,它的工作是追踪内存分配和使用以便发现分配的内存何时不再需要,并在这种情况下自动释放它。

不幸的是这个过程只是个近似的过程,因为知道是否还需要一些内存的一般问题是不可决定的(无法靠算法解决)。

大多数垃圾收集器的工作原理是收集不能再访问的内存,比如指向它的所有变量都超出作用域。但这也是对可收集内存空间的一种低估,因为在任何时候作用域内都仍可能有一个变量指向一个内存地址,然而它再也不会被访问。

垃圾收集

由于无法确定某些内存是否“不再需要”,垃圾收集实现了对一般解决方法的限制。这一节将会解释理解主要的垃圾收集算法的必要概念和局限性。

内存引用

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

在内存管理的上下文中,如果一个对象可以访问另一个对象则说成是前者引用了后者(可是隐式也可是显式)。例如,JavaScript 对象有对其原型的引用(隐式引用)和对属性的引用(显式引用)。

在这个上下文中,”对象“的概念扩展到比常规 JavaScript 对象更广泛的范围,并且还包含函数作用域(或全局词法作用域)。

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

引用计数垃圾收集

这是最简单的垃圾收集算法。如果没有指向对象的引用,就被认为是“可收集的”。

看看如下代码:

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objects are created.
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected
// 创建了两个对象
// o2 被当作 o1 的属性而引用
// 现在没有可被收集的垃圾

var o3 = o1; // the 'o3' variable is the second thing that
            // has a reference to the object pointed by 'o1'.
            // o3是第二个引用了o1 所指向对象的变量。

o1 = 1;      // now, the object that was originally in 'o1' has a
            // single reference, embodied by the 'o3' variable
            // 现在,本来被 o1 指向的对象变成了单一引用,体现在 o3 上。

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property.
                // The other as the 'o4' variable
                // 通过属性 o2 建立了对它所指对象的引用
                // 这个对象现在有两个引用:一个作为属性的o2
                // 另一个是变量 o4

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it.
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.
            // 原本由 o1 引用的对象现在含有0个引用。
            // 它可以被作为垃圾而收集
            // 但是它的属性 o2 仍然被变量 o4 引用,所以它不能被释放。

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it.
           // It can be garbage collected.
           // 原本由 o1 引用的对象的属性 o2 现在也只有0个引用,它现在可以被收集了。

循环制造出问题

这在循环引用时存在限制。在下面示例中,创建了两个互相引用的对象,从而创建了一个循环。它们在函数调用返回后超出作用域,所以实际上它们已经没用了并应该被释放。但引用计数算法考虑到由于它们至少被引用了一次,所以两者都不会被当作垃圾收集。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();

3-4.png

标记和清理算法

为了决定是否还需要对象,这个算法确定了对象是否可以访问。

标记和清理算法有如下三个步骤:

  1. 根:通常,根是被代码引用的全局变量。例如在 JavaScript 中,可以作为根的全局变量是 window 对象。同一对象在 Node.js 中被称为 global。垃圾收集器建立了所有根的完整列表。
  2. 接着算法检查所有根及它们的子节点,并把它们标记为活跃的(意为它们不是垃圾)。根所不能获取到的任何东西都被标记为垃圾。
  3. 最终,垃圾收集器把未标记为活跃的所有内存片段释放并返还给操作系统。

标记和清理算法的视觉化行为.gif

这个算法比之前的更好,因为“一个对象没有引用”造成这个对象变得不可获取,但通过循环我们看到反过来却是不成立的。

2012年后,所有现代浏览器都装载了标记和清理垃圾收集器。近年来,在 JavaScript 垃圾收集所有领域的改善(分代/增量/并发/并行垃圾收集)都是这个算法(标记和清理)的实现改进,既不是垃圾收集算法自身的改进也并非决定是否对象可获取的目标的改进。

这篇文章中,你可以阅读到有关追踪垃圾收集的大量细节,并且涵盖了标记和清理及它的优化。

循环不再是问题

在上面的第一个例子中,当函数调用返回后,两个对象不再被全局对象的可获取节点引用。结果是,它们会被垃圾收集齐认为是不可获取的。

3-6.png

即便它们彼此间仍存在引用,它们也不能被根获取到。

垃圾收集器与直觉相反的行为

虽然垃圾收集器很方便,但它们也有自己的一套折中策略。其一是非确定性。换句话说,垃圾收集是不可预测的。你无法确切知道垃圾收集什么时候执行。这意味着在一些情况下程序会要求比实际需要更多的内存。另一些情况下,短时暂停会在一些特别敏感的应用中很明显。虽然非确定性意味着无法确定垃圾收集执行的时间,但大多数垃圾收集的实现都共享一个通用模式:在内存分配期间进行收集。如果没有内存分配发生,垃圾收集器就处于闲置。考虑以下场景:

  1. 执行大量内存分配。
  2. 它们大多数(或全部)被标记为不可获取(假设我们将一个不再需要的指向缓存的引用置为null)。
  3. 不再有进一步的内存分配发生。

在这个场景下,大多数垃圾收集不会再运行收集传递。换言之,即时存在无法访问的引用可以收集,它们也不会被收集器注意到。这些不是严格意义上的泄露,但是仍然导致了比正常更高的内存使用。

什么是内存泄露?

就像内存所暗示的,内存泄露是被应用使用过的一块内存在不需要时尚未返还给操作操作系统或由于糟糕的内存释放未能返还。

3-7.jpeg

编程语言喜欢用不同的方式进行内存管理。但一块已知内存是否还被使用实际上是个无法决定的问题。换句话说,只有开发人员可以弄清除是否应该将一块内存还给操作系统。

某些编程语言提供了开发人员手动释放内存的特性。另一些则希望由开发人员完全提供显式的声明。维基百科上有关于手动自动内存管理的好的文章。

四种常见 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();

可以通过在 JavaScript 文件的开头添加 'use strict'; 来避免这一切,这会开启一个更加严格的模式来解析代码,它可以防止意外创建全局变量。

意外的全局变量当然是个问题,但是通常情况下,你的代码会被显示全局变量污染,并且根据定义它们无法被垃圾收集器收集。应该尤其注意用来临时性存储和处理大量信息的全局变量。如果你必须使用全局变量存储信息而当你这样做了时,确保一旦完成之后就将它赋值为 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.

上面这段代码展示了引用不再需要的节点或数据的后果。

renderer 对象可能在某个时候被覆盖或移除,这将会导致封装在间隔处理函数中的语句变得冗余。一旦发生这种情况,处理器和它依赖的东西必须要等到间隔器先被停止之后才能收集(记住,它依然是活跃的)。这将会导致这样的事实:用于储存和处理数据的 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 APIs(其他库和框架也支持),它也可以在节点无效之前移除监听器。这个库也会确保没有内存泄露发生,即使应用运行在老浏览器之下。

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 被变量 unused 拥有的闭包所引用(值是上一次 replaceThing 调用所得到的变量 theThing )。要记住的是当一个闭包作用域被创建时,位于同一个父作用域内的其他闭包也共享这个作用域。

在这个案列中,为闭包 someMethod 创建的作用域被 unused 共享。即便 unused 从未使用,someMethod 可以通过位于 replaceThing 外层的 theThing 使用(例如,在全局中)。又因为 someMethodunused 共享闭包作用域,unused 引用的 originalThing 被强制处于活跃状态(在两个闭包之间被共享的整个作用域)。这些妨碍了被收集。

在上述列子中,当 unused 引用了 originalThing 时,共享了为 someMethod 创建的作用域。可以通过 replaceThing 作用域外的 theThing 使用 someMethod,且不管其实 unused 从未使用。事实上 unused 引用了 originalThing 使其保持在活跃状态,因为someMethodunused 共享了闭包作用域。

所有的这些导致了相当大的内存泄露。你会看到在上述代码一遍又一遍运行时内存使用量的激增。它不会在垃圾收集器运行时变小。一系列的闭包被创建(此例中根是变量 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.
    // 图片是body的直接子元素
    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.
    // 这时,全局elements对象仍有一个对#button元素的引用。换句话说,button元素
    // 仍然在内存里,无法被垃圾收集器回收。
}

还有一个例外情况应该被考虑到,它出现在引用 DOM 树的内部或叶节点时。如果你在代码里保存了一个对表格单元(td 标签)的引用,然后决定把表格从 DOM 中移除但保留对那个特别单元格的引用,就能预料到将会有大量的内存泄露。你可能认为垃圾收集器将释放其他所有的东西除了那个单元格。但是,这将不会发生。因为这个单元格是表格的一个子节点,子节点保存了对它们父节点的引用,引用这一个单元格将会在内存里保存整个表格。