杜绝js中四种内存泄漏类型的发生

5,137 阅读13分钟
原文链接: www.shuaihua.cc

这篇文章将探索在客户端JavaScript中常见的内存泄漏代码,内存泄漏会造成一系列的后果:运行缓慢,应用崩溃,潜在危险,甚至会影响其他应用的运行。

介绍

内存泄漏是每一位开发者最终都会面临的一个问题。即使是在有内存管理机制的语言上开发,也同样会出现内存泄漏的情况。内存泄漏会造成一系列的后果:运行缓慢,应用崩溃,潜在危险,甚至会影响其他应用的运行。

内存泄漏

本质上,内存泄漏可以被定义为:应用向系统请求内存后,由于一些原因而导致无法将内存归还给系统或自由内存池。编程语言用不同的机制管理着内存,这一机制减少内存泄漏发生的几率。然而某一块内存是否还在被应用使用着或着已经不需要了是一件无法准确判断的事情。换句话说,只有开发者最清楚一块内存是否可以被操作系统回收了。一些编程语言提供了这样的功能帮助开发者管理内存,另一些编程语言则要求开发者能自行解决一块内存是否可以回收。在维基百科上有一些关于手动自动的内存管理的好文章。

JS内存管理

JavaScript绝对称得上是有再循环回收机制的脚本语言,再循环回收脚本语言通过“周期性检查”机制帮助开发者管理内存——比如之前分配的一块内存是否仍然可以从应用的另一部分“触达”。换句话说,再循环回收脚本语言将“哪些内存仍被使用着”问题变成了“那些内存仍被应用其他部分使用着”的问题,从而简便了内存管理的复杂性。这两者的差异很微妙,但重要的是:只有开发者才知道一块分配的内存在未来是否还用得到,对于不能获取到的内存,编程语言将通过算法进行判断之后返回给操作系统。

未使用再循环回收机制的语言通常采用其他技术管理内存:精细化管理,开发者需要准确无误的告诉编译器某一块分配的内存何时将不再需要;引入了计数的手段,每一次引用都关联到每一处内存块(当引用次数变为0时,这块内存将归还给操作系统)。这一技术手段需要开发者自己权衡(使用不当会造成内存的泄漏)。

JS内存泄漏

对于有再循环回收机制的语言来说,造成内存泄漏的主要因素是“不期望的引用”。为了理解什么叫做“不期望的引用”,首先我们需要理解再循环回收器是如何判断一块内存是否能被触达。

标记与清除

大多数再循环回收器使用“Mark-and-sweep”算法,这一算法由以下步骤组成:

1、再循环回收器创建一个叫做“roots”的列表,我们暂且叫它根列表。根列表通常是代码中的一个全局变量,用来存放各种引用。在JavaScript中,“window”对象就是一种全局变量,它可以扮演根列表的角色。window对象总是存在的,所以再循环回收器将它window及window对象所有后代的属性认为是总是存在的(也就是不能当作再循环被回收)。

2、所有的根列表都被标记为活跃的状态(也就是说不能当作再循环被回收)。再循环回收器也会递归检查window对象的所有后代。所有能被检查到的后代都不会被当作是再循环。

3、所有不存在于window对象及其后代中的数据所占用的内存都将被当作再循环回收起来,回收器此时会将这些回收的内存归还给操作系统。

现代浏览器们通过各自的算法改进再循环回收器。从本质上讲它们都大同小异:可触达的一块内存标记为不可回收,剩余的都被标记为再循环。

不再需要的引用指的是:引用一块内存并向其中存储数据,当开发者不再需要这个数据时(这时内存被应该被回收),由于某些原因导致这些占用内存的数据依然被标记为活跃且不可回收的状态而仍然存在于根列表树内(也就是window对象中)。在JavaScript的上下文中,不再需要的引用首先是一个变量,这个变量存在与代码中的某处,这个变量指向某一块内存的引用,当这个变量在未来不需要再被使用时本应该被回收而没有被回收。由于开发者的失误导致了内存的泄漏。

所以,为了理解在JavaScript中有哪些最普遍的泄漏情况,我们需要知道哪一种方式的引用最容易被遗忘。

四种常见内存泄漏

意外声明全局变量

发明JavaScript这门语言的目的之一是为了让它看起来像Java,但是更灵活、自由,更适合初学者上手。能证明JavaScript语言本身很灵活自由的证据就是它允许直接给未声明的变量赋值:这样的书写行为将在全局对象中创建一个新的变量。对于浏览器来说,全局对象即是‘window’。

看起来:

function foo(){
  bar = "this is a hidden global variable";
}

事实上:

function foo(){
  window.bar = "this is an explicit global variable";
}

变量bar本应该拥有一个只在函数foo作用域内有效的引用,但是你忘记了使用var去声明它,JavaScript不会报错,但是却创建了一个全局变量(如果你是有意为之则不在此次讨论之内)。虽然说在本示例中泄漏一个简单的字符串并不会产生多大的危害,但是这确实是糟糕的做法。

另一种意外创建了全局变量的做法是通过‘this’关键字:

function foo(){
  this.variable = "potential accidental global";
}

// Foo作为自由函数调用,this指向全局对象(window)
// 而不是undefined
foo();

为了避免这些失误的发生,添加 “use strict”; 行到你的JavaScript文件的头部。这将开启JavaScript解析器的严格模式,从而阻止创建意外的全局变量。

全局变量

即使我们现在谈论的是不确定是否需要声明全局变量的情况,但有些时候,我们确实会有意识的声明一个全局变量。这里我们谈论的是定义无法回收的全局变量(除非这个全局变量为null或者再分配为指向其他引用)。值得一提的是,只有需要临时存储和处理非常大的数据时才会考虑使用全局变量。如果你一定要使用全局变量存储很多数据的话,请确保在你完成一些需要这些数据的操作后为这个全局变量赋一个null值或者重新分配一个引用。一个最常见的会增加内存消耗的东西就是“缓存”,缓存数据是指那些会重复使用到的数据,为了有效的使用,缓存必须拥有峰值边界限制,如果不加约束的使用缓存技术,必定会由于他们的内容无法被回收而造成非常高的内存的消耗。

定时器与回调函数

定时器 在JavaScript中很常用,一些javascript库通过回调函数的方式为节点元素提供了各式各的监听器和工具函数,这些库都会注意当节点元素不可触达前让回调函数内的引用也不在可触达,以避免内存被泄漏。下面是常见的使用setInterval函数的情况:

var someResource = getData();
setInterval(function(){
  var node = document.getElementById('Node');
  if(node){
    // 执行一些对node和someResource的操作
    node.innerHTML = JSON.stringify(someResource);
  }
}, 1000);

这个例子向我们描述了使用定时器不当会发生什么:定时器的回调函数中创建了指向节点元素和一些数据的引用,当1秒过后再次执行这一回调函数,上一次执行的回调函数中还保留着对节点和数据的引用,如此每个一秒循环着重复着。也许在未来的某个时刻,node元素会从节点树中移除,这会让定时器回调函数块中的所有引用变得毫无意义。然而定时器的回调函数仍然在每隔1秒的执行着。无法被回收(正常的情况是定时器可以被回收)。如果定时器中的回调函数不能被回收,那么这个回调函数中的依赖的资源也不能被回收,也就是 someResource 所指向的数据的引用无法被回收。

上面是定时器使用不当造成的内存泄漏,下面是事件监听器使用不当造成内存泄漏的情况。

一旦你不再需要用到某个事件监听器,就必须明确的将他们移除掉(或者在相关联的对象将要被回收之前完成)。在过去,在那个ie6的年代,浏览器无法很好的管理循环引用的情况,因此需要明确移除掉事件监听器。而如今,绝大多数浏览器可以在当需要监听的节点被移除后能很好的回收事件监听器处理函数,即使开发者并没有明确要移除它们,这是一个好的最佳实践,然而,最好在对象被清楚之前移除对象上的事件监听器。

var element = document.getElementById('button');
function onClick(event){
  element.innerHTML = 'text';
}
element.addEventListener('click', onClick);
// 做一些操作
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 当跳出上面的作用域后,即使是在那些不能处理好循环引用的旧浏览器上也能将element元素和onClick函数回收。

事件监听与循环引用

对于JavaScript开发者来说,监听器和循环引用是祸根。可能是bug也可能是IE浏览器原本的设计意图使然,旧版本的IE浏览器无法检测到DOM节点和Javascript之间的循环引用,这是典型的监听器通常将一些引用仍然保留在其中的原因。换句话说,使用IE浏览器,每次为节点元素添加监听器都会造成一次泄漏,这就是为什么当节点被移除或者对象赋值为null前之要就要明确的移除事件处理函数。如今,现代浏览器(包括IE和Edge)采用现代的垃圾回收算法,可以检测到循环引用,并且可以正确的处理着中情况。换句话说,当一个被添加了事件监听器的元素不可引用后并不需要严格的调用 removeEventListener 方法。

以jQuery为代表的各种框架和库会在移除节点之前移除监听器(需要使用这个库提供的特定API操作)。这些过程在库的内部实现,确保不会产生泄漏,即使在像IE这样问题众多的浏览器中也不会出现内存泄漏的情况。

无效DOM引用

有时将DOM节点存储到数据结构中很有用。假设你想快速的更新表格中的几行内容,直觉上你会把表格的每一行的DOM节点存储成一个数组或者对象列表。如果你这样做了,就会有两处引用了相同的DOM节点,一处是DOM树,另一处是你存储DOM节点的对象。如果在未来某刻你决定移除这些行,你需要确保这两处的引用都失效了,只有两处的引用都切断,那几个DOM元素才算真正的从内存中移除。

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

function doStuff(){
  elements.image.src = 'http://some.url/image';
  elements.button.click();
  console.log(elements.text.innerHTML);
  // 其他操作
}

function removeButton(){
  // button是body的子节点
  document.body.removeChild(document.getElementById('button'));
  // 这时,我们在 elements 对象中仍然有着指向 #button 元素的引用的变量。
  // 换句话说,#button 元素仍然存在于内存当中,无法被在循环回收器回收,从而造成内存泄漏。
}

另一种需要引起重视的是DOM树中节点之间也存在引用的情况。假设你通过代码存储了表格中某一个单元格(比如),后来你决定把整个表格从DOM树中删除(单单把那一个单元格的引用保留下来),也许你会以为再循环回收器会把整个表格回收而只留下那一个单元格,实际上真实的情况并不是这样的:单元格属于表格的子节点,子节点拥有指向它们父节点的引用。换句话说,由于你通过代码将一个单元格的引用保留了下来,造成了整个表格仍然保留在内存中。所以当你以后要保留DOM元素的引用时要格外的注意。

闭包

闭包是JavaScript的灵魂:闭包让匿名函数具备了从父作用域块中捕获变量的能力,Meter网站的开发者发现了一个由于JavaScript运行时而产生的特殊情况,这将通过很微妙的方式造成内存泄漏。

var theThing = null;
var replaceThing = function(){
  var originalThing = theThing;
  var unused = function(){
    if(originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function(){
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

这个代码片段做了一件事情:每一次 replaceThing 被调用时,theThing 就重新指向一个新的对象的引用,这个新的对象包含一个很大的数组和一个新的闭包 someMethod。同时,变量 unused 拥有一个闭包函数,这个函数中拥有指向 originalThing 的引用( theThing 在第一次调用 replaceThing 函数后不再是null)。好像有点不对劲,是吗?重点是一旦在父作用域中创建一个闭包函数的作用域,父作用域是被共享的。在该例中, someMethod 闭包被 unused 共享。 unused 拥有指向 originalThing 的引用。即使 unused 从来没有被使用, someMethod 方法也能通过 theThing 对象被使用。就像 someMethod
unused 共享了闭包作用域,即使 unused 从来没有被使用,它指向 originalThing 的引用依然是活跃的状态,所以 originalThing 不会被回收。当这一代码片段运行起来,不断增加的内存占用就愈发明显。当执行再循环回收器时并不会起多大的作用。本质上,创建了一个闭包链,每一个闭包中都拥有指向最大的数组的引用,造成不断增长的泄漏。