【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

silvermouse 发布于1年前
0 条问题

几周前,我们开始了一系列旨在深入挖掘 JavaScript 及其实际工作原理(的研究):我们认为,通过了解 JavaScript 的构建块以及其集成方法,您将能够编写更好的代码和应用程​​序。

该系列的第一部分重点对于引擎,Runtime 机制和堆栈调用进行一个概述。

第二部分仔细检视了 Google V8 JavaScript 引擎的内部,并提供了一些有关如何编写更好的 JavaScript 代码的提示。

在第三部分中,我们将讨论开发人员越来越忽视的另一个关键主题,由于日常使用的编程语言的日益成熟和复杂而产生的 - 内存管理。我们还将提供一些有关如何处理 SessionStack 中 JavaScript 内存泄漏的方法,因为我们需要确保 SessionStack 不会导致内存泄漏,也不会增加我们集成的 Web 应用程序的内存消耗。

Overview

一些计算机语言 (如 C) 具有低级内存管理基元, 如 malloc() 和 free() 。开发人员使用这些基元显式分配和释放操作系统的内存。

JavaScript 在创建事物(对象,字符串等)时分配内存,并且在不再使用时 “自动” 释放它们,这个过程被称为垃圾收集。 释放资源的这种看似“自动”的性质是一个混乱的根源,给了JavaScript(和其他高级语言)开发人员错误的印象:即他们可以选择不关心内存管理。这是一个极大的错误。

即便使用高级语言,开发人员也应该对内存管理(或至少内存管理的基本知识)有所了解。 有时,开发人员必须理解自动内存管理(例如垃圾收集器中的错误或实现限制等)中遇到的问题,以便正确处理它们(或找到适当的、存在最少量的利弊权衡和代码 “坑” 的解决方法)。

内存生命周期

任何编程语言,内存的生命周期几乎总是相同的:

【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

以下是对周期每个步骤发生的情况的概述:

  • 分配内存 - 内存由操作系统分配,允许程序使用它。在低级语言(例如C)中,这是您作为开发人员处理的显式操作。 然而,在高级语言中,这是被自动完成的。
  • 使用内存 - 这是你的程序实际使用早前分配的内存的时间。你在代码中使用分配的变量时,读写操作正在进行。
  • 释放内存 - 现在是释放您不需要的整个内存的时间,以便它可以再次可用并可用。与分配内存操作一样,这种操作在低级语言中是显式的。

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

内存是什么

在直接跳转到 JavaScript 中内存相关问题之前,我们将简要讨论一般内存以及它的工作原理。

在硬件层面上,计算机内存由大量的内存组成人字拖。 每个触发器包含几个晶体管,并且能够存储一个位。 单个触发器可通过唯一的标识符寻址,因此我们可以读取并覆盖它们。 因此,在概念上,我们可以将整个计算机内存看作是我们可以阅读和写入的一大堆数组。

既然作为人类,我们不是很善于把我们所有的思考和算术都放在一起,我们把它们组织成更大的组,它们可以一起用来表示数字。 8 位称为 1 字节。 除了字节之外,还有字(有时是 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 位。这几乎肯定会对其他计划产生非常不良的后果。

【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

当函数调用其他函数时,每个函数在调用时都会获得自己的堆栈块。 它保存所有的局部变量,还有一个程序计数器,可以记住它在执行中的位置。 当功能完成时,其内存块再次可用于其他目的。

动态分配

不幸的是,当我们在编译时不知道一个变量需要多少内存时,事情并不那么容易。 假设我们要做如下的事情:

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

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

因此,它不能为堆栈上的变量分配空间。 相反,我们的程序需要在运行时明确地要求操作系统获得适当的空间量。 这个内存是从堆空间分配的。 静态和动态内存分配的区别如下表所示:

【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

为了充分了解动态内存分配的工作原理,我们需要花更多的时间在指针上,这可能与这篇文章的主题有太多的偏离。 如果您有兴趣了解更多信息,请在评论中通知我们,我们可以在未来的文章中详细介绍指针。

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

【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

标记和扫描算法

为了确定是否需要对象,该算法确定对象是否可访问。

该算法由以下步骤组成:

  1. 垃圾回收器构建“根”列表。 根通常是代码中保留引用的全局变量。 在 JavaScript 中,“窗口”对象是可以作为根的全局变量的示例。
  2. 所有根被检查并标记为活动(即不是垃圾)。 所有的孩子也被递归检查。 从根部到达的一切都不被认为是垃圾。
  3. 所有未被标记为活动的内存现在可以被认为是垃圾。 收集器现在可以释放该内存并将其返回到操作系统。

这个算法比前一个更好,因为“一个对象有零引用”导致这个对象是不可达的。 相反,我们已经看到了周期。

截至 2012 年,所有现代浏览器都装载了一个标记和扫描垃圾回收器。 过去几年, JavaScript 垃圾收集(代数/增量/并行/并行垃圾收集)领域的所有改进都是对该算法(标记和扫描)的实现进行了改进,但并没有对垃圾收集算法本身的改进, 其目标是确定一个对象是否可达。

在本文中,您可以阅读更多关于跟踪垃圾回收的详细信息,这些垃圾收集也包括标记和扫描以及优化。

周期不再是问题了

在上面的第一个例子中,在函数调用返回之后,两个对象不再被全局对象可访问的东西引用。 因此,垃圾收集器将无法找到它们。

【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

即使对象之间有引用,它们也不可从根目录访问。

反垃圾收集器的直观行为

虽然垃圾收集者很方便,但他们自己也有自己的权衡。其中一个是非确定论。换句话说,GC 是不可预测的。你不能真正地告诉你什么时候会收集。这意味着在某些情况下,程序会使用实际需要的更多内存。在其他情况下,特别敏感的应用程序可能会引起短暂暂停。虽然非确定性意味着在执行集合时无法确定,但大多数 GC 实现共享在分配期间执行收集遍历的常见模式。如果没有执行分配,大多数 GC 保持空闲状态。考虑以下情况:

执行相当大的一组分配。

这些元素中的大多数(或全部)被标记为不可达(假设我们将指向我们不再需要的缓存的引用置空)。

不执行进一步的分配。

在这种情况下,大多数 GC 将不会再运行任何进一步的收集通行证。换句话说,即使有不可达到的参考可供收集,这些都不是由收集器声明。这些不是严格的泄漏,但仍然导致高于通常的内存使用。

什么是内存泄漏?

实质上,内存泄漏可以被定义为应用程序不再需要的内存,但由于某种原因,内存泄漏不会返回到操作系统或可用内存池。

编程语言有利于管理内存的不同方法。 然而,是否使用某种内存实际上是一个不可判定的问题。 换句话说,只有开发人员可以清楚一个内存是否可以返回到操作系统。

某些编程语言提供了帮助开发者执行此操作的功能 其他人则期望开发人员完全明确何时使用一块内存。 维基百科有关于手动和自动内存管理的好文章。

四种常见的 JavaScript 内存泄漏

1:全局变量

JavaScript以有趣的方式处理未声明的变量:对未声明变量的引用在全局对象内创建一个新变量。 在浏览器的情况下,全局对象是窗口。 换一种说法:

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 模式能够防止意外的全局变量。 详细了解这种 JavaScript 执行模式。

变量填充。 这些定义是不可收集的(除非分配为 null 或重新分配)。 特别是,用于临时存储和处理大量信息的全局变量值得关注。 如果您必须使用全局变量来存储大量数据,请确保将其分配为空值,或者在完成之后将其重新分配。

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

在 JavaScript 中使用 setInterval 是很常见的。

大多数提供观察者和其他设施的回调函数库都会在调用自己的实例变得无法访问之后对其进行任何引用。 但是在 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.

此示例说明了定时器可能发生的情况:引用节点或不再需要的数据的计时器。

渲染器表示的对象可能会在将来被删除,从而使整个块不在间隔处理程序中。但是,由于间隔仍然有效,因此无法收集处理程序(需要停止间隔才能发生)。如果无法收集间隔处理程序,则不能收集其依赖关系。这意味着无法收集服务器数据(可能存储大量数据)。

在观察者的情况下,重要的是进行显式调用,以便在不再需要时删除它们(或者相关对象即将无法访问)。

过去,以前特别重要的是某些浏览器(好的旧 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.

如今,现代浏览器(包括 Internet Explorer 和 Microsoft Edge)使用现代垃圾收集算法,可以检测这些周期并正确处理它们。 换句话说,在使节点无法访问之前,不必要调用 removeEventListener 。

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

闭包

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 的闭包(来自前一次调用 replaceThing的Thing )。已经有点混乱了吗?重要的是,一旦为同一个父范围内的闭包创建了一个范围,该范围将被共享。

在这种情况下,为关闭 someMethod 创建的范围与未使用共享。未使用的是引用 originalThing 。即使未使用未使用,一些方法也可以通过 replaceThing 范围之外的 Thing (例如全局某处)使用。并且由于 someMethod 与未使用的共享封闭范围,未使用的引用必须将 originalThing 强制它保持活动(两个闭包之间的整个共享范围)。这样可以防止其收集。

当这个代码段重复运行时,可以观察到内存使用量的稳定增长。当GC运行时,这不会变小。实质上,创建了一个关闭的链接列表(其根源以 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树内部的内部或叶节点的引用时,必须考虑这一点。假设您在 JavaScript 代码中保留对表格特定单元格(

标记)的引用。有一天,您决定从 DOM 中删除该表,但保留对该单元格的引用。直观地,可以假设 GC 将收集除了该单元格之外的所有内容。实际上,这不会发生:该单元格是该表的子节点,并且孩子们保持对父母的引用。也就是说,从 JavaScript 代码引用表格单元会导致整个表保留在内存中。保持对 DOM 元素的引用时仔细考虑。
我们在 SessionStack 尝试遵循这些最佳做法来编写正确处理内存分配的代码,这就是为什么:将 SessionStack 集成到生产网络应用程序中后,它会开始记录所有内容:所有 DOM 更改,用户交互,JavaScript 异常,堆栈跟踪,网络请求失败,调试消息等。
使用 SessionStack,您可以将 Web 应用中的问题重播为视频,并查看用户发生的一切。所有这一切都将发生,对您的网络应用程序没有性能影响。
由于用户可以重新加载页面或导航您的应用程序,因此所有观察者,拦截器,可变分配等都必须正确处理,因此不会导致任何内存泄漏或不增加网络应用程序的内存消耗我们整合了。
有一个免费的计划,所以你可以试试看。

【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

查看原文: 【译】JS 中的内存管理 && 常见的 4 种内存泄露处理方式

  • beautifulbutterfly
  • greenbear
  • organicpanda
  • crazypanda
  • goldencat
  • lazybear
  • whitecat
  • whiteelephant
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。