[译]JavaScript是如何工做的:内存管理以及如何处理四种常见的内存泄漏

JavaScript是如何工做的:内存管理以及如何处理四种常见的内存泄漏

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

译者:neal1991java

welcome to star my articles-translator , providing you advanced articles translation. Any suggestion, please issue or contact megit

LICENSE: MITgithub

几个礼拜以前咱们开始一系列对于JavaScript以及其本质工做原理的深刻挖掘:咱们认为经过了解JavaScript的构建方式以及它们是如何共同合做的,你就可以写出更好的代码以及应用。算法

这个系列的第一篇博客专一于介绍对于引擎,运行时以及调用栈的概述(译者注:第一篇博客翻译版)。第二篇博客近距离地检测了Google V8 引擎的内部而且提供了一些如何写出更好的JavaScript代码的建议。shell

在第三篇博客中,咱们将会讨论另一个关键的话题。这个话题因为随着编程语言的逐渐成熟和复杂化,愈来愈被开发者所忽视,这个话题就是在平常工做中使用到的——内存管理。咱们还将提供一些有关如何处理咱们在SessionStack中的JavaScript中的内存泄漏的建议,由于咱们须要确保SessionStack不会致使内存泄漏或者增长咱们集成的Web应用程序的内存消耗。express

概述

语言,好比C,具备低层次的内存管理方法,好比malloc()以及free()。开发者利用这些方法精确地为操做系统分配以及释放内存。编程

同时,JavaScript会在建立一些变量(对象,字符串等等)的时候分配内存,而且会在这些不被使用以后“自动地”释放这些内存,这个过程被称为垃圾收集。这个看起来“自动化的”特性其实就是产生误解的缘由,而且给JavaScript(以及其余高层次语言)开发者一个假象,他们不须要关心内存管理。大错特错。数组

即便是使用高层次语言,开发者应该对于内存管理有必定的理解(或者最基本的理解)。有时候自动的内存管理会存在一些问题(好比一些bug或者垃圾收集器的一些限制等等),对于这些开发者必须可以理解从而可以合适地处理(或者使用最小的代价以及代码债务去绕过这个问题)。浏览器

内存生命周期

无论你在使用什么编程语言,内存的生命周期基本上都是同样的:

clipboard.png

下面是对于周期中每一步所发生的状况的概述:

  • 分配内存——操做系统为你的程序分配内存而且容许其使用。在低层次语言中(好比C),这正是开发者应该处理的操做。在高层次的语言,然而,就由语言帮你实现了。
  • 使用内存——当你的程序确实在使用以前分配的内存的阶段。当你在使用你代码里面分配的变量的时候会发生以及操做。
  • 释放内存——这个阶段就是释放你再也不须要的内存,从而这些内存被释放而且可以再次被使用。和分配内存操做同样,这在低层次的语言也是开发者须要明确的操做。

对于调用栈以及内存堆有一个快速的概念认识,你能够阅读咱们关于这个话题的第一篇博客

什么是内存?

在咱们讲述JavaScript内存以前,咱们将简要地讨论一下内存是什么以及它们是如何在 nutshell 中工做的。

在硬件层次上,计算机内存由大量的 寄存器 组成。每个寄存器都包含一些晶体管而且可以存储一比特。单独的寄存器能够经过独特的标识符去访问,所以咱们可以读取以及重写它们。所以,从概念上来讲,咱们能够认为咱们的整个计算机内存就是一个咱们可以读写的大型比特数组。

由于做为人类,咱们不擅长直接基于比特进行思考以及算术,咱们将它们组织成大规模群组,它们在一块儿能够表明一个数字。8个比特称为一个字节。除了字节,还有词(有时候是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 的比特。这将极可能给程序的其余部分带来一些不良的后果。

clipboard.png

当函数调用其它函数的时候,当它被调用的时候都会获取它本身的堆栈块。它在那保存了它全部的局部变量,可是还会有一个程序计数器记录它执行的位置。当这个函数执行完毕,它的内存块就能够再次用于其余目的。

动态分配

不幸的是,当咱们在编译的时候不知道变量须要多少内存的话事情可能就不那么简单。假设咱们想作下面的事情:

int n = readInput(); // reads input from the user

    ...

    // create an array with "n" elements

在此,在编译阶段中,编译器就没有办法知道数组须要多少内存,由于它取决于用户的输入。

所以,它就不可以为栈上的变量分配空间。相反,咱们的程序须要明确地询问操做运行时须要的空间数量。这个内存是从堆空间中分配出来的。动态内存和静态内存分配的区别总结以下表格:

clipboard.png

为了深刻地理解动态内存分配是如何工做的,咱们须要花费更多的时间在指针,这个可能有点偏离这篇博客的话题。若是你感兴趣了解更多,在评论里面告诉我,我将会在后续的博客中挖掘更多的细节。

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

clipboard.png

标记-清除算法

为了决定哪一个对象是须要的,算法会决定是否这个对象是可访问的。

这个算法由如下步骤组成:

  1. 这个垃圾收集器构建一个“roots”列表。Root是全局变量,被代码中的引用所保存。在 JavaScript中,“window”就是这样的做为root的全局变量的例子。
  2. 全部的root都会被监测而且被标志成活跃的(好比不是垃圾)。全部的子代也会递归地被监测。全部可以由root访问的一切都不会被认为是垃圾。
  3. 全部再也不被标志成活跃的内存块都被认为是垃圾。这个收集器如今就能够释放这些内存并将它们返还给操做系统。

clipboard.png

这个算法要优于以前的由于“一个具备0引用的对象”可让一个对象不可以再被访问。可是相反的却不必定成立,好比咱们遇到循环的时候。

在2012年,全部的现代浏览器都使用标记-清除垃圾收集器。过去几年,JavaScript垃圾收集(代数/增量/并行/并行垃圾收集)领域的全部改进都是对该算法(标记和扫描)的实现进行了改进,但并无对垃圾收集算法自己的改进, 其目标是肯定一个对象是否可达。

在这篇文章中,你能够获得更多关于垃圾收集追踪而且也覆盖到了关于标记-清除算法的优化。

循环再也不是一个问题

在上述的第一个例子中,在函数调用返回以后,这两个对象不可以被全局对象所访问。所以,垃圾收集器就会发现它们不可以被访问了。

clipboard.png

即便在这两个对象之间存在着引用,它们不再能从root访问了。

列举垃圾收集器的直观行为

虽然垃圾收集器很方便,但它们本身也有本身的代价。 其中一个是非肯定论。 换句话说,GC是不可预测的。 你不能真正地告诉你何时会收集。 这意味着在某些状况下,程序会使用实际须要的更多内存。 在其余状况下,特别敏感的应用程序可能会引发短暂暂停。 虽然非肯定性意味着在执行集合时没法肯定,但大多数GC实现共享在分配期间执行收集遍历的常见模式。 若是没有执行分配,大多数GC保持空闲状态。 考虑如下状况:

  1. 执行至关大的一组分配。
  2. 这些元素中的大多数(或所有)被标记为不可访问(假设咱们将指向咱们再也不须要的缓存的引用置空)。
  3. 再也不执行分配。

在这种状况下,大多数GC不会再运行收集处理。换句话说,即便存在对于收集器来讲不可访问的引用,它们也不会被收集器所认领。严格意义来讲这并非泄露,可是依然会致使比日常更多的内存使用。

什么是内存泄露?

实质上,内存泄漏能够被定义为应用程序再也不须要的内存,可是因为某些缘由不会返回到操做系统或可用内存池。

clipboard.png

编程语言有支持管理内存的不一样方法。 然而,某块内存是否被使用其实是一个不可断定的问题。 换句话说,只有开发人员能够清楚一个内存是否能够返回到操做系统。

某些编程语言提供了帮助开发者执行此操做的功能。其余的则指望开发人员可以彻底明确什么时候使用一块内存。 维基百科有关于手动自动内存管理的好文章。

四种常见的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();

为了阻止这些错误的发生,能够在js文件头部添加'use strict'。这将会使用严格模式来解析 JavaScript 从而阻止意外的全局变量。了解更多关于JavaScript执行的模式。

即便咱们讨论了未预期的全局变量,但仍然有不少代码用显式的全局变量填充。 这些定义是不可收集的(除非分配为null或从新分配)。 特别是,用于临时存储和处理大量信息的全局变量值得关注。 若是你必须使用全局变量来存储大量数据,请确保在完成以后将其分配为null或从新分配

2: 被遗忘的计时器和回调

setInterval 在 JavaScript 中是常常被使用的。

大多数提供观察者和其余模式的回调函数库都会在调用本身的实例变得没法访问以后对其任何引用也设置为不可访问。 可是在setInterval的状况下,这样的代码很常见:

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 处理器中代码变得再也不被须要。然而,这个处理器不可以被收集由于interval依然活跃的(这个interval须要被中止从而表面这种状况)。若是这个interval处理器不可以被收集,那么它的依赖也不可以被收集。这意味这存储大量数据的severData也不可以被收集。

在这种观察者的状况下,作出准确的调用从而在不须要它们的时候当即将其移除是很是重要的(或者相关的对象被置为不可访问的)。

过去,之前特别重要的是某些浏览器(好的老IE 6)没法管理好循环引用(有关更多信息,请参见下文)。 现在,大多数浏览器一旦观察到的对象变得没法访问,就能收集观察者处理器,即便侦听器没有被明确删除。 可是,在处理对象以前,明确删除这些观察者仍然是一个很好的作法。 例如:

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.

当今,如今浏览器(报错IE和Edge)都使用了现代的垃圾收集算法,其可以检测到这些循环而且进行适宜的处理。换句话说,不再是严格须要在将节点置为不可访问以前调用removeEventListener 。

框架和库(如jQuery)在处理节点以前(在为其使用特定的API时)会删除侦听器。 这是由库内部处理的,这也确保没有泄漏,即便在有问题的浏览器下运行,如...是的,IE 6。

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)。同时,unused 会保持一个指向originalThing引用的闭包(从上一个调用的theThingreplaceThing)。可能已经很迷惑了,是否是?重要的事情是一旦在相同的父级做用域为闭包产生做用域,这个做用域就会被共享

在这种状况下,为someMethod闭包产生的做用域就会被unused 所共享。unused 具备对于originaThing的引用。即便 unused 再也不被使用,someMethod依然能够经过replaceThing做用域以外的theThing来使用。而且因为somethodunused 共享闭包做用域,unused指向originalThing的引用强迫其保持活跃(两个闭包之间的整个共享做用域)。这将会阻止垃圾手机。

当这个代码段重复运行时,能够观察到内存使用量的稳定增加。 当GC运行时,这不会变小。 实质上,建立了一个关闭的连接列表(其root以TheThing变量的形式),而且这些闭包的范围中的每个都对大数组进行间接引用,致使至关大的泄漏。

这个问题由Meteor团队发现,他们有一篇很好的文章,详细描述了这个问题。

4: DOM 以外的引用

有时将DOM节点存储在数据结构中多是有用的。 假设要快速更新表中的几行内容。 存储对字典或数组中每一个DOM行的引用多是有意义的。 当发生这种状况时,会保留对同一DOM元素的两个引用:一个在DOM树中,另外一个在字典中。 若是未来某个时候您决定删除这些行,则须要使两个引用置为不可访问。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

function doStuff() {
    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树内部的内部或叶节点的引用时,必须考虑这一点。 假设你在JavaScript代码中保留对表格特定单元格(<td>标记)的引用。 有一天,你决定从DOM中删除该表,但保留对该单元格的引用。 直观地,能够假设GC将收集除了该单元格以外的全部内容。 实际上,这不会发生:该单元格是该表的子节点,而且孩子们保持对父代的引用。 也就是说,从JavaScript代码引用表格单元会致使整个表保留在内存中。 保持对DOM元素的引用时须要仔细考虑。

相关文章
相关标签/搜索