[译] JavaScript是如何工作的:内存管理 + 如何处理4个常见的内存泄漏(译)

前言:这篇文章的主要内容由翻译而来,原文链接。但是大体内容与原文不尽相同,删除了一些内容,同时新增部分内容。由于本文大部分内容是翻译而来,若有理解不当之处还望谅解并指出,我会尽快进行修改。(内心:如果有什么不对的地方还希望大家指出,反正我也不会改 。玩笑话玩笑话 别当真!)

概述

在一些语言中,开发人员需要手动的使用原生语句来显示的分配和释放内存。但是在许多高级语言中,这些过程都会被自动的执行。在JavaScript中,变量(对象,字符串,等等)创建的时候为其分配内存,当不再被使用的时候会“自动地”释放这些内存,这个过程被称为垃圾回收。这个看似“自动的”释放资源的本质是一个混乱的来源,给JavaScript(和其他高等级语言)开发者可以不去关心内存管理的错误印象。这是一个很大的错误

内存泄漏

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

内存泄漏缺陷具有隐蔽性、积累性的特征,比其他内存非法访问错误更难检测。因为内存泄漏的产生原因是内存块未被释放,属于遗漏型缺陷而不是过错型缺陷。此外,内存泄漏通常不会直接产生可观察的错误症状,而是逐渐积累,降低系统整体性能,极端的情况下可能使系统崩溃。

内存生命周期

无论使用哪一种编程语言,内存的生命周期几乎总是一模一样的 分配内存、使用内存、释放内存。 在这里我们主要讨论内存的回收。

引用计数垃圾回收

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

对JS引用类型不熟悉的请先百度引用类型,理解了值类型(基本类型)和引用类型之后才能理解下面的代码

var obj1 = {
  obj2: {
    x: 1
  }
};
//2个对象被创建。 obj2被obj1引用,并且作为obj1的属性存在。这里并没有可以被回收的。
//obj1和obj2都指向了{obj2: {x: 1}}这个对象,这个示例中用`原来的对象`来表示这个对象。

var obj3 = obj1;  //obj3也引用了obj1指向的对象。
obj1 = 1; // obj1不引用原来的对象了。此时原来的对象只有obj3在引用。

var obj4 = obj3.obj2; //obj4引用了obj3对象的obj2属性,
//此时obj2对象有2个引用,一个是作为obj3的一个属性,一个是作为obj4变量。

obj3 = 1;
// 咦,obj1原来对象只有obj3在引用,现在obj3也没用在引用了。
// obj1原来的对象就沦为了一只单身狗,于是乎抓狗大队就来带走了它。(好吧、其实内存就可以被回收了)。
// 然而  obj2对象依然有人爱(被obj4引用)。所以obj2的内存就不会被垃圾回收。

obj4 = null;
// obj2内心在呐喊:小姐姐不要离开我 QOQ。现在obj2也没有被引用了,引用计数就是0
也就是可以被回收了。

简而言之~,如果内存有人爱,那就不会被回收。如果是单身狗的话,[手动滑稽]。

循环引用会造成麻烦

引用计数在涉及循环引用的时候有一个缺陷。在下面的例子中,创建了2个对象,并且相互引用,这样创建了一个循环。因此他们实际上是无用的,可以被释放。然而引用计数算法考虑到2个对象中的每一个至少被引用了一次,因此都不可以被回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2;
  o2.p = o1;
}

f();

单身狗心里千万头草泥马在奔腾(我特么也会自己牵自己手啊,我也会假装情侣拍照啊)

标记清除算法

别以为你假装不是单身狗就拿你没办法了,这个算法确定了对象是否可以被达到。 这个算法包含了以下步骤:

  1. 从‘根’上生成一个列表(通常是以全局变量为根)。在JS中window对象可以作为一个'根'
  2. 所有的'根'都被标记为活跃的,所有的子变量也被递归检查。能够从'根'上到达的都不会被认为成垃圾。
  3. 没有被标记为活跃的就被认为成垃圾。这些内存就会被释放。

上图就是标记清除的动作。

在之前的例子中,虽然两个变量相互引用,但在函数执行完之后,这个两个变量都没有被window对象上的任何对象所引用。因此,他们会被认为不可到达的。

4种常见的JS内存泄漏

1:全局变量 JavaScript用一个有趣的方式管理未被声明的变量:对未声明的变量的引用在全局对象里创建一个新的变量。在浏览器的情况下,这个全局对象是window。换句话说:

function foo(arg) {
  bar = 'some text';
}
//等同于
function foo(arg) {
  window.bar = 'some text';
}

如果bar被假定只在foo函数的作用域里引用,但是你忘记了使用var去声明它,一个意外的全局变量就被声明了。 在这个例子里,泄漏一个简单的字符并不会造成很大的伤害,但是它确实有可能变得更糟。 有时有会通过this来创建意外的全局变量。

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

如果有时全局变量被用于暂时储存大量的数据或者涉及到的信息,那么在使用完之后应该指定为null或者重新分配

2:被遗忘的定时器或者回调 还是来个栗子吧,定时器可能会产生对不再需要的DOM节点或者数据的引用。

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

renderer对象在将来有可能被移除,让interval没有存在的意义。然而当处理器interval仍然起作用时,renderer并不能被回收(interval在对象被移除时需要被停止),如果interval不能被回收,它的依赖也不可能被回收。这就意味着serverData,大概保存了大量的数据,也不可能被回收。 如今,大部分的浏览器都能而且会在对象变得不可到达的时候回收观察处理器,甚至监听器没有被明确的移除掉。在对象被处理之前,最好也要显式地删除这些观察者。

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。 框架和插件例如jQuqery在处理节点(当使用具体的api的时候)之前会移除监听器。这个是插件内部的处理可以确保不会产生内存泄漏,甚至运行在有问题的浏览器上(哈哈哈 说的就是IE6)。

3: 闭包 闭包是javascript开发的一个关键方面,一个内部函数使用了外部(封闭)函数的变量。由于JavaScript运行的细节,它可能以下面的方式造成内存泄漏:

var theThing = null;

var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) console.log('hi')  //引用了originalThing
  };
  
  theThing = {
    longStr: new Array(1000000).jojin('*'),
    someMethod: function (){
      console.log('message');  
    }
  };
};

setInterval(replaceThing,1000);

这些代码做了一件事情,每次relaceThing被调用,theThing获得一个包含大量数据和新的闭包(someMethod)的对象。同时,变量unused引用了originalThingtheThing是上一次函数被调用时产生的)。已经有点困惑了吧?最重要的事情是一旦为同一父域中的作用域产生闭包,则该作用域是共享的。

在这个案例中,someMethodunused共享闭包作用域,unused引用了originalThing,这阻止了originalThing的回收,尽管unused不会被使用,但是someMethod依然可以通过theThing来访问replaceThing作用域外的变量(例如某些全局的)。

4:来自DOM的引用 在你要重复的操作DOM节点的时候,存储DOM节点是十分有用的。但是在你需要移除DOM节点的时候,需要确保移除DOM tree和代码中储存的引用。

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

//Do some stuff

document.body.removeChild(document.getElementById('image'));
//这个时候  虽然从dom tree中移除了id为image的节点,但是还保留了一个对该节点的引用。于是image仍然不能被回收。

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

总结

  1. 小心使用全局变量,尽量不要使用全局变量来存储大量数据,如果是暂时使用,要在使用完成之后手动指定为null或者重新分配
  2. 如果使用了定时器,在无用的时候要记得清除。如果为DOM节点绑定了事件监听器,在移除节点时要先注销事件监听器。
  3. 小心闭包的使用。如果掌握不好,至少在使用大量数据的时候仔细考量。在使用递归的时候也要非常小心(例如用canvas做小游戏)。
  4. 在移除DOM节点的时候要确保在代码中没有对节点的引用,这样才能完全的移除节点。在移除父节点之前要先移除子节点。
关注下面的标签,发现更多相似文章
评论
说说你的看法