Jason Pan

JavaScript 工作原理 —— 内存管理 + 4类常见内存泄漏的处理

潘忠显 / 2021-04-03


“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显

为了让开发者深入的理解 JavaScript 及其工作原理,编写更好的代码和应用,我们发表了一系列深入研究的文章。第一篇文章重点介绍了引擎、运行时和调用栈概述,第二篇仔细解释了Google V8引擎内部构成,并提供了一些如何编写优化的 JavaScript 代码的技巧。

本文作为第三篇文章,将讨论内存管理。现代编程语言的日趋复杂和成熟,导致开发人员正越来越忽略内存管理。

[TOC]

概述

一些语言(比如 C 语言)具有低级的内存管理原语,例如 malloc()free()。开发人员调用这些原语,在操作系统上显式地分配和释放内存。

JavaScript 在创建对象、字符串等变量时分配内存,并在不再使用它们时“自动”释放,这一过程称为“垃圾收集 (GC, garbage collection)”。这种看似“自动”的特性,给 JavaScript(和其他高级语言)开发者带来了错误的印象:他们可以选择不关心内存管理。 这是一个大错误。

即使使用高级语言,开发人员也应该对内存管理有所了解,至少是要了解基础知识。有时,自动内存管理也存在一些问题。例如,错误或垃圾收集器中的实现限制等。开发者必须了解这些问题,以便正确处理它们,或者找到代价最小或带来最少技术债的解决方法。

内存生命周期

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

img

要快速了解调用堆栈和内存堆的概念,请阅读我们的第一篇文章

什么是内存

在介绍 JavaScript 的内存之前,我们将概述并简要讨论一下一般的内存。

在硬件级别上,计算机内存包含大量的触发器 (FF, Flip-flop)。每个触发器包含几个晶体管,并且能够存储 1 bit。单个触发器可通过唯一标识符 (unique identifier) 寻址,我们可以读取和覆盖它们。因此,从概念上讲,我们可以将整个计算机内存视为可以读取和写入的一个巨大的 bit 数组。

人类并不擅长按位进行思维和算术运算,因此我们将 bit 组织成更大的组,以用来表示数字。8 位称为 1 个字节 (byte),还有 16 位或 32 位的字 (word)。

很多东西都存储于内存中:

编译器和操作系统合作,处理了大部分内存管理,但是我们建议您了解原理。

编译代码时,编译器检查原始数据类型并提前计算它们将需要多少内存。然后,所需的数量将在“调用栈空间”中分配给程序。在调用函数时,这些变量的内存会添加到现有内存的顶部,当它们终止时,将按照LIFO(后进先出)的顺序将其删除,因此该空间被称为“栈空间”。例如,考虑以下声明:

int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

编译器会立即判断这段代码需要 28 字节(4 + 4 × 4 + 8 = 28 bytes)

正式现在的整数和双精度浮点数所占内存大小。但20年前,int 典型的尺寸是 2 字节,double是 4 字节。现在代码不必依赖基本数据类型的大小。

编译器将插入与操作系统进行交互的代码(译者注:系统调用),以请求在堆栈上存储所需的字节数以存储变量。

在上面的示例中,编译器知道每个变量的确切内存地址。实际上,每当我们给变量赋值 n 时,它都会在内部转换为“内存地址 0x4127963 之类的东西。

注意,如果我们尝试在这里访问 x[4],我们将访问到与 m 相关的数据。这是因为我们正在访问数组中不存在的元素,它比数组中最后一个实际分配的元素 x[3] 还多 4 个字节,并且可能最终读取(或覆盖)其中的一些 m 的位。这几乎肯定会对程序的其余部分,产生非常不希望的后果。

img

当函数调用其他函数时,每个函数在调用时都会获得自己的堆栈块。它把所有的局部变量都保存在那里,还有一个程序计数器,用来记住它在哪里执行。函数结束后,其存储块将再次用于其他目的。

动态分配

但事情没有那么简单,在编译时,有些变量不能知道具体需要多少内存。假设我们执行下边的代码:

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

上边的例子,在编译时,编译器不知道该数组将需要多少内存,因为它由用户提供的值确定。因此不能在栈上为变量分配空间。相反,程序需要明确要求操作系统提供合适大小的内存空间。这部分内存是从“堆空间 (heap space)”分配的。下表总结了静态和动态内存分配之间的区别:

静态和动态分配的内存之间的差异

为了完全理解动态内存分配的工作原理,我们需要花更多的时间在“指针 (pointers) ”上,这可能与本文的主题有点偏离,我们将在以后的文章中详细介绍指针。

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);

一些函数调用 (function calls) 也会导致对象分配:

var d = new Date(); // allocates a Date object
var e = document.createElement('div'); // allocates a DOM element

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

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string
// Since strings are immutable, 
// JavaScript may decide to not allocate memory, 
// but just store the [0, 3] range.

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

JavaScript 中使用内存

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

这可以通过读取或写入变量或对象属性的值,以及将参数传递给函数来完成。

不需要内存时释放

大多数内存管理问题都出现在此阶段。这里最困难的是:弄清已分配的内存在何时不再需要使用。通常需要开发者确定程序中不再需要该内存的位置,然后释放它。

高级语言嵌入了一个称为“垃圾收集器”的模块,跟踪内存的分配和使用情况,当存在不再需要的已分配内存,将其自动释放内存。

不幸的是,该过程是不是很精确,因为知道是否需要某些内存的一般性问题是不可判定问题 (undecidable),这无法通过算法来解决。

大多数垃圾收集器的工作,是收集无法访问的内存,例如所有指向该内存的变量都出了作用域。这仅能收集的这些存储空间的近似值,因为可能存在某个变量指向一块内存位置,但永远不会再访问这块内存了。

垃圾收集

由于无法确定是否“不再需要”一些内存这一事实,垃圾回收实现了对一般问题的解决方案的限制。本节将解释必要的概念,以解释主要的垃圾收集算法及其局限性。

Due to the fact that finding whether some memory is “not needed anymore” is undecidable, garbage collections implement a restriction of a solution to the general problem. This section will explain the necessary notions to understand the main garbage collection algorithms and their limitations.

内存引用

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

我们描述内存管理时,一个对象引用另外一个对象,指的是前者可以隐式或显式地访问后者。例如,JavaScript 对象对其 prototype隐式引用,以及对其属性值的显式引用

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

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

译者注:参考闭包

引用计数垃圾收集 (Reference-counting GC)

这是最简单的垃圾收集算法。如果没有引用指向该对象,则该对象被视为“垃圾可收集”。来看下面的代码:

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

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 
                                                       
o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

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

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.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.

循环引用的问题

引用计数算法会在循环引用遇到限制。在下面的示例中,创建了两个对象并互相引用,从而创建了一个循环。它们将在函数调用后,出了作用域,它们实际上是无用的并且可以被释放。但是,引用计数算法认为,由于两个对象中的每个对象都至少被引用了一次,因此都无法进行垃圾回收。

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

f();

img

标记-清除算法 (Mark-and-sweep algorithm)

为了确定是否需要对象,该算法确定该对象是否可到达 (reachable)。标记-清除算法包含以下三个步骤:

  1. 根:根通常是在代码中引用的全局变量。例如,在 JavaScript (译者注:是不是应该为Chrome?) 中,可以用作根的全局变量是 “window” 对象,在 Node.js 中为 “global” 对象。垃圾收集器将构建所有根的完整列表。
  2. 算法检查所有根及其子代,并将其标记为活动状态,这意味着它们不是垃圾。任何根无法到达的部分,都将被标记为垃圾。
  3. 垃圾收集器释放所有未标记为活动的内存,并将该内存返回给操作系统。

运行中标记和清除算法的可视化

标记-清除算法比引用计数算法更好,它能解决循环引用中“一个对象的引用非零,但不能释放”的问题。

This algorithm is better than the previous one since “an object has zero reference” leads to this object being unreachable. The opposite is not true as we have seen with cycles.

截至2012年,所有现代浏览器都附带了标记清除垃圾收集器。过去几年中,在 JavaScript 垃圾收集领域,包括世代/增量/并行/并行垃圾收集,所做的所有改进都是基于标记-清除算法实现方式上的改进,但不是垃圾收集算法本身的改进,也不是确定对象是否可到达的目标。

这篇文章中,可以找到关于追踪垃圾收集的更多细节,这覆盖了标记-清除算法及其优化。

循环引用不再是问题

在上面的第一个示例中,在函数调用返回之后,函数内的两个对象不再被外部的任何对象所引用。然后,垃圾回收器将会发现它们无法访问。

img

即使在对象之间存在引用,也无法从根节点访问它们。

违反直觉的垃圾收集器行为

尽管垃圾收集器很方便,但是它们还是有自己的权衡取舍的。其中之一是非确定性 (non-determinism),换言之,GC是不可预测的。您无法真正确定何时执行收集。这意味着在某些情况下,程序会使用超出实际需要的内存。另外在特别敏感应用中,短暂停可能会很明显。尽管不确定性意味着无法确定何时执行收集,但大多数 GC 实现是使用通用的模式,在分配阶段执行收集遍历 (doing collection passes during allocation)。如果不执行任何分配动作,大多数 GC 会保持空闲状态。请考虑以下情形:

  1. 执行一组相当大的分配。
  2. 大多数元素或所有元素都被标记为不可访问(假设将不需使用的内存的指针,设置为null)
  3. 不执行进一步的分配

在这种情况下,大多数GC将不再运行任何进一步的收集过程。换句话说,即使有不可用的引用可用于收集,GC 也不会收集。这并不是严格意义上的内存泄漏,但仍会导致内存使用率高于正常水平。

什么是内存泄漏

内存泄漏是当应用程序不再需要使用的、已分配的内存,没有被返回到操作系统或空闲内存池。

img

编程语言支持不同的内存管理方式。但某片内存是否被使用是不确定问题,换句话说,只有开发者清楚,哪些内存可以被归还给操作系统。某些编程语言提供特性,来帮助开发者做这件事;其他语言则期望,开发者完全显式地标明哪些内存不会再使用了。维基百科上,手动 (manual) 和自动 (automatic) 内存管理两篇文章可以参考。

JavaScript 四种常见内存泄漏

1. 全局变量

JavaScript以一种有趣的方式处理未声明的变量:当引用未声明的变量时,将在**全局对象 (global object) **中创建一个新变量。在浏览器中,全局对象将是 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,可以避免这些情况,这将打开更严格的 JavaScript 解析模式 (strict mode),以防止意外创建全局变量。

意外的全局变量显然是个问题,但更可能发生的是,您的代码会被显式全局变量所感染,这些变量不能被垃圾收集器收集。特别要注意用于临时存储和处理大量信息的全局变量。如有必要,可以使用全局变量存储数据,但要确保在完成操作后,将其“分配为空”或“将其重新分配”(assign it as null or reassign it)。

2. 被遗忘的计时器与回调

让我们以 setInterval 为例,因为它经常在 JavaScript 中被用到。

提供观察者 (observer) 和接受回调的其他工具的库,通常会确保一旦实例无法访问,所有对回调的引用都将变为不可访问。不过,我们也会偶尔见到下边的代码:

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 对象,这会使间隔处理程序封装的块变得多余。这种情况下,GC 不会收集处理程序及其依赖项,因为首先需要停止仍处于活动状态的间隔处理。归根结底,存储和处理数据负载的 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.

You no longer need to call removeEventListener before making a node unreachable as modern browsers support garbage collectors that can detect these cycles and handle them appropriately.

If you leverage the jQuery APIs (other libraries and frameworks support this too) you can also have the listeners removed before a node is made obsolete. The library would also make sure there are no memory leaks even when the application is running under older browser versions.

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);

一旦调用了 replaceThingtheThing 将获得一个新对象,该对象由一个大数组和一个新的闭包someMethod组成。但是,originalThingunused 变量所持有的闭包引用,其中unused 是上一次对 replaceThing 的调用中的 theThing 变量。要记住:一旦在同一父范围中,为闭包创建了一个闭包作用域,该作用域就回被共享。(once a scope for closures is created for closures in the same parent scope, the scope is shared.)

在这种情况下,为闭包 someMethod 创建的作用域与 unused 共享,而 unused 引用了 originalThing。即使从未使用过 unused,也可以在 replaceThing 范围之外(例如全局某处),通过theThing使用 someMethod。由于 someMethodunused 共享闭包域,对 unused 引用必须使 originalThing 强制其保持活动状态,以防止其被垃圾收集。

(译者注:原文这里有两段含义重复的解释,省略掉)

这样都会导致可观的内存泄漏。当上述代码片段一遍又一遍地运行时,您可能会看到内存使用量激增。当垃圾收集器运行时,其大小不会缩小。创建一个闭包的链表(在本例中,其根为theThing变量),每个闭包范围都对大数组进行间接引用。

流星团队发现了此问题,他们有一篇很棒的文章,对该问题进行了详细描述。

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'));
    // 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树中的内部或叶节点时,还要考虑其他因素。如果在代码中保留对表单元格的引用(<td>标签),并决定从 DOM 中删除表,但仍保留对该特定单元格的引用,则可能会发生大量内存泄漏。您可能会认为垃圾收集器将释放除该单元格之外的所有内容。但是,事实并非如此。由于单元格是表的子节点,并且子级保留对其父级的引用,因此对表单元格的单个引用会将整个表保留在内存中

参考资料