【译】JavaScript是如何工做的:内存管理 + 如何处理4个常见的内存泄露

本文转载自:众成翻译
译者:Leslie Wang
审校: 为之漫笔
连接:http://www.zcfy.cc/article/4211
原文:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbecjavascript

几周前,咱们开始写一个系列,深刻探讨JavaScript和它的工做原理。咱们认为了解JavaScript的构成以及它们如何协做,有助于编写出更好的代码和应用程序。html

本系列第一篇重点介绍了引擎、运行时、调用栈。第二篇揭示了谷歌V8 JavaScript引擎的内部机制,而且提供了一些关于如何写出更好的JavaScript代码的建议。前端

本文做为第三篇,将会讨论另外一个开发者容易忽视的重要主题 :内存管理。咱们也会提供一些关于如何处理JavaScript内存泄露的技巧。在SessionStack,咱们须要确保不会形成内存泄露或者不会增长咱们集成的Web应用的内存消耗。java

概述

某些语言,好比C有低级的原生内存管理原语,像malloc()free()。开发人员使用这些原语能够显式分配和释放操做系统的内存。node

相对地,JavaScript会在建立变量(对象、字符串)时自动分配内存,并在这些变量不被使用时自动释放内存,这个过程被称为垃圾回收。这个“自动”释放资源的特性带来了不少困惑,让JavaScript(和其余高级级语言)开发者误觉得能够不关心内存管理。这是一个很大的错误web

即便使用高级级语言,开发者也应该对于内存管理有必定的理解(至少有基本的理解)。有时自动内存管理存在一些问题(例如垃圾回收实现可能存在缺陷或者不足),开发者必须弄明白这些问题,以便找一个合适解决方法。算法

内存生命周期

不管你用哪种编程语言,内存生命周期几乎老是同样的:编程

Here is an overview of what happens at each step of the cycle:
这是对生命周期中的每一步大概的说明:segmentfault

  • 分配内存— 内存是被操做系统分配,这容许程序使用它。在低级语言中(例如C),这是一个做为开发者须要处理的显式操做。在高级语言中,然而,这些操做都代替开发者进行了处理。
  • 使用内存。实际使用以前分配的内存,经过在代码操做变量对内在进行读和写。
  • 释放内存 。不用的时候,就能够释放内存,以便从新分配。与分配内存操做同样,释放内存在低级语言中也须要显式操做。

想要快速的了解堆栈和内存的概念,能够阅读本系列第一篇文章。数组

什么是内存

在直接探讨Javascript中的内存以前,咱们先简要的讨论一下什么是内存、内存大概是怎么样工做的。

在硬件中,电脑的内存包含了大量的触发电路,每个触发电路都包含一些<span style="font-size: 1rem;">可以储存1位数据的</span>晶体管。触发器经过惟一标识符来寻址,从而能够读取和覆盖它们。所以,从概念上来说,能够认为电脑内存是一个巨大的可读写阵列。

人类不善于把咱们全部的思想和算术用位运算来表示,咱们把这些小东西组织成一个你们伙,这些你们伙能够用来表现数字:8位是一个字节。字节之上是字(16位、32位)。

许多东西被存储在内存中:

  1. 全部的变量和程序中用到的数据;
  2. 程序的代码,包括操做系统的代码。

编译器和操做系统共同工做帮助开发者完成大部分的内存管理,可是咱们推荐你了解一下底层到底发生了什么。

编译代码的时候,编译器会解析原始数据类型,提早计算出它们须要多大的内存空间。而后将所需的数量分配在栈空间中。之因此称为栈空间,是因在函数被调用的时候,他们的内存被添加在现有内存之上(就是会在栈的最上面添加一个栈帧来指向存储函数内部变量的空间)。终止的时候,以LIFO(后进先出)的顺序移除这些调用。例如:

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

编译器立刻知道须要内存
4 + 4 × 4 + 8 = 28字节。

这是当前整型和双精度的大小。大约20年之前,整型一般只须要2个字节,双精度须要4个字节,你的代码不受基础数据类型大小的限制。

编译器会插入与操做系统交互的代码,来请求栈中必要大小的字节来储存变量。

在上面的例子中,编辑器知道每一个变量准确的地址。事实上,不管何时咱们写变量n,将会在内部被翻译成相似“memory address 4127963”的语句。

注意,若是咱们尝试访问x[4]的内存(开始声明的x[4]是长度为4的数组,x[4]表示第五个元素),咱们会访问m的数据。那是由于咱们正在访问一个数组里不存在的元素,m比数组中实际分配内存的最后一个元素x[3]要远4个字节,可能最后的结果是读取(或者覆盖)了m的一些位。这确定会对其余程序产生不但愿产生的结果。

当函数调用其余函数的时候,每个函数被调用的时候都会得到本身的栈块。在本身的栈块里会保存函数内全部的变量,还有一个程序计数器会记录变量执行时所在的位置。当函数执行完以后,会释放它的内存以做他用。

动态分配

不幸的是,事情并非那么简单,由于在编译的时候咱们并不知道一个变量将会须要多少内存。假设咱们作了下面这样的事:

int n = readInput(); //读取用户的输入

...

//建立一个有n个元素的数组

编译器不知道这个数组须要多少内存,由于数组大小取决于用户提供的值。

所以,此时不能在栈上分配空间。程序必须在运行时向操做系统请求够用的空间。此时内存从堆空间中被分配。静态与动态分配内存之间的不一样在下面的表格中被总结出来:

静态分配内存与动态分配内存的区别。

为了彻底理解动态内存是如何分配的,咱们须要花更多的时间在指针上,这个可能很大程度上偏离了这篇文章的主题。若是你有兴趣学习更多的知识,那就在评论中让我知道,我就能够在以后的文章中写更多关于指针的细节。

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;
} //为函数(可调用的对象)分配内存

//函数表达式也会分配一个对象
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

 //一些函数调用也会致使对象分配
`var d = new Date(); // allocates a Date object`   //分配一个Date对象的内存

`var e = document.createElement('div');  //分配一个DOM元素的内存

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

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3);  //s2是一个新的字符串
// 由于字符串是不可变的
// JavaScript可能决定不分配内存
// 而仅仅存储 0-3的范围

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
//新的数组有4个元素是a1和a2链接起来的。

在JavaScript中使用内存

在JavaScript中使用被分配的内存,本质上就是对内在的读和写。

好比,读、写变量的值或者对象的属性,抑或向一个函数传递参数。

内存不在被须要时释放内存

大部分的内存管理问题都在这个阶段出现。

这里最难的任务是找出这些被分配的内存何时再也不被须要。这经常要求开发者去决定程序中的一段内存不在被须要并且释放它。

高级语言嵌入了一个叫垃圾回收的软件,它的工做是跟踪内存的分配和使用,以便于发现一些内存在一些状况下再也不被须要,它将会自动地释放这些内存。

不幸的是,这个过程是一个近似的过程,由于通常关于知道内存是不是被须要的问题是不可判断的(不能用一个算法解决)。

大部分的垃圾回收器会收集再也不被访问的内存,例如指向它的全部变量都在做用域以外。然而,这是一组能够收集的内存空间的近似值。由于在任什么时候候,一个内存地址可能还有一个在做用域里的变量指向它,可是它将不会被再次访问。

垃圾收集

因为找到一些内存是不是“再也不被须要的”这个事实是不可断定的,垃圾回收的实现存在局限性。本节解释必要的概念去理解主要的垃圾回收算法和它们的局限性。

内存引用

垃圾回收算法依赖的主要概念是引用。

在内存管理的语境下,一个对象只要显式或隐式访问另外一个对象,就能够说它引用了另外一个对象。例如,JavaScript对象引用其Prototype(隐式引用),或者引用prototype对象的属性值(显式引用)。

在这种状况下,“对象”的概念扩展到比普通JavaScript对象更广的范围,而且还包含函数做用域。(或者global词法做用域

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

引用计数垃圾回收

这是最简单的垃圾回收算法。 一个对象在没有其余的引用指向它的时候就被认为“可被回收的”。

看一下下面的代码:

var o1 = {
  o2: {
    x: 1
  }
};

//2个对象被建立
/'o2'被'o1'做为属性引用
//谁也不能被回收

var o3 = o1; //'o3'是第二个引用'o1'指向对象的变量

o1 = 1;      //如今,'o1'只有一个引用了,就是'o3'
var o4 = o3.o2; // 引用'o3'对象的'o2'属性
                //'o2'对象这时有2个引用: 一个是做为对象的属性
                //另外一个是'o4'

o3 = '374'; //'o1'原来的对象如今有0个对它的引用
             //'o1'能够被垃圾回收了。
            //然而它的'o2'属性依然被'o4'变量引用,因此'o2'不能被释放。

o4 = null;  //最初'o1'中的'o2'属性没有被其余的引用了
           //'o2'能够被垃圾回收了

循环引用创造麻烦

在涉及循环引用的时候有一个限制。在下面的例子中,两个对象被建立了,并且相互引用,这样建立了一个循环引用。它们会在函数调用后超出做用域,应该能够释放。然而引用计数算法考虑到2个对象中的每个至少被引用了一次,所以都不能够被回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用 o2
  o2.p = o1; // o2 引用 o1\. 造成循环引用
}

f();

标记清除算法

为了决定一个对象是否被须要,这个算法用于肯定是否能够找到某个对象。

这个算法包含如下步骤。

  1. 垃圾回收器生成一个根列表。根一般是将引用保存在代码中的全局变量。在JavaScript中,window对象是一个能够做为根的全局变量。
  2. 全部的根都被检查和标记成活跃的(不是垃圾),全部的子变量也被递归检查。全部可能从根元素到达的都不被认为是垃圾。
  3. 全部没有被标记成活跃的内存都被认为是垃圾。垃圾回收器就能够释放内存而且把内存还给操做系统。

上图就是标记清除示意。

这个算法就比以前的(引用计算)要好些,由于“一个对象没有被引用”致使这个对象不能被访问。相反,正如咱们在循环引用的示例中看到的,对象不能被访问到,不必定不存在引用。

2012年起,全部浏览器都内置了标记清除垃圾回收器。在过去几年中,JavaScript垃圾回收领域中的全部改进(代/增量/并行/并行垃圾收集)都是由这个算法(标记清除法)改进实现的,但并非对垃圾收集算法自己的改进,也没有改变它肯定对象是否可达这个目标。

推荐一篇文章,其中有关于跟踪垃圾回收的细节,包括了标记清除法和它的优化算法。

循环引用再也不是问题

在上面的例子中(循环引用的那个),在函数执行完以后,这个2个对象没有被任何能够到达的全局对象所引用。所以,他们将会被垃圾回收器发现为不可到达的。

尽管在这两个对象之间有相互引用,可是他们不能从全局对象上到达。

垃圾回收器的反常行为

尽管垃圾回收器很方便,可是他们有一套本身的方案。其中之一就是不肯定性。换句话说,GC是不可预测的。你不可能知道一个回收器何时会被执行。这意味着程序在某些状况下会使用比实际需求还要多的内存。在其余状况下,在特别敏感的应用程序中,可能会出现短停顿。尽管不肯定意味着不能肯定回收工做什么时候执行,但大多数GC实现都会在分配内存的期间启动收集例程。若是没有内存分配,大部分垃圾回收就保持空闲。参考下面的状况。

  1. 执行至关大的一组分配。
  2. 这些元素中的大部分(或者全部的)都被标记为不可到达的(假设咱们清空了一个指向咱们再也不须要的缓存的引用。)
  3. 没有更多的分配被执行。

在这种状况下,大多数垃圾回收实现都不会作进一步的回收。换句话说,尽管这里有不可达的引用变量可供回收,回收器也不会管。严格讲,这不是泄露,但结果却会占用比一般状况下更多的内存。

什么是内存泄漏

内存泄漏基本上就是再也不被应用须要的内存,因为某种缘由,没有被归还给操做系统或者进入可用内存池。

编程语言喜欢不一样的管理内存方式。然而,一段肯定的内存是否被使用是一个不可判断的问题。换句话说,只有开发者才能弄清楚,是否一段内存能够被还给操做系统。

某些编程语言为开发者提供了释放内存功能。另外一些则期待开发者清楚的知道一段内存何时是没用的。Wikipedia有一篇很是好的关于内存管理的文章。

4种常见的JavaScript内存泄漏

1:全局变量

JavaScript用一个有趣的方式管理未被声明的变量:对未声明的变量的引用在全局对象里建立一个新的变量。在浏览器的状况下,这个全局对象是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做为函数调用,this指向全局变量(window)
// 而不是undefined
foo();

为了防止这些问题发生,能够在你的JaveScript文件开头使用'use strict';。这个可使用一种严格的模式解析JavaScript来阻止意外的全局变量。

除了意外建立的全局变量,明确建立的全局变量一样也不少。这些固然属于不能被回收的(除非被指定为null或者从新分配)。特别那些用于暂时存储数据的全局变量,是很是重要的。若是你必需要使用全局变量来存储大量数据,确保在是使用完成以后为其赋值null或者从新赋其余值。

2: 被遗忘的定时器或者回调

在JavaScript中使用setInterval是十分常见的。

大多数库,特别是提供观察器或其余接收回调的实用函数的,都会在本身的实例没法访问前把这些回调也设置为没法访问。但涉及setInterval时,下面这样的代码十分常见:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //每5秒执行一次

定时器可能会致使对不须要的节点或者数据的引用。

renderer对象在未来有可能被移除,让interval处理器内部的整个块都变得没有用。但因为interval仍然起做用,处理程序并不能被回收(除非interval中止)。若是interval不能被回收,它的依赖也不可能被回收。这就意味着serverData,大概保存了大量的数据,也不可能被回收。

在观察者的状况下,在他们再也不被须要(或相关对象须要设置成不能到达)的时候明确的调用移除是很是重要的。

在过去,这一点尤为重要,由于某些浏览器(旧的IE6)不能很好的管理循环引用(更多信息见下文)。现在,大部分的浏览器都能并且会在对象变得不可到达的时候回收观察处理器,即便监听器没有被明确的移除掉。然而,在对象被处理以前,要显式地删除这些观察者仍然是值得提倡的作法。例如:

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}

element.addEventListener('click', onClick);

// 作点事

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

// 当元素被销毁
//元素和事件都会即便在老的浏览器里也会被回收

现在的浏览器(包括IE和Edge)使用现代的垃圾回收算法,能够当即发现并处理这些循环引用。换句话说,先调用removeEventListener再删节点并不是严格必要。

jQuery等框架和插件会在丢弃节点前删除监听器。这都是它们内部处理,以保证不会产生内存泄漏,甚至是在有问题的浏览器(没错,IE6)上也不会。

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)的对象。同时,变量unused保持了一个引用originalThing(theThing是上次调用replaceThing生成的值)的闭包。已经有点困惑了吧?最重要的事情是一旦为同一父域中的做用域产生闭包,则该做用域是共享的。

这里,做用域产生了闭包,someMethodunused共享这个闭包中的内存。unused引用了originalThing。尽管unused不会被使用,someMethod能够经过theThing来使用replaceThing做用域外的变量(例如某些全局的)。并且someMethodunused有共同的闭包做用域,unusedoriginalThing的引用强制oriiginalThing保持激活状态(两个闭包共享整个做用域)。这阻止了它的回收。

当这段代码重复执行,能够观察到被使用的内存在持续增长。垃圾回收运行的时候也不会变小。从本质上来讲,闭包的链接列表已经建立了(以theThing变量为根),这些闭包每一个做用域都间接引用了大数组,致使大量的内存泄漏。

这个问题被Meteor团队发现,他们有一篇很是好的文章描述了闭包大量的细节。

4: DOM外引用

有的时候在数据结构里存储DOM节点是很是有用的,好比你想要快速更新一个表格几行的内容。此时存储每一行的DOM节点的引用在一个字典或者数组里是有意义的。此时一个DOM节点有两个引用:一个在dom树中,另一个在字典中。若是在将来的某个时候你想要去移除这些排,你须要确保两个引用都不可到达。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

function doStuff() {
    image.src = 'http://example.com/image_name.png';
}

function removeImage() {
    //image是body元素的子节点
    document.body.removeChild(document.getElementById('image'));

    //这个时候咱们在全局的elements对象里仍然有一个对#button的引用。
    //换句话说,buttom元素仍然在内存中并且不能被回收。
}

当涉及DOM树内部或子节点时,须要考虑额外的考虑因素。例如,你在JavaScript中保持对某个表的特定单元格的引用。有一天你决定从DOM中移除表格可是保留了对单元格的引用。人们也许会认为除了单元格其余的都会被回收。实际并非这样的:单元格是表格的一个子节点,子节点保持了对父节点的引用。确切的说,JS代码中对单元格的引用形成了整个表格被留在内存中了,因此在移除有被引用的节点时候要小心。

咱们在SessionStack努力遵循这些最佳实践,由于:

一旦你整合essionStack到你的生产应用中,它就开始记录全部的事情:DOM变化、用户交互、JS异常、堆栈跟踪、失败的网络请求、调试信息,等等。

经过SessionStack,你能够回放应用中的问题,看到问题对用户的影响。全部这些都不会对你的应用产生性能的影响。由于用户能够从新加载页面或者在应用中跳转,全部的观察者、拦截器、变量分配都必须合理处置。以避免形成内存泄漏,也预防增长整个应用的内存占用。

这是一个免费的计划,你如今能够尝试一下。


欢迎关注个人公众号,关注前端文章:

justjavac微信公众号

参考资料

相关文章
相关标签/搜索