javascript是如何工做的:03-内存管理和如何处理4种常见的内存泄漏

概述

像C语言这种具备底层内存管理的原始语言,例如malloc()free()。开发人员使用这些原始语言明确地给操做系统分配和释放内存。javascript

同时,JavaScript 在建立事物(对象,字符串,等等)的时候分配内存,而且在再也不使用的时候“自动”释放内存,这是一个垃圾回收的过程。释放资源的这种看起来“自动”的特性是混乱的来源,给 JavaScript (和其余高级语言)开发者一种他们能够再也不关心内存管理的错误印象。这是一个大错误java

即便在使用高级语言工做的时候,开发者应该理解内存管理(或者最少是基本知识)。有时,自动内存管理存在问题(例如垃圾回收中的bug或者实现限制,等),开发人员必须了解这些问题才能正确处理它们(或者找到适当的解决办法,同时尽可能减小成本和代码负担)。git

内存生命周期

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

如下是对周期中的每一步发生的状况概述:算法

  • 分配内存 - 内存是由操做系统分配的,操做系统容许你的程序使用它。在底层级别语言(例如C)中,这是你做为一个开发者应该处理的明确操做。然而,在高级别语言,这是你应该处理的。
  • 使用内存 - 这其实是程序使用以前分配的内存的时候。操做在代码中分配内存的时候发生。
  • 释放内存 - 如今是时候释放你再也不须要的整个内存了,这样它能够从新释放而且可再次使用。与分配内存同样,在低级别语言中这是明确的。

要快速了解调用栈和内存堆的概念,能够阅读这个主题的第一篇文章express

什么是内存?

在直接进入 JavaScript 内存以前,咱们将简单地讨论下什么是通常内存,以及它是如何工做的。编程

在硬件层面,计算机内存是由大量的触发器组成。
每一个触发器包含几个晶体管,而且能够存储一个比特。每一个触发器经过一个惟一的标识符寻址,所以咱们能够读取和复写它们。所以,从概念上讲,咱们能够认为整个计算机的内存是一组比特数组,咱们能够读写他们。数组

可是做为人类,咱们并不擅长使用比特来完成咱们全部的思考和算数,咱们将它们组成更大的组,它们能够一块儿来表示数字。8比特被称为1字节。除了字节,还有单词(有时是16,有时是32位)。浏览器

内存中存储大量的东西:缓存

  1. 全部的变量和全部程序使用的数据。
  2. 编程的代码,包括操做系统的。

编译器和操做系统一块儿工做,为你处理大量的内存管理,可是咱们建议你查看一下背后发生的事情。

当你编译你的代码的时候,编译器能够检查原始数据类型,而且提早计算它们须要多少内存。而后在调用堆栈空间中将须要的内存分配给程序。分配这些变量的空间叫作堆栈空间,由于在调用函数的时候,他们的内存会添加到现有的内存之上。一旦他们停止,它们将以LIFO(后入先出)的顺序移除。例如,考虑下面的声明:

init n; // 4bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

编译器能够立马看到这些代码须要多大内存

4 + 4 * 4 + 8 = 28 bytes.
这就是它如何处理当前整型和double的大小。大概20年前,整型一般是2bytes,和4字节。你的代码不该该依赖当前基本数据类型的大小。

编译器将插入与操做系统交互的代码,它须要请求当前栈上所需存储变量的大小的字节数。

在上面的例子中,编译器确切地知道每一个变量的内存地址。实际上,无论你何时书写变量n,这将会在内部被翻译成相似于“内存地址 4127963”。

注意到,若是咱们试图在这里访问x[4],咱们将须要访问与之关联的m。这是由于咱们访问了数组上不存在的元素 - 它比实际上分配给数组的最后一个元素x[3]多了4字节,而且可能将会最终读取(或者复写)一些m的比特。这将对剩下的程序产生很是不指望的后果。

当一个函数调用另外一个函数时,每一个函数在调用堆栈时都会获得本身的堆栈块。它将其全部的本地变量都保存在这里,而且有一个程序计数器去记住执行过程当中的位置。当这个函数调用完成,它的内存块将会用于其余用途。

动态分配

不幸的是,当咱们不知道一个变量在编译的时候须要多少内存,事情就变得不简单了,假如咱们要作下面的事情:

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

这里,在编译的时候,编译器不知道这个数组须要读书内存,由于它是由user提供的值决定的。

所以,它不能为堆栈上的变量分配空间。相反,咱们的程序须要在运行的时候向操做系统申请适当的空间。这个内存是从堆空间分配的。静态和动态分配内存的不一样能够总结为以下的表格:

要彻底理解动态内存分配是如何工做的,咱们须要花费一些时间在指针上,这可能有点偏离了本文的主体。若是你有兴趣了解更多,请在评论区让我知道,咱们能够在之后的文章中再去深刻了解这些细节。

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

标记扫描算法

为了肯定对象是否须要,这个算法决定对象是否可访问。

标记扫描算法须要通过这3步:

  1. Roots:一般,roots是代码中引用的全局变量。例如在 JavaScript 中,一个能够充当root的全局变量是"window"对象。Node.js中相同的对象是“global”。垃圾回收生成全部roots完整的列表。
  2. 算法将会检查roots的全部变量已经他们的children而且标记为活动(意思是,他们不是垃圾)。root访问不到的东西将会被标记为垃圾。
  3. 最后,垃圾回收将会释放没有被标记为活动的内存片断,而且将内存返回给操做系统。

这个算法比以前那个好,由于“一个对象零引用”致使这个对象不可访问。相反的状况并不像咱们看到的循环引用那样。

截止到2012年,全部的现代浏览器都实现了一个标记扫描垃圾回收。过去几年在JavaScript垃圾回收领域所作的全部优化都是对该算法(标记扫描)的改进实现,而不是优化垃圾回收算法自己,也不是一个对象是否可访问的目标。

在这篇文章,你能够读到关于追踪垃圾回收很是详细的介绍,而且包括标记扫描的优化。

循环再也不是问题

在上面的第一个例子中,当函数调用返回后,两个对象再也不被全局对象的任何东西访问。所以,垃圾回收将会找到没法访问的他们。

即便在两个对象之间有引用,可是从root开始再也没法访问。

垃圾回收器的反直觉行为

虽然垃圾回收器很是方便有着本身的一套推导方案。其中之一是非决定的。换句话说,CGs是不可预测的。你没法真正判断什么时候执行回收。这意味着在某些状况下,程序会使用比它实际须要的更多的内存。在其余状况下,在一些很是敏感的应用程序中短暂停顿是很是明显的。虽然非肯定性意味着不能肯定何时执行回收,大多数GC在分配内存期间实现执行集合传递的通用模式。若是不执行分配,大多数GCs处于空闲状态。考虑如下状况:

  1. 执行一组至关大的分配。
  2. 大多数这些元素(或者全部)都被标记为没法访问(假设咱们将再也不须要的引用指向缓存)。
  3. 没有更多的执行分配。

在这种状况下,大多数GCs将不会进一步执行回收。换句话说,对于收集器就算有不可访问的变量引用,收集器也不会清理这些。这些严格意义上来讲不是引用,可是会致使比一般更高的内存。

什么是内存泄漏?

正如内存表示,内存泄漏是应用程序过去使用,可是将来再也不须要的内存片断,而且没有返回给操做系统或者空闲内存池。

编程语言倾向于不一样的内存管理方式。然而,肯定一个内存片断使用与否是一个难以肯定的问题。换句话说,只有开发者能够清楚地肯定一个内存片断是否能够返回给操做系统。

某些编程语言提供了帮助开发者实现这个的功能。其余则指望开发者清楚的知道一段内存未被使用。维基百科对于内存管理的手动自动有着很是好的文章。

4种常见的 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: 被遗忘的定时器和回调

咱们使用常常在 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对象能够在某个点被替换或者删除,这使得使用间隔快处理变得冗余。若是这种状况发生,无论是处理器,仍是依赖都须要被回收,首先须要中止间隔(记住,它仍然是活动的)。归根结底,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保持活动(两个闭包之间共享整个做用域)。这就阻止了回收。

在上面的例子中,为闭包someMethod建立的做用域与unused是共享的,同时unused引用originalThingsomeMethod能够经过replaceThing外部的做用域theThing来使用,尽管实际上unused从未被使用。实际上未使用的引用originalThing须要保持active状态,由于someMethodunused共享做用域。

全部的这些会致使至关大的内存泄漏。当上面的代码一遍又一遍的运行时,你可能会看到内存使用量暴增。当垃圾回收运行的时候它的大小不会缩小。一个有序的闭包列表被建立(在本例中root是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.
    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树中的内部或者子节点的引用问题。若是在代码中保留着对单元格(td标签)的引用,而且决定从DOM中删除这个表格,但须要保留对特定单元格的引用,则可能会致使大的内存泄漏。你可能会认为垃圾回收会释放除了那个单元格之外的任何东西。然而实际上并不是如此。因为单元格是表格的一个子节点,而且子节点保留对父节点的引用,这个对表格单元格的单个引用将整个表格保持在内存中

相关文章
相关标签/搜索