JavaScript工做原理(三):内存管理和4种常见的内存泄漏

该系列的第一篇文章重点介绍了引擎,运行时和调用堆栈的概述。第二篇文章深刻剖析了Google的V8 JavaScript引擎,并提供了关于如何编写更好的JavaScript代码的一些提示。算法

在第三篇文章中,咱们将讨论另外一个愈来愈被开发人员忽视的关键主题,由于平常使用的编程语言(内存管理)愈来愈成熟和复杂。咱们还会提供一些关于如何处理内存泄漏的技巧。编程

概述

像C这样的编程语言,提供从底层上管理内存的方法,如malloc()和free()。开发人员使用这些方法,用来从操做系统分配内存,或释放内存到操做系统中。segmentfault

当对象或字符串等被建立时,JavaScript会申请和分配内存;当对象或字符不在被使用时,它们就会被自动释放,这也被称为垃圾处理。这种释放资源的看似是“自动”的,这偏偏是误解的来源,给JavaScript(以及其余高级语言)开发人员形成了他们可能选择不关心内存管理的错误印象。这是一个大错误。数组

即便使用高级语言,开发人员也应该理解内存管理。有时自动内存管理也会出现问题(如bugs或者垃圾回收限制等),开发人员不得不先了解它们,而后才能妥善处理。浏览器

内存生命周期

不管您使用什么编程语言,内存生命周期几乎都是同样的:session

v8_memory_life_cycle

如下简单描述了在该周期的每一个步骤中发生的状况:闭包

  • 分配内存 - 内存由操做系统分配,容许程序使用它。在底层语言(如C)中,这是一个显式操做,您做为开发人员应该处理。然而,在高级语言中,这个操做被隐藏了。
  • 使用内存 - 这是您的程序实际使用以前分配的内存。读取和写入操做发生在您在代码中使用分配的变量时。
  • 释放内存 - 如今是释放您不须要的整个内存的时间,以便它能够变为空闲并再次可用。 与分配内存操做同样,这个操做在底层语言中是能够直接调用的。

有关调用堆栈和内存堆的概念的概述,您能够阅读本系列第一篇文章。框架

什么是内存?

在开始讨论JavaScript的内存以前,咱们将简要讨论通常内存概念以及它如何工做。编程语言

在硬件级别上,计算机内存由大量的触发器。每一个触发器都包含一些晶体管而且可以存储一个bit。单个触发器可经过惟一标识符进行寻址,所以咱们能够读取并覆盖它们。所以,从概念上讲,咱们能够将整个计算机内存看做是咱们能够读写的bit数组。ide

从人类角度来讲,咱们不擅长用bit来完成咱们现实中思想和算法,咱们把它们组织成更大的部分,它们一块儿能够用来表示数字。 8位(比特位)称为1个字节(byte)。除字节外,还有单词(word)(有时是16,有时是32位)。

不少东西都存储在这个内存中:

  • 全部程序使用的全部变量和其余数据。
  • 程序的代码,包括操做系统的代码。

编译器和操做系统一块儿工做,为您处理大部份内存管理,但咱们建议您看看底下发生了什么。

编译代码时,编译器能够检查原始数据类型并提早计算它们须要多少内存。而后将所需的内存分配给调用堆栈空间中的程序。分配这些变量的空间称为堆栈空间,由于随着函数的调用,它们的内存将被添加到现有内存之上。当它们终止时,它们以LIFO(后进先出)顺序被移除。例如,请考虑如下声明:

int n; // 4字节
int x [4]; // 4个元素的数组,每一个4个字节
double m; // 8个字节

编译器能够当即看到代码须要
4 + 4×4 + 8 = 28个字节。

这就是它如何处理整数和双精度的当前大小。大约20年前,整数一般是2个字节,而且是双4字节。您的代码不该该依赖于此时基本数据类型的大小。

编译器将插入与操做系统进行交互的代码,以在堆栈中请求必要的字节数,以便存储变量。

在上面的例子中,编译器知道每一个变量的确切内存地址。事实上,只要咱们写入变量n,就会在内部翻译成相似“内存地址4127963”的内容。

注意,若是咱们试图在这里访问x[4],咱们将访问与m关联的数据。这是由于咱们正在访问数组中不存在的元素 - 它比数组中最后一个实际分配的元素x [3]更远了4个字节,而且可能最终读取(或覆盖)m个位中的一些位。这对方案的其他部分几乎确定会有很是不但愿的后果。

图片描述

当函数调用其余函数时,每一个函数在调用时都会得到本身的堆栈块。它保留了它全部的局部变量,同时还有一个程序计数器,记录它在执行时的位置。当功能完成时,其存储器块再次可用于其余目的。

动态分配内存

不幸的是,当咱们在编译时有时不知道变量须要多少内存时,假设咱们想要作以下的事情:

int n = readInput(); //用户输入
...
//常见一个长度为n的数组

在编译时,编译器不知道数组须要多少内存,由于它由用户提供的值决定。

所以,它不能为堆栈上的变量分配空间。 相反,咱们的程序须要在运行时明确要求操做系统提供适当的空间。 该内存是从堆空间分配的。 下表总结了静态和动态内存分配之间的区别:

图片描述

为了充分理解动态内存分配是如何工做的,咱们须要在指针上花费更多时间,这可能与本文的主题偏离太多。 若是您有兴趣了解更多信息,请在评论中告诉咱们,咱们能够在之后的文章中详细介绍指针。

JavaScript分配内存

如今咱们将解释第一步(分配内存),以及它如何在JavaScript中工做。

JavaScript减轻了开发人员处理内存分配的责任 - JavaScript自身声明的时候就分配内存,而后赋值。

var n = 374; // 为数字分配内存
var s = 'sessionstack'; // 为字符串分配内存 
var o = {
  a: 1,
  b: null
}; // 为对象和它的值分配内存
var a = [1, null, 'str'];  // (相似对象) 为数组和它的值
                           // 分配内存
function f(a) {
  return a + 3;
} // 为函数分配内存 (which is a callable object)
// 函数表达式也会分配内存
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

一些函数调用也会致使对象分配:

var d = new Date(); // 为日期对象分配内存
var e = document.createElement('div'); // 为DOM元素分配内存

方法能够分配新的值或对象:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string
// 因为字符串是不可改变的, 
// 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对象更普遍的范围,而且还包含函数范围(或全局词法范围)。

词法范围定义了如何在嵌套函数中解析变量名称:即便父函数已返回,内部函数也包含父函数的做用域。

4种常见的内存泄漏

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();
您能够经过添加'use strict'来避免这些问题; 在您的JavaScript文件的开始处,它将切换更严格的解析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.

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

renderer对象可能会被替换或删除,这会使得间隔处理程序封装的块变得冗余。若是发生这种状况,则不须要收集处理程序及其依赖关系,由于interval须要先中止(请记住,它仍然处于活动状态)。这一切归结为serverData确实存储和处理负载数据的事实也不会被收集。

当使用observers时,你须要确保你作了一个明确的调用,在完成它们以后将其删除(再也不须要观察者,不然对象将没法访问)。

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

一旦replaceThing函数被调用,theThing变量将被赋值为一个由很长的字符串和一个新闭包(someMethod)组成的新对象。originalThing变量被一个闭包引用,这个闭包由unused变量保持。须要记住的是,当一个闭包的做用域被建立,同属父范围内的闭包的做用域会被共享。

在这种状况下,闭包someMethod建立的做用域将与闭包unused的做用域共享。unused引用了originalThing,尽管代码中unused从未被调用过,可是咱们仍是能够在replaceThing函数外经过theThing来调用someMethod。因为someMethod与unused的闭包做用域共享,闭包unused的引用了originalThing,强制它保持活动状态(两个闭包之间的共享做用域)。这阻止了它被垃圾回收。

在上面的例子中,闭包someMethod建立的做用域与闭包unused做用域的共享,而unused的引用originalThing。尽管闭包unused从未被使用,someMethod仍是能够经过theThing,从replaceThing范围外被调用。事实上,闭包unused引用了originalThing要求它保持活动,由于someMethod与unused的做用域共享。

闭包会保留一个指向其做用域的指针,做用域就是闭包父函数,因此闭包unused和someMethod都会有一个指针指向replaceThing函数,这也是为何闭包能够访问外部函数的变量。因为闭包unused引用了originalThing变量,这使得originalThing变量存在于lexical environment,replaceThing函数里面定义的全部的闭包都会有一个对originalThing的引用,因此闭包someMethod天然会保持一个对originalThing的引用,因此就算theThing替换成其它值,它的上一次值不会被回收。

全部这些均可能致使至关大的内存泄漏。当上面的代码片断一遍又一遍地运行时,您可能会发现内存使用量激增。当垃圾收集器运行时,其大小不会缩小。建立了一个闭包的链表(在这种状况下,它的根就是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() {
    // image元素是body的子元素
    document.body.removeChild(document.getElementById('image'));
    // 这时咱们还有一个对 #image 的引用,这个引用在elements对象中
    // 换句话说,image元素还在内存中,不能被GC回收
}

涉及DOM树内的内部节点或叶节点时,还有一个额外须要考虑的问题。若是在代码中保留对表格单元格(<td>标记)的引用,并决定从DOM中删除该表格并保留对该特定单元格的引用,则能够预期会出现严重的内存泄漏。你可能会认为垃圾回收器会释放该这个单元格外的全部内容。然而,状况并不是如此。因为单元格是表格的子节点,而且子节点保持对父节点的引用,所以对表格单元格的这种单引用将使整个表格保留在内存中,不能被GC回收。

相关文章
相关标签/搜索