皇上,还记得我吗?我就是1999年那个Linux伊甸园啊-----24小时滚动更新开源资讯,全年无休!

解读 JavaScript 之内存管理和常见内存泄露处理

几周前,我们开始了一系列旨在深入挖掘 JavaScript 及其工作原理的研究:我们认为,通过了解 JavaScript 的构建块及其组合方式,你将可以更好的编写代码和应用程序。 该系列研究的第一篇幅重点介绍了 引擎、运行时和调用堆栈的概述 。第二篇幅则 深入测试研究了谷歌的 V8 JavaScript 引擎的内部零件,也提供了一些关于如何更好编写 JavaScript 代码的技巧 。 在该研究的第三篇,我们将讨论的另一个重要的话题:内存管理。现如今编程语言日益成熟和复杂,导致内存管理日渐被开发者所忽略,但不得不承认内存管理是编程语言日常使用的基本技术。我们会提供一些关于如何在 JavaScript 处理内存泄漏的方式,比如,在 SessionStack 中需要确保 SessionStack 不会造成内存泄漏或在 Web 中不增加带有内存消耗的集成应用程序。

概述

类似 C 这样的语言有着诸如 malloc() 和 free() 这种低级内存管理原语。开发者用这些原语明确的从操作系统中分配和释放内存。 与此同时呢,在 Javascript 中,在有东西(对象,字符串等等)被创建时分配内存,当这些东西不再使用时就有一个叫垃圾回收的程序来“自动”释放掉。这种看起来很自然的“自动”释放资源的行为,其实是让人混乱的源头,因为它给 JavaScript(以及其他的高级语言)开发者一种错误的感觉,那就是他们可以不用关心内存管理相关的事。 这是一个很大的错误。 甚至在使用高级语言的时候,开发者也应该对内存管理有所了解(至少知道一些基本的东西)。有的时候,自动内存管理是有些问题的(比如垃圾回收器中存在 bug 或只实现了有限的功能等)。开发者应该了解这些东西以便于能够采取合适的处理方式(或找到一个合适的代价最小的解决方案)。

内存生命周期

无论你使用什么编程语言,内存生命周期几乎都是一样的:  以下是对循环中每个步骤发生的情况的概述:

  • 分配内存 –  内存由允许程序使用它的操作系统分配。 在低级语言(如 C 语言)中,这是一个开发人员应该掌握的明确操作。 然而,在高级语言中,这是为你服务的。
  • 使用内存 –  这是你的程序实际上使用之前分配的内存的时间。 读取和写入操作正在你的代码中使用分配的变量。
  • 释放内存 –  现在是释放你不需要的整个内存的时间,以便它可以变成空闲的并且可以再次使用。 与分配内存操作一样,这个操作在低级语言中是明确的。

有关调用堆栈和内存堆的概念的快速概述,可以阅读我们 关于主题的第一篇文章

什么是内存?

在直奔 JavaScript 内存之前,我们将会简要的讨论一下什么是内存,以及它是如何工作的。 在硬件层面,计算机内存是由大量的 触发器 组成的。每一个触发器都包含有一些晶体管,能够存储 1 比特。单个触发器可通过一个唯一标识符来寻址,这样我们就可以读和写了。因此从概念上讲,我们可以把计算机内存看作是一个巨大的比特数组,我们可以对它进行读和写。 但是作为人类,我们并不善于用比特来思考和运算,因此我们将其组成更大些的分组,这样我们就可以用来表示数字。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 字节。

这就是它怎样工作于当前的 integers 和 doubles 型的大小。约 20 年前,integers 通常(占用)2 字节,double 占 4 字节。你的代码不应该依赖于此时刻的基本数据类型的大小。

编译器将插入些会互相作用于操作系统在堆栈上去请求必要的字节数来存储变量代码。 在以上例子中,编译器知道每个变量精确的内存地址。事实上,无论我们何时写入变量 n,而本质上这会被翻译为如“内存地址 4127963 ”。 注意:如我们尝试去访问 accessx[4],我们将访问与 m 相关的数据。这是因为我们访问数组中的一个元素并不存在– 它比数组中的最后一个被分配的元素 x[3] 多 4 个字节(简述:越界),并且最终可能读取(或覆盖)m 中的一些字节。这几乎可以肯定对程序的后续部分产生不良的后果。  当函数调用其它函数时,每个函数都会分到自己的堆栈块。它保存着所有的本地变量,而且还有一个程序计数器,它记录着程序执行的位置。当函数执行完毕后,其内存块可再次用于其他用途。

动态分配

不幸的是,当我们不知道在编译时一个变量需要多少内存的时候,事情就不是那么简单了。假定我们打算做下面的事情:

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

在编译时,编译器并不知道数组需要多大的内存,因为它的大小是由用户提供的值来决定的。 因此,不能够从栈上来分配空间。我们的程序在运行时需要显示的向操作系统申请正确的内存空间。这段内存是从 堆区 分配的。静态和动态内存分配的不同点总结起来如下表:   静态和动态内存分配的不同  要完全理解动态内存分配是如何工作的,我们需要在指针上花更多的时间,这个偏离本文主题有点多。如果你有兴趣了解更多,就在评论中告诉我,我会在将来的文章中深入探讨指针的细节。

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 对象更广泛的范围,并且还包含函数作用域(或全局词法作用域)。

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

基于引用计数的垃圾回收

这是最简单的内存回收算法。一个对象被认为是垃圾回收的条件是指向它的引用计数为 0。 看看下面的代码:

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. 根节点:通常,根节点是代码中引用的全局变量。例如,在 JavaScript 中,可以充当根节点的全局变量是“window”对象。 Node.js 中的相同对象称为“global”。所有根节点的完整列表由垃圾收集器构建。
  2. 算法检查所有根节点和他们的子节点并且标记他们是活跃的(意味着他们不是垃圾)。任何根节点不能访问的子节点将被标记为垃圾。
  3. 最后,垃圾回收器释放所有未标记为活动的内存块,并将该内存返回给操作系统。

 标记和扫描算法的可视化图  这个算法比前一个算法更好,因为“一个对象有零引用”的算法将导致这个对象无法访问。正如我们已经看到循环引用一样,情况正好相反。 截至 2012 年,所有现代浏览器都发布了标记式的垃圾收集器。 JavaScript 垃圾收集(代码/增量/并发/并行垃圾收集)领域中所做的所有改进都是对这种算法(标记和扫描算法)的实现和改进,而不是对垃圾收集算法本身的改进,也不是对象是否可以访问的算法机制改进。 在本文中,你可以详细阅读有关垃圾回收跟踪机制的更多详细信息,这些垃圾回收也涵盖了标记和扫描算法及其优化。

循环引用不再是问题

在上面的第一个例子中,在函数调用返回之后,有两个不再被全局对象访问到的引用对象。因此,它们将被垃圾收集器发现并无法访问。  即使在对象之间有引用,它们也不能从根目录访问。

垃圾收集器的反直观行为

尽管垃圾收集器很方便,但它们也有自己的一套权衡机制。其中之一是非决定论(non-determinism)。换句话说,GC 是不可预测的。你不能真正知道什么时候收集。这意味着在某些情况下,程序会使用比实际需要更多的内存。在其他情况下,在特别敏感的应用程序中,可能还会出现明显的短暂暂停。尽管非确定性意味着收集器不能确定何时执行,但大多数 GC 在分配期间共享垃圾回收的通用模式。如果没有执行分配,大多数 GC 保持空闲状态。考虑以下情况:

  1. 大量分配并执行回收。
  2. 大多数这些元素(或所有这些元素)被标记为无法访问(假设我们将一个引用指向我们不再需要的缓存)。
  3. 没有进一步的分配执行回收。

在这些情况下,大多数 GC 不会运行下一步的垃圾回收。换句话说,即使有不可用的引用可用于收集,也不会主动要求回收。这并不是严格意义上的泄漏,但仍会导致内存使用率高于平时。

什么是内存泄漏?

正如内存所显示的那样,内存泄漏是应用程序过去使用但不再需要的内存片段,且尚未返回到操作系统或可用内存池。   编程语言偏爱不同的方式来管理内存。然而,某一段内存是否被使用实际上是一个 不可预判的问题 。用另一种话来说,只有开发者可以清楚一块内存是否可以被返回给操作系统。 某些编程语言提供了帮助开发人员这样做的功能,他人则希望开发人员能够完全清楚一段内存何时未被使用。 维基百科有很好的关于 manual(手册)和 自动内存管理 的文章。

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

你可以通过增加 ‘use strict’ 来避免它;在你 javascript 文件开始前开启一个更严格的模式解析 javascript 来防止意外创建全局变量。

意外的全局变量当然是一个问题。然而,通常情况下,你的代码会受到显式的全局变量的影响,这些全局变量根据定义不能被垃圾收集器所收集的。需要特别注意用于临时存储和处理大量信息的全局变量。如果您必须使用全局变量来存储数据,那么请确保将其 分配为空值 ,或者在完成后 重新分配数据

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 变量(theThing 被前边的 replaceThing 调用)。需要记住的是 一旦一个闭包作用域被同一个父作用域的闭包所创建,那么这个作用域是共享的。 在这个例子中,这个闭包 someMethod 创建的作用域是跟 unused 共享的,unused 引用了 originalThing 。尽管没有用到 unused ,通过 replaceThing 作用于之外的 theThing 用到了 someMethod (例如全局)。由于 someMethod 和 unused 共享闭包作用域,引用 unused 却强制让 originalThing 保持激活状态(两个闭包共享整个作用域)。这阻止了它的回收。 在上边这个例子中,这个闭包 someMethod 创建的作用域是跟 unused 共享的,unused 引用了 originalThing,通过 replaceThing 作用于之外的 theThing 用到了 someMethod ,尽管没有使用 unused 。事实上 unused 引用了 originalThing , 只要 someMethod 和 unused 共享了闭包作用域,它就一直处于激活状态。 所有这些都可能导致严重的内存泄漏。当上面的代码片段一次又一次地运行时,你可以看到内存使用量的急剧增加。当垃圾收集器运行时,也不会减少。一个链接列表闭包被创建(在这种情况下 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 中删除该表格,并保留对该特定单元格的引用,则可能会出现严重的内存泄漏。你可能会认为垃圾回收器会释放除了那个单元之外的所有东西。但情况并非如此。由于单元格是表格的子节点,并且子节点保持对父节点的引用,所以 对表格单元格的这种单引用将会把整个表格保留在内存中。

我们在  SessionStack 中尝试遵循这些最佳实践,以编写可正确处理内存分配的代码,原因如下: 一旦将 SessionStack 集成到你的产品级的 Web 应用程序中,它就会开始记录所有的东西:所有的 DOM 更改、用户交互、JavaScript 异常、堆栈跟踪、网络请求失败、调试消息等。 通过 SessionStack ,你可以以视频的方式重现问题,并查看发生在用户身上的所有事情。所有这些都必须在对你的网络应用程序的性能没有任何影响的情况下进行的。 由于用户可以重新加载页面或导航你的应用程序,所有的观察者、拦截器、变量分配等都必须正确处理,所以它们不会导致任何内存泄漏,或者不会增加我们所集成的 Web 应用程序的内存开销。 这有一个免费的方案,所以你可以 试试看 。 

资源

转自 https://www.oschina.net/translate/how-does-javascript-actually-work-part-3
分享到:更多 ()