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

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

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

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

概述

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

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

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

内存的生命周期

不管你使用哪一种编程语言,内存生命周期几乎老是相同的:编程

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

  • 分配内存——内存是由操做系统分配的,并容许您的程序去使用。在低级语言(如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

标记清除算法

为了肯定一个对象是否被须要,该算法肯定对象是不是可得到的。

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

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

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

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

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.
复制代码

上面的代码片断显示了使用计时器引用再也不须要的节点或数据的结果。

渲染器对象可能会被替换或删除,这将使由interval处理程序封装的块变得多余。若是发生这种状况,处理程序及其依赖项都不会被收集,由于须要首先中止间隔(请记住,它仍然是活动的)。这一切都归结到一个事实,即服务器数据,它确定存储和处理负载的数据也不会被收集。

在使用观察者时,您须要确保在使用完它们以后进行显式调用来删除它们(要么再也不须要观察者,要么对象将变得不可到达)。

幸运的是,大多数现代浏览器均可觉得您完成这项工做:一旦观察到的对象变得没法访问,即便您忘记删除侦听器,它们也会自动收集观察者处理程序。在过去,一些浏览器没法处理这些状况(好的旧版本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) // a reference to 'originalThing'
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);
复制代码

一旦replaceThing被调用,这个东西就会获得一个新的对象,这个对象由一个大数组和一个新的闭包(someMethod)组成。然而,originalThing是由未使用的变量(即上一次调用replaceThing的thing变量)持有的闭包引用的。须要记住的是,一旦在同一个父范围中为闭包建立了范围,这个范围就会被共享

在本例中,为闭包someMethod建立的范围与未使用的共享。未使用的有对原始事物的引用。即便从未使用过,也有一些方法能够经过replaceThing范围以外的事物来使用(例如,在全球的某个地方)。因为someMethod与未使用的包共享闭包范围,未使用的引用必须保持活动(两个闭包之间的整个共享范围)。这阻止了它的收集。

在上面的示例中,为闭包someMethod建立的范围与未使用的共享,而未使用的引用原始值。有些方法能够经过replaceThing做用域以外的东西来使用,尽管事实上从未使用过。未使用的引用originalThing要求它保持活动状态,由于someMethod与未使用的共享闭包范围。

全部这些都会致使至关大的内存泄漏。当上面的代码片断反复运行时,您可能会看到内存使用量的激增。当垃圾收集器运行时,它的大小不会缩小。建立闭包的链表(在本例中,它的根是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/…

原文连接

相关文章
相关标签/搜索