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

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

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

概述

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

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

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

内存生命周期

无论您使用什么编程语言,内存生命周期几乎都是一样的:

v8_memory_life_cycle

以下简单描述了在该周期的每个步骤中发生的情况:

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

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

什么是内存?

在开始讨论JavaScript的内存之前,我们将简要讨论一般内存概念以及它如何工作。

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

从人类角度来说,我们不擅长用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回收。

阅读 1.2k

推荐阅读
xupea
用户专栏

专注前端开发技术,Scrum敏捷开发和STEAM教育

22 人关注
19 篇文章
专栏主页