【翻译】JavaScript内存泄露

我们在进行JavaScript开发时,很少会考虑内存的管理。JavaScript中变量的声明和使用看起来是一件很轻松的事,底层的细节处理交给浏览器去做就好了。

但是,随着web应用变得越来越庞大以及AJAX的使用,用户在一个网页中操作和停留的时间越来越久,我们会注意到浏览器占用的内存越来越大甚至到达了G数量级。造成这个问题的罪魁祸首就是memory leak(内存泄露)。

下面我们将讨论一下内存的管理以及最常见的内存泄露问题。

JavaScript的内存管理

JavaScript内存管理的核心概念:可达性(reachability)

  1. 所谓可达性指的是一些可被全局作用域访问到的对象(原文:A distinguished set of objects are assumed to be reachable: these are known as the roots)。最典型的的便是在调用栈(call stack)中被引用的全部对象(即当前function域内的所有局部变量和参数),以及部分全局变量。
  2. 只有一个对象可以被全局作用域直接访问或通过一系列的引用链间接访问,那么这个对象便会一直占用内存。

译者注:翻译的不太满意,原文讲的也略为晦涩。个人理解为所谓可达性这样理解:如果一个对象obj可以通过window.obj访问到,或者window.a.b.c.obj(其中a,b,c泛指层层作用域)访问到,那么便可以将这个对象obj是可达的。

浏览器的垃圾回收器会自动处理不可达的对象。

举个栗子:

function Menu(title) {
  this.title = title
  this.elem = document.getElementById('id')
}
var menu = new Menu('My Menu')
document.body.innerHTML = ''  // (1)
menu = new Menu('His menu') // (2)

在注释(1)处,body.innerHTML被清除,理论上讲body的所有子节点被清除了,因为它们无法再被访问到。

但是通过document.getElementById('id')获取的这个节点是个例外,我们可以通过menu.elem访问到它,所以它仍然占用着内存。当然,如果此时检查它的父节点parentNode将会返回null

也就是说:父节点被清空并不能保证其子节点全部被清除

在注释(2)处,window.menu的引用被重新定义,使得原来的menu无法被访问。

这种情况下,原来的menu会被浏览器的垃圾回收器处理掉。

此时,整个旧menu结构被彻底删除。当然,如果存在与它关联的其他代码,则它仍然保持完整。

译者注:也就是说,如果代码中存在与旧menu作用域关联的对象,即使重新定义window.menu的引用对象,旧的menu结构仍然不会被回收。

循环引用-Circular references collection

闭包经常会引起循环引用,举个栗子:

function setHandler() {
  var elem = document.getElementById('id')
  elem.onclick = function() {
    // ...
  }
}

此例中,DOM节点elem通过onclick直接引用一个匿名function,同时,这个匿名function引用其外层作用域setHandlerelem(典型的闭包)。

译者注:上图中的LexicalEnvironment指的是setHandler的作用域。

通过这种内存结构可以得出结论:即使handler内没有任何代码,一些特殊的方法比如addEventListener/attachEvent也能够从其内部创建相关引用。

监听器handler一般伴随着相关DOM节点同时被清除:

function cleanUp() {
  var elem = document.getElementById('id')
  elem.parentNode.removeChild(elem)
}

调用cleanUp()删除DOM节点elem,此时cleanUp函数作用域仍然存在elem的引用LexialEnvironment.elem,但是因为没有嵌套的函数与之关联作用域,所以cleanUpLexialEnvironment被回收。然后,elem便不具备可达性,其handler也随之被回收。

内存泄露

内存泄露指的是浏览器因为种种原因没有回收无用对象占用的内存。

内存泄露的原因可能是浏览器的bug,或者浏览器扩展插件的问题,但是更多的时候,是因为我们代码结构的不严谨。

IE8以下浏览器的DOM-JS内存泄露

IE8版本以前的浏览器不能够回收DOM对象和JavaScript之间的循环引用。

IE6的SP3版本问题更严重,甚至网页关闭以后仍然不能回收内存。

所以,上文提到的setHandler在IE8以下浏览器中,elem以及其关联的闭包never被回收。

function setHandler() {
  var elem = document.getElementById('id')
  elem.onclick = function() { /* ... */ }
}

除了DOM节点,其他对象(比如XHR)也会引起问题。

我们可以通过以下代码打破IE浏览器的循环引用。

elem=null,从而监听器handler无法引用此DOM节点,这样便破坏了循环引用。

这种解决方法虽然存在弊端,但对付IE浏览器却也不失为一种好对策。

对于IE浏览器的内存泄露问题,读者可以参考Understanding and Solving Internet Explorer Leak PatternsCircular Memory Leak Mitigation

XmlHttpRequest的内存管理与内存泄露

在IE9以下版本浏览器运行如下代码:

var xhr = new XMLHttpRequest() // or ActiveX in older IE
xhr.open('GET', '/server.url', true)
xhr.onreadystatechange = function() {
  if(xhr.readyState == 4 && xhr.status == 200) {            
    // ...
  }
}
xhr.send(null)

每次运行的内存结构如下:

异步对象XHR会被浏览器跟踪,产生一个对它的内部引用。

理论上讲,每次请求完成后,XHR对象的引用就会被清除。但是IE9一下版本的浏览器并不会这么做

请在IE9以下版本访问此demo

幸运的是,我们可以轻松地解决这个问题:在闭包内删除xhr对象,在handler内部通过this访问它。

var xhr = new XMLHttpRequest() 
xhr.open('GET', 'jquery.js', true) 
xhr.onreadystatechange = function() {
  if(this.readyState == 4 && this.status == 200) {            
    document.getElementById('test').innerHTML++
  }
} 
xhr.send(null)
xhr = null
}, 50)

这样便破坏了循环引用,解决了内存泄露问题。IE9以下版本打开demo

setInterval/setTimeout

setInterval/setTimeout使用的函数同样存在内部引用并且被浏览器跟踪直到运行结束,随后被回收。

对于setInterval,通过clearInterval来结束运行,但是setInterval运行的函数如果存在跨域引用,也会引起内存泄露。

对于服务器端的JS和V8引擎关于setInterval的问题可以参考:Memory leak when running setInterval in a new context

内存泄露的占用空间

简单的数据结构引起的内存泄露所占用的空间很少。

但是,因为闭包机制,如果关联外层作用域的内层函数保持活跃,外层的所有变量也会被保留。

想象一下,如果一个函数中存在一个庞大的字符串变量:

function f() {
  var data = "Large piece of data, probably received from server"
  /* do something using data */
  function inner() {
    // ...
  }
  return inner
}

只要inner函数保持活跃,外层的f函数作用域就不会被回收,变量data将会占用内存资源。

JavaScript解释器无法判断哪个外层变量被内层函数引用,所以它选择保留外层的所有变量。我希望最新的解释器可以针对这个问题进行优化,但难以预料它是否能够办到

事实上,这样的机制也是有好处的,很多情况下并不算是内层泄露。比如每次请求创建的函数,它们不被回收因为它们是监听器或者其他有用的东西。

如果外层的变量只被外层函数使用而不被内层函数引用,这样的变量可以通过设置null来节省内存。

function f() {
  var data = "Large piece of data, probably received from server"
  /* do something using data */

  function inner() {
    // ...
  }
data = null
  return inner
}

此时,虽然变量data仍然作为外层函数的一个属性占用着内存,但相比较庞大的字符串,它占用的资源空间大幅减少。

jQuery内存泄露处理方法及其弊端

jQuery用$.data方法处理IE6-7的内存泄露,不幸的是,与此同时也引起了jQuery专属的泄露问题。

$.data函数的本质是将JavaScript实体与DOM节点绑定,然后通过DOM节点来进行对JavaScript实体的读/存操作:

$(document.body).data('prop', 'val') // set
alert( $(document.body).data('prop') ) // get

$(elem).data(prop, val)的工作原理如下:

  1. 首先为本DOM节点分配一个唯一的数字标识(如果之前没有设置过): elem[ jQuery.expando ] = id = ++jQuery.uuid // from jQuery source 其中jQuery.expando是一个随机数,不会发生重复。
  2. 待设置的属性被赋予一个特殊的对象jQuery.cache jQuery.cache[id]['prop'] = val

当需要读取DOM节点的data属性时,原理如下:

  1. DOM节点的唯一数字标识被重新获取id = elem[ jQuery.expando ];
  2. data属性通过jQuery.cache[id]获取。

这个API的目的是令DOM节点不产生对JavaScript对象的直接引用。用一个安全的数字来标识。被设置的data属性在jQuery.cache中,内部的事件监听也是通过$.data()API驱动。

但是这样做有一个严重的副作用:被设置data属性的元素不能通过原生代码删除。

举个栗子:

$('<div/>')
  .html(new Array(1000).join('text')) // div with a text, maybe AJAX-loaded
  .click(function() { })
  .appendTo('#data')
document.getElementById('data').innerHTML = ''

demo

子节点伴随着父节点innerHTML=''被清除,但是被设置的data属性仍然保留在jQuery.cache中,更重要的是,此节点对应的事件监听器也被保留下来,最终结果就是:此节点与它的监听器,和整个闭包,都被保留下来,引起内存泄露。

再举个稍微简单的栗子:

function go() {
  $('<div/>')
    .html(new Array(1000).join('text')) 
    .click(function() { })
}

demo

解决方法

首先,应该使用jQuery API删除元素,如remove(),empty()html(),这些方法可以查找后裔节点的data属性并删除它们。这样做可能有点过头,但起码保证了清空了被占用的内存。

如果对性能要求比较严格,解决方法还是很纠结的。

  1. 如果你很清楚地了解哪个元素存在handler并且它们的数量不多,可以安全的使用removeData()手动清除data属性。然后就可以使用detach()方法了,detach()方法在删除元素的同时并不会清除data属性和原生方法;
  2. 如果你不喜欢第一种方法,并且DOM树非常庞大,你可以将$elem.detach()$(elem).remove()通过setTimteout执行,通过异步执行来避免视觉卡顿(evade visual sluggishnes)。

检查jQuery的内存泄露非常简单,查看$.cache可以很方便的找出问题的引发原因。

jQuery的问题讨论到此为止。

找出并修复问题

找出问题

内存泄露的方式有很多,浏览器也不断有新的bug出现。我们甚至会发现HTML5中存在功能性的泄露,为了修复它们,首先我们需要重现它们并找出解决方案。

浏览器并不会立即执行内存清除工作,许多垃圾回收器算法都是不定时地清理内存。浏览器也可能等待达到一定的限定值时再执行清理工作。

所以,如果你发现了内存泄露问题,或许你需要等待一段时间才能执行回收操作。

浏览器占用的内存可能会越来越多,但最终在一段时间之后它会进行清理工作。

Don’t take a minute of increasing memory as a loop proof if the overall footprint is still low. Add something to leaking objects. A large string will do.(这段不会翻)

准备浏览器

与网页有交互的浏览器第三方扩展可能会引起内存泄露,所以首先需要保证:

  1. 禁用Flash;
  2. 禁用杀毒软件以及与浏览器有交互的其他软件;
  3. 禁用插件。
    • IE可以通过设置命令行参数禁用插件 "C:\Program Files\Internet Explorer\iexplore.exe" -extoff 也可以在浏览器中设置:
    • Firefox可以通过下述命令来运行profile管理区创建一个空的profile: firefox --profilemanager

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏腾讯IVWEB团队的专栏

你可能不知道的 ECMAScript 2016 的变化(英译)

与 ECMAScript 6(也称为ECMAScript 2015)相比,ECMAScript 2016 是对 JavaScript 语言规范的一个小更新。 ...

2240
来自专栏Flutter入门

Flutter入门三部曲(2) - 界面开发基础

上一节我们熟悉了初始化后的flutter的界面。这一节,我们就来重点了解一下这部分的内容。

8390
来自专栏大内老A

三种属性操作性能比较:PropertyInfo + Expression Tree + Delegate.CreateDelegate

《上篇》主要介绍如何通过DataBinder实现批量的数据绑定,以及如何解决常见的数据绑定问题,比如数据的格式化。接下来,我们主要来谈谈DataBinder的设...

23610
来自专栏Jerry的SAP技术分享

ES6, Angular,React和ABAP中的String Template(字符串模板)

String Template(字符串模板)在很多编程语言和框架中都支持,是一个很有用的特性。本文将Jerry工作中使用到的String Template的特性...

1164
来自专栏Flutter入门

Vue 绑定简单分析实现

使用js es6 中 Object.defineProperty为我们自己定义的VM创建示例。同时这个方法通过提供了set.get方法的触发我们的监听事件。

1451
来自专栏AI研习社

一个快速方便的图形化 Python 调试器 —— birdseye | Github 项目推荐

Birdseye 是一个简单快速的 Python 调试器,它可以在函数的调用中记录表达式的值,并且在退出函数后轻松查看。例如: ? 它不是通过逐行浏览来查看表达...

4006
来自专栏达摩兵的技术空间

享元模式解读(1)

本文是基于《javascript设计模式与开发实践》的享元模式相关章节整理实践而出,建议阅读时间为15-25min.

963
来自专栏技术博客

Knockout.Js官网学习(selectedOptions绑定、uniqueName 绑定)

selectedOptions绑定用于控制multi-select列表已经被选择的元素,用在使用options绑定的<select>元素上。

881
来自专栏一个小程序员的成长笔记

HTML5中引入的关键特性

新特性描述 accesskey 定义通过键盘访问元素的快捷键 contenteditable 该特性设置为true时,浏览器应该允许用户编辑元素的内容...

2889
来自专栏前端大白专栏

使用react心得

1585

扫码关注云+社区

领取腾讯云代金券