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

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

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

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

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

概述

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

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

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

内存生命周期

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

内存生命循环

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

  • 分配内存——内存由容许程序使用的操做系统分配。在低级编程语言(如 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 中移除但保留对那个特别单元格的引用,就能预料到将会有大量的内存泄露。你可能认为垃圾收集器将释放其余全部的东西除了那个单元格。可是,这将不会发生。由于这个单元格是表格的一个子节点,子节点保存了对它们父节点的引用,引用这一个单元格将会在内存里保存整个表格。

相关文章
相关标签/搜索