[译] JavaScript 工做原理:内存管理 + 处理常见的4种内存泄漏

几周前,咱们开始了一系列旨在深刻挖掘 JavaScript 及其工做原理的研究。咱们的初衷是:经过了解 JavaScript 代码块的构建以及它们之间协调工做的原理,咱们将可以编写更好的代码和应用程序。javascript

本系列的第一篇文章着重于提供引擎概览, 运行时, 以及堆栈调用。第二篇文章仔细审查了 Google 的 V8 JavaScript 引擎的内部区块而且提供了一些关于怎样编写更好 JavaScript 代码的建议。html

在第三篇文章中, 咱们将讨论另一个愈来愈被开发人员忽视的主题,缘由是应用于平常基础内存管理的程序语言愈来愈成熟和复杂。咱们也将会在 SessionStack 提供一些关于如何处理 JavaScript 内存泄漏的建议,咱们须要确认 SessionStack 不会致使内存泄漏,或者不会增长咱们集成的 web 应用程序的消耗。前端

概览

例如,像 C 这样的编程语言,有 malloc()free() 这样的基础内存管理函数。开发人员可使用这些函数来显式分配和释放操做系统的内存。java

与此同时,JavaScrip 在对象被建立时分配内存,并在对象再也不使用时“自动”释放内存,这个过程被称为垃圾回收。这种看似“自动”释放资源的特性是致使混乱的来源,它给了 JavaScript(和其余高级语言)开发者们一种错觉,他们能够选择不去关心内存管理。这是一种错误的观念node

即便使用高级语言,开发者也应该对内存管理有一些理解(至少关于基本的内存管理)。有时,自动内存管理存在的问题(好比垃圾回收器的错误或内存限制等)要求开发者须要理解内存管理,才能处理的更合适(或找到代价最少的替代方案)。react

内存生命周期

不管你使用哪一种程序语言,内存生命周期老是大体相同的:android

如下是对循环中每一步具体状况的概述:ios

  • 内存分配 — 内存由操做系统分配,它容许你的应用程序使用。在基础语言中 (好比 C 语言),这是一个开发人员应该处理的显式操做。然而在高级系统中,语言已经帮你完成了这些工做。git

  • 内存使用 — 这是你的程序真正使用以前分配的内存的时候,读写操做在你使用代码中已分配的变量时发生。github

  • 内存释放 — 释放你明确不须要的内存,让其再次空闲和可用。和内存分配同样,在基础语言中这是显式操做。 关于调用栈和内存堆的概念的快速概览,能够阅读咱们的关于主题的第一篇文章

内存是什么?

在直接跳到有关 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 bytes

这是它处理 integers 和 doubles 类型当前大小的方式。大约 20 年前,integers 一般是 2 个字节,doubles 一般是 4 个字节。您的代码不该该依赖于某一时刻基本数据类型的大小。

编译器将插入与操做系统交互的代码,为堆栈中的变量请求存储所需的字节数。

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

注意,若是咱们试图在这里访问 x[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 引用了它的 prototype (隐式引用)和它的属性值(显式引用)。

在这种状况下,“对象”的概念扩展到比普通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.根节点:通常来讲,根是代码中引用的全局变量。例如,在 JavaScript 中,能够充当根节点的全局变量是“window”对象。Node.js 中的全局对象被称为“global”。完整的根节点列表由垃圾收集器构建。 2.而后算法检查全部根节点和他们的子节点而且把他们标记为活跃的(意思是他们不是垃圾)。任何根节点不能访问的变量将被标记为垃圾。 3.最后,垃圾收集器释放全部未被标记为活跃的内存块,并将这些内存返回给操做系统。

标记和扫描算法行为的可视化。

由于“一个对象有零引用”致使该对象不可达,因此这个算法比前一个算法更好。咱们在周期中看到的情形恰巧相反,是不正确的。

截至 2012 年,全部现代浏览器都内置了标记扫描式的垃圾回收器。去年在 JavaScript 垃圾收集(通用/增量/并发/并行垃圾收集)领域中所作的全部改进都是基于这种算法(标记和扫描)的实现改进,但这不是对垃圾收集算法自己的改进,也不是对判断一个对象是否可达这个目标的改进。

在本文中, 您能够阅读有关垃圾回收跟踪的更详细的信息,文章也包括标记和扫描算法以及其优化。

周期再也不是问题

在上面的第一个例子中,函数调用返回后,两个对象再也不被全局对象中的某个变量引用。所以,垃圾收集器会认为它们不可访问。

即便两个对象之间有引用,从根节点它们也再也不可达。

统计垃圾收集器的直观行为

尽管垃圾收集器很方便,但他们也有本身的一套权衡策略。其中之一是不肯定性。换句话说,GCs(垃圾收集器)们是不可预测的。你不能肯定一个垃圾收集器什么时候会执行收集。这意味着在某些状况下,程序其实须要使用更多的内存。其余状况下,在特别敏感的应用程序中,短暂暂停多是显而易见的。尽管不肯定性意味着不能肯定一个垃圾收集器什么时候执行收集,大多数 GC 共享分配中的垃圾收集通用模式。若是没有执行分配,大多数 GC 保持空闲状态。考虑以下场景:

  1. 大量的分配被执行。
  2. 大多数这些元素(或所有)被标记为不可访问(假设咱们废除一个指向咱们再也不须要的缓存的引用)。
  3. 没有执行更深的内存分配。

在这种状况下,大多数 GC 不会运行任何更深层次的收集。换句话说,即便存在不可用的引用可用于收集,收集器也不会声明这些引用。这些并非严格的泄漏,但仍会致使高于平常的内存使用率。

什么是内存泄漏?

就像内存描述的那样,内存泄漏是应用程序过去使用但再也不须要的还没有返回到操做系统或可用内存池的内存片断。

编程语言偏好不一样的内存管理方式。可是,某段内存是否被使用其实是一个不可断定问题。换句话说,只有开发人员能够明确某块内存是否能够返回给操做系统。

某些编程语言提供了帮助开发人员执行上述操做的功能。其余人则但愿开发人员可以彻底明确某段内存什么时候处于未使用状态。维基百科在如何手工自动内存管理方面有很好的文章。

JavaScript 常见的四种内存泄漏

1:全局变量

JavaScript 用一种有趣的方式处理未声明的变量:当引用一个未声明的变量时,在 global 对象中建立一个新变量。在浏览器中,全局对象将是 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 API(其余库和框架也支持这个),您也能够在节点废弃以前删除监听器。即便应用程序在较旧的浏览器版本下运行,这些库也会确保没有内存泄漏。

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 变量的 Thing 变量)所持有的闭包所引用。须要记住的是一旦为同一个父做用域内的闭包建立做用域,做用域将被共享。

在个例子中,someMethod 建立的做用域与 unused 共享。unused 包含一个关于 originalThing 的引用。即便 unused 从未被引用过,someMethod 也能够经过 replaceThing 做用域以外的 theThing 来使用它(例如全局的某个地方)。因为 someMethodunused 共享闭包范围,unused 指向 originalThing 的引用强制它保持活动状态(两个闭包之间的整个共享范围)。这阻止了它们的垃圾收集。

在上面的例子中,为闭包 someMethod 建立的做用域与 unused 共享,而 unused 又引用 originalThingsomeMethod 能够经过 replaceThing 范围以外的 theThing 来引用,尽管 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 应用程序中的问题,并查看全部的用户行为。全部这些都必须在您的网络应用程序没有性能影响的状况下进行。

因为用户能够从新加载页面或导航你的应用程序,全部的观察者,拦截器,变量分配等都必须正确处理,这样它们才不会致使任何内存泄漏,也不会增长咱们正在整合的Web应用程序的内存消耗。

这里有一个免费的计划因此你能够试试看.

Resources


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索