【译】JavaScript的工作原理:内存管理和4种常见的内存泄漏

2,854 阅读20分钟

该系列的第一篇文章重点介绍了引擎,运行时和调用堆栈的概述。第二篇文章深入剖析了Google的V8 JavaScript引擎,并提供了关于如何编写更好的JavaScript代码的一些提示。

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

概述

类似与C这种编程语言,提供了从底层来管理内存的方法,比如malloc()和free()。开发人员可以通过它们,来处理操作系统的分配内存,或释放内存到操作系统中。
在JavaScript当中,当对象或字符串等被创建时,JavaScript会申请和分配内存;当对象或字符不再被使用时,它们就会被自动释放,这个过程被称为垃圾处理。正是这种自动看似自动回收的认识让JavaScript开发者误以为他们不用关心内存管理,这是一个很大的错误
即使使用高级语言,开发者也应该理解内存管理(即便是基础),有时自动内存管理也会有一些问题(例如bug或者垃圾回收实现的局限性等等),所以开发者必须要明白它们,才能够妥善的处理。

内存生命周期

无论你使用什么语言,内存的生命周期大体是相同的:

这儿描述一下,在每一个生命周期发生的事情:

  • 分配内存——内存是由操作系统分配,运行程序使用它,在底层语言当中(如C),这是需要一个显示的操作,作为开发人员需要处理的,在高级语言当中,这个操作被隐藏了。
  • 使用内存——这是你的程序实际使用之前分配的内存。读取和写入操作发生在您在代码中使用分配变量的时候。
  • 释放内存——当你不需要使用的时候,应该释放内存,以便它可以变为空闲并再次可用。 与分配内存操作一样,这个操作在底层语言中是可以直接调用的。

有关调用堆栈和内存堆的概念的概述,您可以阅读本系列第一篇文章

什么是内存

在开始讨论JavaScript的内存之前,我们先短暂的讨论一下相关概念和内存是怎么工作的。
在硬件层面之上,电脑的内存是由大量的触发器,每个触发器都包含一些晶体管并且能够存储一个bit。单个触发器可通过唯一标识符进行寻址,这样就可以读取并覆盖它们。因此,从概念上讲,我们可以将整个计算机内存看作是我们可以读写的bit数组。
从人类角度来说,我们不擅长用bit来完成我们现实中思想和算法,我们把它们组织成更大的部分,它们一起可以用来表示数字。 8位(比特位)称为1个字节(byte)。除字节外,还有单词(word)(有时是16,有时是32位)。

很多东西都存储在这个内存中:

  • 所有程序使用的所有变量和其他数据。
  • 程序的代码,包括操作系统的代码。 编译器和操作系统一起工作,为您处理大部分内存管理,但我们还是建议能够明白下层发生了什么。

编译代码时,编译器可以预先检查原始数据类型并提前计算它们需要多少内存。然后将所需的内存分配给调用堆栈空间中的程序。分配这些变量的空间称为堆栈空间,因为随着函数的调用,它们的内存将被添加到现有内存之上。当它们终止时,它们以LIFO(后进先出)顺序被移除。例如,请考虑以下声明:

int n; // 4字节
int x [4]; // 4个元素的数组,每个4个字节
double m; // 8个字节

编译器可以立即看到代码需要:
4 + 4×4 + 8 = 28个字节。

这就是它如何处理整数和双精度的当前大小。大约20年前,整数通常是2个字节,并且是双4字节。您的代码不应该依赖于此时基本数据类型的大小。

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

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

注意,如果我们试图在这里访问x[4],我们将访问与m关联的数据。这是因为我们正在访问数组中不存在的元素 - 它比数组中最后一个实际分配的元素x[3]更远了4个字节,并且可能最终读取(或覆盖)m中的一些位。这儿就会有bug了。

当函数调用其他函数时,每个函数在调用时都会获得自己的堆栈块。它保留了它所有的局部变量,同时还有一个程序计数器,记录它在执行时的位置。当功能完成时,其存储器块再次可用于其他目的。

动态分配内存

不幸的是,当我们在编译时有时不知道变量需要多少内存时,假设我们想要做如下的事情:

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(); // 为日期对象分配内存
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中使用内存

基本上在JavaScript中使用分配的内存意味着读取和写入。
这可以通过读取或写入变量或对象属性的值,或者甚至将参数传递给函数来完成。

当内存不再需要时释放

大部分内存管理问题都是在这个阶段出现的。

确定何时不再需要使用分配的内存是最困难的。它通常需要开发人员确定程序中的哪个地方不再需要这些内存,并将其释放。

高级语言嵌入了一个名为垃圾收集器的软件,其工作是跟踪内存分配和使用情况,以便找到何时不再需要分配的内存,在这种情况下,它会自动释放它。

不幸的是,这个过程是一个大概,因为知道是否需要某些内存的一般问题是不可判定的(不能由算法解决)。

大多数垃圾收集器通过收集不能再访问的内存来工作,例如,指向它的所有变量都超出了范围。然而,这只可以收集的一组内存空间的近似值,因为在任何时候内存位置可能仍然有一个指向它的变量,但它将不会再被访问。

垃圾收集

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

内存引用

垃圾收集算法所依赖的主要概念是参考之一。

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

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

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

引用计数法垃圾收集

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

var o1 = {
  o2: {
    x: 1
  }
};
// 两个对象被创建. 
// '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',没有地方应用它了,他可以被垃圾回收

循环引用的问题

在循环引用方面存在限制。在以下示例中,创建了两个对象并相互引用,从而创建了一个循环。在函数调用之后它们将超出范围,因此它们实际上是无用的并且可以被释放。但是,引用计数算法认为,由于两个对象中的每一个至少被引用一次,因此两者都不能被垃圾收集。

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

f();

标记和扫描算法

为了确定是否需要一个对象,该算法确定对象是否可以获得。

标记和扫描算法通过以下3个步骤:

  1. root:一般来说,root是在代码中引用的全局变量。例如,在JavaScript中,可以充当root的全局变量是“window”对象。Node.js中的相同对象称为“global”。垃圾收集器构建了所有root的完整列表。
  2. 然后算法检查所有root和它们的子节点,并将它们标记为活动(意思是,它们不是垃圾)。root无法访问的任何内容都将被标记为垃圾。
  3. 最后,垃圾收集器释放所有未标记为活动的内存块,并将该内存返回给操作系统。
    此算法优于前一个算法,因为“对象具有零引用”导致此对象无法访问。正如我们在周期中看到的那样,情况正好相反。

截至2012年,所有现代浏览器都提供了标记 - 清除垃圾收集器。在过去几年中,在JavaScript垃圾收集(生成/增量/并发/并行垃圾收集)领域所做的所有改进都是该算法的实现改进(标记和清除),不仅不是对垃圾收集算法本身的改进,也不是判断一个对象是否可及作为目标。

本文中,您可以更详细地阅读跟踪垃圾收集,其中还包括标记和清除及其优化。

循环引用不再是问题

在上面的第一个示例中,在函数调用返回之后,两个对象不再被从全局对象可到达的内容引用。因此,垃圾收集器将无法访问它们。

尽管对象之间存在引用,但它们无法从根目录访问。

垃圾收集器的反常行为

虽然垃圾收集器很方便,但它们有自己的权衡取舍。其中之一是非决定论。换句话说,GC是不可预测的。您无法确定何时会执行收集。这意味着在某些情况下,程序会使用更多实际需要的内存。在其他情况下,在特别敏感的应用中,短暂停顿可能会很明显。尽管非确定性意味着无法确定何时执行集合,但大多数GC的实现都是在分配期间执行集合过程这种常见模式。如果没有执行分配,则大多数GC保持空闲。请考虑以下情形:

  1. 执行大量分配。
  2. 大多数这些元素(或所有元素)都被标记为无法访问(假设我们将指向我们不再需要的缓存的引用置空,设置为null)。
  3. 没有进一步的分配。
    在这种情况下,大多数GC不会再运行任何收集过程。换句话说,即使有可用于收集的无法访问的引用,收集器也不会声明这些引用。这些并非严格泄漏,但仍导致高于平常的内存使用率。

什么是内存泄漏?

就像内存所暗示的那样,内存泄漏是应用程序过去使用但不再需要但尚未返回操作系统或可用内存池的内存块。

编程语言支持不同的内存管理方式。但是,是否使用某段内存实际上是一个不可判定的问题。换句话说,只有开发人员才能明确是否可以将一块内存返回给操作系统。
某些编程语言提供的功能可帮助开发人员实现此目的, 其他人希望开发人员完全明确何时未使用内存。维基百科有关于手动自动内存管理的好文章。

四种类型的常见JavaScript内存泄漏

1.全局变量

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或者在完成之后重新赋值。

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对象可能会被替换或删除,这会使得间隔处理程序封装的块变得冗余。如果发生这种情况,则不需要收集处理程序及其依赖关系,因为interval需要先停止(请记住,它仍然处于活动状态)。这一切归结为serverData确实存储和处理负载数据的事实也不会被收集。

当使用observers时,你需要确保你做了一个明确的调用,在完成它们之后将其删除(不再需要观察者,否则对象将无法访问)。

幸运的是,大多数现代浏览器都会为您完成这项工作:即使您忘记删除侦听器,一旦观察到的对象变得无法访问,他们会自动收集观察者处理程序。在过去,一些浏览器无法处理这些情况(旧版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(其他库和框架也支持这一点),您也可以在节点过时之前删除侦听器。 即使应用程序在较旧的浏览器版本下运行,该库也会确保没有内存泄漏。

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变量保持。需要记住的是,当一个闭包的作用域被创建,同属父范围内的闭包的作用域会被共享。

在这种情况下,闭包someMethod创建的作用域将与闭包unused的作用域共享。unused引用了originalThing,尽管代码中unused从未被调用过,但是我们还是可以在replaceThing函数外通过theThing来调用someMethod。由于someMethod与unused的闭包作用域共享,闭包unused的引用了originalThing,强制它保持活动状态(两个闭包之间的共享作用域)。这阻止了它被垃圾回收。

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

闭包会保留一个指向其作用域的指针,作用域就是闭包父函数,所以闭包unused和someMethod都会有一个指针指向replaceThing函数,这也是为什么闭包可以访问外部函数的变量。由于闭包unused引用了originalThing变量,这使得originalThing变量存在于lexical environment,replaceThing函数里面定义的所有的闭包都会有一个对originalThing的引用,所以闭包someMethod自然会保持一个对originalThing的引用,所以就算theThing替换成其它值,它的上一次值不会被回收。

所有这些都可能导致相当大的内存泄漏。当上面的代码片段一遍又一遍地运行时,您可能会发现内存使用量激增。当垃圾收集器运行时,其大小不会缩小。创建了一个闭包的链表(在这种情况下,它的根就是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() {
    // image元素是body的子元素
    document.body.removeChild(document.getElementById('image'));
    // 这时我们还有一个对 #image 的引用,这个引用在elements对象中
    // 换句话说,image元素还在内存中,不能被GC回收
}

涉及DOM树内的内部节点或叶节点时,还有一个额外需要考虑的问题。如果在代码中保留对表格单元格(一个<td>标签)的引用,并决定从DOM中删除该表格并保留对该特定单元格的引用,则可以预期会出现严重的内存泄漏。你可能会认为垃圾回收器会释放该这个单元格外的所有内容。然而,情况并非如此。由于单元格是表格的子节点,并且子节点保持对父节点的引用,因此对表格单元格的这种单引用将使整个表格保留在内存中,不能被GC回收。

后续文档翻译会陆续跟进!!

欢迎关注玄说前端公众号,后续将推出系列文章《一个大型图形化应用0到1的过程》,此账户也将同步更新