阅读 406

[译]JavaScript 是怎么工作的:内存管理及怎么处理四种常见的内存泄露

几个星期前,我们开始了一个系列,旨在更深入地研究 JavaScript 及其实际工作原理:我们认为,通过了解 JavaScript 的构建块以及它们如何协同工作,您将能够编写更好的代码和应用程序。

本系列的第一篇文章重点介绍了引擎、运行时和调用堆栈的概述。第二篇文章仔细研究了谷歌的 V8 JavaScript 引擎的内部部分,也提供了一些建议关于如何编写更好的JavaScript代码。

在这第三篇文章中,我们将讨论另一个重要的主题——内存管理,由于日常使用的编程语言的日益成熟和复杂性,这个主题越来越被开发人员忽视。我们还将提供一些关于如何在 SessionStack中处理 JavaScript 中的内存泄漏的技巧,因为我们需要确保 SessionStack 不会导致内存泄漏或不会增加集成在其中的 web 应用程序的内存消耗。

概述

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

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

即使在使用高级语言时,开发人员也应该了解内存管理(或至少了解基本知识)。有时,自动内存管理会出现一些问题(比如说出现了 bug 或者垃圾收集器中的实现限制等),开发人员必须了解这些问题才能正确地处理它们(或者找到一种适当的替代方案,以实现最小的折中和代码改动)。

内存的生命周期

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

以下是在循环的每一步所发生的事情的概述:

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

  • 使用内存——这是程序实际使用之前分配的内存的时间。当您在代码中使用分配的变量时,将执行读写操作。

  • 释放内存——现在是时候释放您不需要的整个内存了,这样它就可以再次变得空闲和可用。与分配内存操作一样,这个操作在低级语言中是显式的。

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

内存是什么?

在直接跳到 JavaScript 中的内存之前,我们将简要地讨论通常意义的内存是什么以及它是如何工作的。

在硬件层面上,计算机内存由大量的触发器组成。每个触发器包含几个晶体管,能够存储一比特。单个触发器可由唯一标识符寻址,因此我们可以读取或者覆盖它们。因此,从概念上讲,我们可以把整个计算机内存看作是一个可以读写的巨大位数组。

因为作为人类,我们并不擅长把所有的思考和算术都以比特的形式表现出来,所以我们把它们组织成更大的群体,这些群体可以用来表示数字。8位称为1字节。除了字节之外,还有单词(有时是16位,有时是32位)。

很多东西都储存在内存里:

  1. 所有程序使用的所有变量和其他数据。

  2. 程序的代码,包括操作系统的代码。

编译器和操作系统一起工作,为您处理大部分内存管理工作,但是我们建议您查看一下底层到底发生了什么。

在编译代码时,编译器可以检查基本数据类型并提前计算它们需要多少内存。然后将所需的数量分配给调用堆栈空间中的程序。分配这些变量的空间称为堆栈空间,因为在调用函数时,它们的内存将添加到现有内存之上。当它们终止时,将按照后进先出(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 的一些位。对程序的其余部分这几乎肯定会产生非常不希望得到的结果。

当函数调用其他函数时,每个函数在调用时都得到自己的堆栈块。它保留了所有的局部变量,但也有一个程序计数器,它可以记住它的执行位置。当函数完成时,它的内存块再次可用作其他用途。

动态分配

不幸的是,当我们在编译时不知道一个变量需要多少内存时,事情就不那么容易了。假设我们想做如下事情:

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

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

因此,它不能为堆栈上的变量分配空间。相反,我们的程序需要在运行时显式地向操作系统请求适当的空间。这个内存是从堆空间分配的。静态和动态内存分配的区别如下表所示:

要完全理解动态内存分配是如何工作的,我们需要花更多的时间在指针上,这可能与本文的主题有一点偏离。如果你有兴趣了解更多,请在评论中告诉我,我们可以在以后的文章中讨论指针的更多细节。

JavaScript中的内存分配

现在,我们将解释第一步(分配内存)如何在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
复制代码

一些方法也可以分配新的值或者对象:

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中使用分配的内存意味着读写它。

分配内存可以通过读取或写入变量或对象属性的值,甚至将参数传递给函数来实现。

当不再需要内存时释放

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

这里最困难的任务是确定何时不再需要被分配的内存。它通常需要开发人员确定在程序中的哪些内存块不再被需要并释放它。

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

不幸的是,这个时间是一个近似值,因为无法确定某个内存块是否被需要是一个普遍的问题(无法通过算法解决)。

大多数垃圾收集器是通过收集那些不被引用的内存来工作的,例如,所有指向这片内存的变量都已经出了作用域。但是,这样收集的内存空间集是不够的,因为在任何点上,可能任然存在一片内存地址在作用域中被一个变量引用,但是这个变量永远不会被再次访问。

垃圾收集

由于查找某些内存是否“不再需要”是不可确定的,所以垃圾收集器在实现解决这个普遍问题上受到了限制。本节将解释理解垃圾收集算法的主要概念及其局限性的重要性。

内存引用

垃圾收集算法的主要概念是需要参考的一个方面。

在内存管理上下文中,如果前面的对象可以访问后面对象(可以是隐式的,也可以是显式的),那么就说一个对象引用了另一个对象。例如,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

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();
复制代码

circle

标记清除算法

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

标记清除法通过这三个步骤:

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

2、然后,算法会检查所有根及其子节点,并将它们标记为活动的(这意味着它们不是垃圾)。不属于任何一个根的内存会被标记为垃圾。

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

mark and sweep
这个算法比之前的算法更好,因为“一个对象没有被引用”会导致这个对象不能被访问。同样,相反的情况并不像我们在循环中看到的那样。

从2012年开始,所有的现代浏览器都推出了“标记清除”垃圾收集器。过去几年,在 JavaScript 垃圾收集领域(世代/增量/并行/并行垃圾收集)所做的所有改进都是该算法(标记清除)的实现改进,但不是垃圾收集算法本身的改进,或者决定一个对象是不是可获取的这个目标的改进

在本文中,您可以更详细地了解跟踪垃圾收集,其中也包括标记清除算法及其优化。

循环引用从此不再是一个问题

在上面的第一个例子中,函数调用返回后,两个对象不再被全局对象中可访问的对象引用。因此,垃圾收集器将把他们标记为不可访问的。

即使这两个对象互相引用,它们也不能从 window 中被访问。

垃圾收集器的反直觉行为

尽管垃圾收集器很方便,但它们还是有自己的权衡。其中一个是不确定性。换句话说,垃圾收集器是不可预测的。您不能真正的分辨出垃圾回收器什么时候会被执行。这意味着在某些情况下,程序会使用比实际需要更多的内存。在其他情况下,在特别敏感的应用程序中可能会出现短暂的停顿。尽管不确定性意味着不能确定何时执行垃圾回收,但是大多数垃圾收集器的实现共享了在内存分配期间执行垃圾回收这样的公共模块。如果不执行内存分配,大多数垃圾收集器将保持空闲状态。考虑以下场景:

  1. 执行分配一组很大的内存。

  2. 这些元素中的大部分(或全部)都被标记为不可获得的(假设我们将指向我们不再需要的一片内存的引用设为 null)。

  3. 不再执行进一步的内存分配。

在这种情况下,大多数垃圾收集器将不再进行任何垃圾回收。换句话说,即使有可以被回收的不可获得的引用,也不会被收集器标记。这些并不是严格意义上的泄漏,但仍然会导致比通常更高的内存使用量。

什么是内存泄漏?

正如内存所暗示的,内存泄漏是应用程序在过去使用过但不再被需要的,但尚未返回到操作系统或空闲内存池的内存片段。

编程语言喜欢使用不同的内存管理方法。然而,是否使用某段内存实际上是一个无法确定的问题。换句话说,只有开发人员才能弄清楚一块内存是否可以返回到操作系统。

某些编程语言提供了帮助开发人员完成内存分配和回收的特性。另一些则希望开发人员能够完全清楚地知道何时有一块内存未被使用。Wikipedia有关于手动自动内存管理的好文章。

四种常见的 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" 来避免这些失误;它会切换到更严格的 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.
复制代码

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

render 对象可能在某个时候会被替换或删除,这将使由 interval 处理程序封装的块变得多余。如果发生这种情况,处理程序及其相关变量都不会被收集,因为计时器(请记住,它仍然是活跃的)需要第一时间被停止。这一切都归结于 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 函数(其他库和框架也支持此功能),您还可以在节点废弃之前删除监听器。即使应用程序在较旧的浏览器版本下运行,该库也会确保没有内存泄漏。

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的thing变量)创建的闭包引用的。需要记住的是,一旦在同一个父级作用域中为闭包创建了作用域,这个作用域就会被共享

在本例中,为闭包 someMethod 创建的作用域与 unused 共享。unused 引用 originalThing。即使 unused 从未使用,someMethod 也可以在 replaceThing 作用域之外被 theThing 使用(例如在全局的某个地方)。并且因为 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 树中的内部节点或叶节点时,还需要考虑另外一个问题。如果您在代码中保留了对表单元格的引用(标记),并决定从 DOM 中删除表,但保留了对特定单元格的引用,那么可能会出现严重的内存泄漏。您可能认为垃圾收集器会释放除那个单元格之外的所有内容。然而,事实并非如此。由于单元格是表的子节点,并且子节点保留对父节点的引用,所以对表中单元格的单个引用将把整个表保存在内存中

我们在 SessionStack试图遵循这些最佳实践来编写正确处理内存分配的代码,原因如下:

一旦您将 SessionStack 集成到您的 web 应用程序中,它将开始记录所有内容:所有DOM更改、用户交互、JavaScript异常、堆栈跟踪、失败的网络请求、调试消息等等。

使用 SessionStack,您可以将 web 应用程序中的问题以视频的形式重播,并查看发生在用户身上的所有事情。所有这些都必须在不影响 web 应用程序性能的情况下进行。

由于用户可以重新加载页面或导航应用程序,所以必须正确处理所有的观察者、拦截器、变量分配等,这样它们就不会造成任何内存泄漏,也不会增加我们集成的web应用程序的内存消耗。

参考资料

思想参考来自www-bcf.usc.edu/~dkempe/CS1…
思想参考来自 David Glasse 的blog.meteor.com/an-interest…
思想参考来自 Sebastián Peyrott 的auth0.com/blog/four-t…
概念部分来自 MDN 前端 文档developer.mozilla.org/en-US/docs/…

原文链接

关注下面的标签,发现更多相似文章
评论