内部存储器分析工具简要介绍,内部存款和储蓄
分类:前端技术

4类 JavaScript 内存泄漏及如何避免

2016/05/26 · JavaScript · 1 评论 · 内存泄漏

本文由 伯乐在线 - 涂鸦码龙 翻译。未经许可,禁止转载!
英文出处:Sebastián Peyrott。欢迎加入翻译组。

译者注:本文并没有逐字逐句的翻译,而是把我认为重要的信息做了翻译。如果您的英文熟练,可以直接阅读原文。

本文将探索常见的客户端 JavaScript 内存泄漏,以及如何使用 Chrome 开发工具发现问题。

内存泄露是每个开发者最终都不得不面对的问题。即便使用自动内存管理的语言,你还是会碰到一些内存泄漏的情况。内存泄露会导致一系列问题,比如:运行缓慢,崩溃,高延迟,甚至一些与其他应用相关的问题。

JavaScript 中 4 种常见的内存泄露陷阱

2016/11/04 · JavaScript · 内存泄漏

本文由 伯乐在线 - ARIGATO 翻译,叙帝利 校稿。未经许可,禁止转载!
英文出处:Sebastián Peyrott。欢迎加入翻译组。

 

原文地址:

简介

内存泄漏是每个开发者最终都要面对的问题,它是许多问题的根源:反应迟缓,崩溃,高延迟,以及其他应用问题。

什么是内存泄漏

JavaScript 中 4 种常见的内存泄露陷阱

了解 JavaScript 的内存泄露和解决方式!

在这篇文章中我们将要探索客户端 JavaScript 代码中常见的一些内存泄漏的情况,并且学习如何使用 Chrome 的开发工具来发现他们。读一读吧!

JavaScript 中 4 种常见的内存泄露陷阱

 

原文:Sebastián Peyrott

译文:伯乐在线专栏作者 - ARIGATO

链接:

点击 → 了解如何加入专栏作者

 

了解 JavaScript 的内存泄露和解决方式!

在这篇文章中我们将要探索客户端 JavaScript 代码中常见的一些内存泄漏的情况,并且学习如何使用 Chrome 的开发工具来发现他们。读一读吧!

什么是内存泄漏?

本质上,内存泄漏可以定义为:应用程序不再需要占用内存的时候,由于某些原因,内存没有被操作系统或可用内存池回收。编程语言管理内存的方式各不相同。只有开发者最清楚哪些内存不需要了,操作系统可以回收。一些编程语言提供了语言特性,可以帮助开发者做此类事情。另一些则寄希望于开发者对内存是否需要清晰明了。

本质上来讲,内存泄露是当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者空闲内存池的现象。编程语言使用不同的方式来管理内存。这些方式可能会减少内存泄露的机会。然而,某一块具体的内存是否被使用实际上是一个不可判定问题(undecidable problem)。换句话说,只有开发者可以搞清楚一块内存是否应该被操作系统回收。某些编程语言提供了帮助开发者来处理这件事情的特性。而其它的编程语言需要开发者明确知道内存的使用情况。维基百科上有几篇写的不错的讲述手动 和自动内存管理的文章。

介绍

内存泄露是每个开发者最终都不得不面对的问题。即便使用自动内存管理的语言,你还是会碰到一些内存泄漏的情况。内存泄露会导致一系列问题,比如:运行缓慢,崩溃,高延迟,甚至一些与其他应用相关的问题。

介绍

内存泄露是每个开发者最终都不得不面对的问题。即便使用自动内存管理的语言,你还是会碰到一些内存泄漏的情况。内存泄露会导致一系列问题,比如:运行缓慢,崩溃,高延迟,甚至一些与其他应用相关的问题。

JavaScript 内存管理

JavaScript 是一种垃圾回收语言。垃圾回收语言通过周期性地检查先前分配的内存是否可达,帮助开发者管理内存。换言之,垃圾回收语言减轻了“内存仍可用”及“内存仍可达”的问题。两者的区别是微妙而重要的:仅有开发者了解哪些内存在将来仍会使用,而不可达内存通过算法确定和标记,适时被操作系统回收。

Javascript 的内存管理

什么是内存泄漏

本质上来讲,内存泄露是当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者空闲内存池的现象。编程语言使用不同的方式来管理内存。这些方式可能会减少内存泄露的机会。然而,某一块具体的内存是否被使用实际上是一个不可判定问题(undecidable problem)。换句话说,只有开发者可以搞清楚一块内存是否应该被操作系统回收。某些编程语言提供了帮助开发者来处理这件事情的特性。而其它的编程语言需要开发者明确知道内存的使用情况。维基百科上有几篇写的不错的讲述手动和自动内存管理的文章。

什么是内存泄漏

本质上来讲,内存泄露是当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者空闲内存池的现象。编程语言使用不同的方式来管理内存。这些方式可能会减少内存泄露的机会。然而,某一块具体的内存是否被使用实际上是一个不可判定问题(undecidable problem)。换句话说,只有开发者可以搞清楚一块内存是否应该被操作系统回收。某些编程语言提供了帮助开发者来处理这件事情的特性。而其它的编程语言需要开发者明确知道内存的使用情况。维基百科上有几篇写的不错的讲述手动 和自动内存管理的文章。

JavaScript 内存泄漏

垃圾回收语言的内存泄漏主因是不需要的引用。理解它之前,还需了解垃圾回收语言如何辨别内存的可达与不可达。

Javascript 是那些被称作垃圾回收语言当中的一员。垃圾回收语言通过周期性地检查那些之前被分配出去的内存是否可以从应用的其他部分访问来帮助开发者管理内存。换句话说,垃圾回收语言将内存管理的问题从“什么样的内存是仍然被使用的?”简化成为“什么样的内存仍然可以从应用程序的其他部分访问?”。两者的区别是细微的,但是很重要:开发者只需要知道一块已分配的内存是否会在将来被使用,而不可访问的内存可以通过算法确定并标记以便返还给操作系统。

Javascript 的内存管理

Javascript 是那些被称作垃圾回收语言当中的一员。垃圾回收语言通过周期性地检查那些之前被分配出去的内存是否可以从应用的其他部分访问来帮助开发者管理内存。换句话说,垃圾回收语言将内存管理的问题从“什么样的内存是仍然被使用的?”简化成为“什么样的内存仍然可以从应用程序的其他部分访问?”。两者的区别是细微的,但是很重要:开发者只需要知道一块已分配的内存是否会在将来被使用,而不可访问的内存可以通过算法确定并标记以便返还给操作系统。

非垃圾回收语言通常使用其他的技术来管理内存,包括:显式内存管理,程序员显式地告诉编译器在何时不再需要某块内存;引用计数,一个计数器关联着每个内存块(当计数器的计数变为0的时候,这块内存就被操作系统回收)。这些技术都有它们的折中考虑(也就是说都有潜在的内存泄漏风险)。

Javascript 的内存管理

Javascript 是那些被称作垃圾回收语言当中的一员。垃圾回收语言通过周期性地检查那些之前被分配出去的内存是否可以从应用的其他部分访问来帮助开发者管理内存。换句话说,垃圾回收语言将内存管理的问题从“什么样的内存是仍然被使用的?”简化成为“什么样的内存仍然可以从应用程序的其他部分访问?”。两者的区别是细微的,但是很重要:开发者只需要知道一块已分配的内存是否会在将来被使用,而不可访问的内存可以通过算法确定并标记以便返还给操作系统。

非垃圾回收语言通常使用其他的技术来管理内存,包括:显式内存管理,程序员显式地告诉编译器在何时不再需要某块内存;引用计数,一个计数器关联着每个内存块(当计数器的计数变为0的时候,这块内存就被操作系统回收)。这些技术都有它们的折中考虑(也就是说都有潜在的内存泄漏风险)。

Mark-and-sweep

大部分垃圾回收语言用的算法称之为 Mark-and-sweep 。算法由以下几步组成:

  1. 垃圾回收器创建了一个“roots”列表。Roots 通常是代码中全局变量的引用。JavaScript 中,“window” 对象是一个全局变量,被当作 root 。window 对象总是存在,因此垃圾回收器可以检查它和它的所有子对象是否存在(即不是垃圾);
  2. 所有的 roots 被检查和标记为激活(即不是垃圾)。所有的子对象也被递归地检查。从 root 开始的所有对象如果是可达的,它就不被当作垃圾。
  3. 所有未被标记的内存会被当做垃圾,收集器现在可以释放内存,归还给操作系统了。

现代的垃圾回收器改良了算法,但是本质是相同的:可达内存被标记,其余的被当作垃圾回收。

不需要的引用是指开发者明知内存引用不再需要,却由于某些原因,它仍被留在激活的 root 树中。在 JavaScript 中,不需要的引用是保留在代码中的变量,它不再需要,却指向一块本该被释放的内存。有些人认为这是开发者的错误。

为了理解 JavaScript 中最常见的内存泄漏,我们需要了解哪种方式的引用容易被遗忘。

 

非垃圾回收语言通常使用其他的技术来管理内存,包括:显式内存管理,程序员显式地告诉编译器在何时不再需要某块内存;引用计数,一个计数器关联着每个内存块(当计数器的计数变为0的时候,这块内存就被操作系统回收)。这些技术都有它们的折中考虑(也就是说都有潜在的内存泄漏风险)。

Javascript 中的内存泄露

引起垃圾收集语言内存泄露的主要原因是不必要的引用。想要理解什么是不必要的引用,首先我们需要理解垃圾收集器是怎样确定一块内存能否被访问的。

Javascript 中的内存泄露

引起垃圾收集语言内存泄露的主要原因是不必要的引用。想要理解什么是不必要的引用,首先我们需要理解垃圾收集器是怎样确定一块内存能否被访问的。

三种类型的常见 JavaScript 内存泄漏

Javascript 中的内存泄露

Mark-and-sweep

大多数的垃圾收集器(简称 GC)使用一个叫做 mark-and-sweep 的算法。这个算法由以下的几个步骤组成:

垃圾收集器建立了一个“根节点”列表。根节点通常是那些引用被保留在代码中的全局变量。对于 Javascript 而言,“Window” 对象就是一个能作为根节点的全局变量例子。window 对象是一直都存在的(即:不是垃圾)。所有根节点都是检查过的并且被标记为活动的(即:不是垃圾)。所有的子节点也都被递归地检查过。每块可以从根节点访问的内存都不会被视为垃圾。 所有没有被标记为垃圾的内存现在可以被当做垃圾,而垃圾收集器也可以释放这些内存并将它们返还给操作系统。现代垃圾收集器使用不同的方式来改进这些算法,但是它们都有相同的本质:可以访问的内存块被标记为非垃圾而其余的就被视为垃圾。

不必要的引用就是那些程序员知道这块内存已经没用了,但是出于某种原因这块内存依然存在于活跃的根节点发出的节点树中。在 Javascript 的环境中,不必要的引用是某些不再被使用的代码中的变量。这些变量指向了一块本来可以被释放的内存。一些人认为这是程序员的失误。

所以想要理解什么是 Javascript 中最常见的内存泄露,我们需要知道在什么情况下会出现不必要的引用。

Mark-and-sweep

大多数的垃圾收集器(简称 GC)使用一个叫做 mark-and-sweep 的算法。这个算法由以下的几个步骤组成:

垃圾收集器建立了一个“根节点”列表。根节点通常是那些引用被保留在代码中的全局变量。对于 Javascript 而言,“Window” 对象就是一个能作为根节点的全局变量例子。window 对象是一直都存在的(即:不是垃圾)。所有根节点都是检查过的并且被标记为活动的(即:不是垃圾)。所有的子节点也都被递归地检查过。每块可以从根节点访问的内存都不会被视为垃圾。 所有没有被标记为垃圾的内存现在可以被当做垃圾,而垃圾收集器也可以释放这些内存并将它们返还给操作系统。现代垃圾收集器使用不同的方式来改进这些算法,但是它们都有相同的本质:可以访问的内存块被标记为非垃圾而其余的就被视为垃圾。

不必要的引用就是那些程序员知道这块内存已经没用了,但是出于某种原因这块内存依然存在于活跃的根节点发出的节点树中。在 Javascript 的环境中,不必要的引用是某些不再被使用的代码中的变量。这些变量指向了一块本来可以被释放的内存。一些人认为这是程序员的失误。

所以想要理解什么是 Javascript 中最常见的内存泄露,我们需要知道在什么情况下会出现不必要的引用。

1:意外的全局变量

JavaScript 处理未定义变量的方式比较宽松:未定义的变量会在全局对象创建一个新变量。在浏览器中,全局对象是 window 。

JavaScript

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

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

真相是:

JavaScript

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

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

函数 foo 内部忘记使用 var ,意外创建了一个全局变量。此例泄漏了一个简单的字符串,无伤大雅,但是有更糟的情况。

另一种意外的全局变量可能由 this 创建:

JavaScript

function foo() { this.variable = "potential accidental global"; } // Foo 调用自己,this 指向了全局对象(window) // 而不是 undefined foo();

1
2
3
4
5
6
7
function foo() {
    this.variable = "potential accidental global";
}
 
// Foo 调用自己,this 指向了全局对象(window)
// 而不是 undefined
foo();

在 JavaScript 文件头部加上 ‘use strict’,可以避免此类错误发生。启用严格模式解析 JavaScript ,避免意外的全局变量。

全局变量注意事项

尽管我们讨论了一些意外的全局变量,但是仍有一些明确的全局变量产生的垃圾。它们被定义为不可回收(除非定义为空或重新分配)。尤其当全局变量用于临时存储和处理大量信息时,需要多加小心。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。与全局变量相关的增加内存消耗的一个主因是缓存。缓存数据是为了重用,缓存必须有一个大小上限才有用。高内存消耗导致缓存突破上限,因为缓存内容无法被回收。

引起垃圾收集语言内存泄露的主要原因是不必要的引用。想要理解什么是不必要的引用,首先我们需要理解垃圾收集器是怎样确定一块内存能否被访问的。

3 种常见的 Javascript 内存泄露

3 种常见的 Javascript 内存泄露

2:被遗忘的计时器或回调函数

在 JavaScript 中使用 setInterval 非常平常。一段常见的代码:

JavaScript

var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // 处理 node 和 someResource node.innerHTML = JSON.stringify(someResource)); } }, 1000);

1
2
3
4
5
6
7
8
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 处理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

此例说明了什么:与节点或数据关联的计时器不再需要,node 对象可以删除,整个回调函数也不需要了。可是,计时器回调函数仍然没被回收(计时器停止才会被回收)。同时,someResource 如果存储了大量的数据,也是无法被回收的。

对于观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。如今,即使没有明确移除它们,一旦观察者对象变成不可达,大部分浏览器是可以回收观察者处理函数的。

观察者代码示例:

JavaScript

var element = document.getElementById('button'); function onClick(event) { element.innerHTML = 'text'; } element.addEventListener('click', onClick);

1
2
3
4
5
6
var element = document.getElementById('button');
function onClick(event) {
    element.innerHTML = 'text';
}
 
element.addEventListener('click', onClick);

对象观察者和循环引用注意事项

老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。如今,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法,已经可以正确检测和处理循环引用了。换言之,回收节点内存时,不必非要调用 removeEventListener 了。

Mark-and-sweep

1: 意外的全局变量

Javascript 语言的设计目标之一是开发一种类似于 Java 但是对初学者十分友好的语言。体现 JavaScript 宽容性的一点表现在它处理未声明变量的方式上:一个未声明变量的引用会在全局对象中创建一个新的变量。在浏览器的环境下,全局对象就是 window,也就是说:

JavaScript

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

1
2
3
4
function foo(arg) {
    bar = "this is a hidden global variable";
}
 

实际上是:

JavaScript

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

1
2
3
4
function foo(arg) {
    window.bar = "this is an explicit global variable";
}
 

如果 bar 是一个应该指向 foo 函数作用域内变量的引用,但是你忘记使用 var 来声明这个变量,这时一个全局变量就会被创建出来。在这个例子中,一个简单的字符串泄露并不会造成很大的危害,但这无疑是错误的。

另外一种偶然创建全局变量的方式如下:

JavaScript

function foo() { this.variable = "potential accidental global"; } // Foo called on its own, this points to the global object (window) // rather than being undefined. // 函数自身发生了调用,this 指向全局对象(window),(译者注:这时候会为全局对象 window 添加一个 variable 属性)而不是 undefined。 foo();

1
2
3
4
5
6
7
8
9
function foo() {
    this.variable = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
// 函数自身发生了调用,this 指向全局对象(window),(译者注:这时候会为全局对象 window 添加一个 variable 属性)而不是 undefined。
 
foo();
 

为了防止这种错误的发生,可以在你的 JavaScript 文件开头添加 'use strict'; 语句。这个语句实际上开启了解释 JavaScript 代码的严格模式,这种模式可以避免创建意外的全局变量。

全局变量的注意事项

尽管我们在讨论那些隐蔽的全局变量,但是也有很多代码被明确的全局变量污染的情况。按照定义来讲,这些都是不会被回收的变量(除非设置 null 或者被重新赋值)。特别需要注意的是那些被用来临时存储和处理一些大量的信息的全局变量。如果你必须使用全局变量来存储很多的数据,请确保在使用过后将它设置为 null 或者将它重新赋值。常见的和全局变量相关的引发内存消耗增长的原因就是缓存。缓存存储着可复用的数据。为了让这种做法更高效,必须为缓存的容量规定一个上界。由于缓存不能被及时回收的缘故,缓存无限制地增长会导致很高的内存消耗。

1: 意外的全局变量

Javascript 语言的设计目标之一是开发一种类似于 Java 但是对初学者十分友好的语言。体现 JavaScript 宽容性的一点表现在它处理未声明变量的方式上:一个未声明变量的引用会在全局对象中创建一个新的变量。在浏览器的环境下,全局对象就是 window,也就是说:

function foo(arg) {

    bar = "this is a hidden global variable";

}

 

实际上是:

function foo(arg) {

    window.bar = "this is an explicit global variable";

}

 

如果 bar 是一个应该指向 foo 函数作用域内变量的引用,但是你忘记使用 var 来声明这个变量,这时一个全局变量就会被创建出来。在这个例子中,一个简单的字符串泄露并不会造成很大的危害,但这无疑是错误的。

另外一种偶然创建全局变量的方式如下:

function foo() {

    this.variable = "potential accidental global";

}

// Foo called on its own, this points to the global object (window)

// rather than being undefined.

// 函数自身发生了调用,this 指向全局对象(window),(译者注:这时候会为全局对象 window 添加一个 variable 属性)而不是 undefined。

 

foo();

 

为了防止这种错误的发生,可以在你的 JavaScript 文件开头添加 'use strict'; 语句。这个语句实际上开启了解释 JavaScript 代码的严格模式,这种模式可以避免创建意外的全局变量。

全局变量的注意事项

尽管我们在讨论那些隐蔽的全局变量,但是也有很多代码被明确的全局变量污染的情况。按照定义来讲,这些都是不会被回收的变量(除非设置 null 或者被重新赋值)。特别需要注意的是那些被用来临时存储和处理一些大量的信息的全局变量。如果你必须使用全局变量来存储很多的数据,请确保在使用过后将它设置为 null 或者将它重新赋值。常见的和全局变量相关的引发内存消耗增长的原因就是缓存。缓存存储着可复用的数据。为了让这种做法更高效,必须为缓存的容量规定一个上界。由于缓存不能被及时回收的缘故,缓存无限制地增长会导致很高的内存消耗。

3:脱离 DOM 的引用

有时,保存 DOM 节点内部数据结构很有用。假如你想快速更新表格的几行内容,把每一行 DOM 存成字典(JSON 键值对)或者数组很有意义。此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。将来你决定删除这些行时,需要把两个引用都清除。

JavaScript

var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') }; function doStuff() { image.src = ''; button.click(); console.log(text.innerHTML); // 更多逻辑 } function removeButton() { // 按钮是 body 的后代元素 document.body.removeChild(document.getElementById('button')); // 此时,仍旧存在一个全局的 #button 的引用 // elements 字典。button 元素仍旧在内存中,不能被 GC 回收。 }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};
 
function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // 更多逻辑
}
 
function removeButton() {
    // 按钮是 body 的后代元素
    document.body.removeChild(document.getElementById('button'));
 
    // 此时,仍旧存在一个全局的 #button 的引用
    // elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
}

此外还要考虑 DOM 树内部或子节点的引用问题。假如你的 JavaScript 代码中保存了表格某一个 <td> 的引用。将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 <td> 以外的其它节点。实际情况并非如此:此<td> 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td> 的引用,导致整个表格仍待在内存中。保存 DOM 元素引用的时候,要小心谨慎。

大多数的垃圾收集器(简称 GC)使用一个叫做 mark-and-sweep 的算法。这个算法由以下的几个步骤组成:

2: 被遗漏的定时器和回调函数

在 JavaScript 中 setInterval 的使用十分常见。其他的库也经常会提供观察者和其他需要回调的功能。这些库中的绝大部分都会关注一点,就是当它们本身的实例被销毁之前销毁所有指向回调的引用。在 setInterval 这种情况下,一般情况下的代码是这样的:

JavaScript

var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // Do stuff with node and someResource. node.innerHTML = JSON.stringify(someResource)); } }, 1000);

1
2
3
4
5
6
7
8
9
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // Do stuff with node and someResource.
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);
 

这个例子说明了摇晃的定时器会发生什么:引用节点或者数据的定时器已经没用了。那些表示节点的对象在将来可能会被移除掉,所以将整个代码块放在周期处理函数中并不是必要的。然而,由于周期函数一直在运行,处理函数并不会被回收(只有周期函数停止运行之后才开始回收内存)。如果周期处理函数不能被回收,它的依赖程序也同样无法被回收。这意味着一些资源,也许是一些相当大的数据都也无法被回收。

下面举一个观察者的例子,当它们不再被需要的时候(或者关联对象将要失效的时候)显式地将他们移除是十分重要的。在以前,尤其是对于某些浏览器(IE6)是一个至关重要的步骤,因为它们不能很好地管理循环引用(下面的代码描述了更多的细节)。现在,当观察者对象失效的时候便会被回收,即便 listener 没有被明确地移除,绝大多数的浏览器可以或者将会支持这个特性。尽管如此,在对象被销毁之前移除观察者依然是一个好的实践。示例如下:

JavaScript

var element = document.getElementById('button'); function onClick(event) { element.innerHtml = 'text'; } 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var element = document.getElementById('button');
 
function onClick(event) {
    element.innerHtml = 'text';
}
 
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.
 

对象观察者和循环引用中一些需要注意的点

观察者和循环引用常常会让 JavaScript 开发者踩坑。以前在 IE 浏览器的垃圾回收器上会导致一个 bug(或者说是浏览器设计上的问题)。旧版本的 IE 浏览器不会发现 DOM 节点和 JavaScript 代码之间的循环引用。这是一种观察者的典型情况,观察者通常保留着一个被观察者的引用(正如上述例子中描述的那样)。换句话说,在 IE 浏览器中,每当一个观察者被添加到一个节点上时,就会发生一次内存泄漏。这也就是开发者在节点或者空的引用被添加到观察者中之前显式移除处理方法的原因。目前,现代的浏览器(包括 IE 和 Microsoft Edge)都使用了可以发现这些循环引用并正确的处理它们的现代化垃圾回收算法。换言之,严格地讲,在废弃一个节点之前调用 removeEventListener 不再是必要的操作。

像是 jQuery 这样的框架和库(当使用一些特定的 API 时候)都在废弃一个结点之前移除了 listener 。它们在内部就已经处理了这些事情,并且保证不会产生内存泄露,即便程序运行在那些问题很多的浏览器中,比如老版本的 IE。

2: 被遗漏的定时器和回调函数

在 JavaScript 中 setInterval 的使用十分常见。其他的库也经常会提供观察者和其他需要回调的功能。这些库中的绝大部分都会关注一点,就是当它们本身的实例被销毁之前销毁所有指向回调的引用。在 setInterval 这种情况下,一般情况下的代码是这样的:

var someResource = getData();

setInterval(function() {

    var node = document.getElementById('Node');

    if(node) {

        // Do stuff with node and someResource.

        node.innerHTML = JSON.stringify(someResource));

    }

}, 1000);

 

这个例子说明了摇晃的定时器会发生什么:引用节点或者数据的定时器已经没用了。那些表示节点的对象在将来可能会被移除掉,所以将整个代码块放在周期处理函数中并不是必要的。然而,由于周期函数一直在运行,处理函数并不会被回收(只有周期函数停止运行之后才开始回收内存)。如果周期处理函数不能被回收,它的依赖程序也同样无法被回收。这意味着一些资源,也许是一些相当大的数据都也无法被回收。

下面举一个观察者的例子,当它们不再被需要的时候(或者关联对象将要失效的时候)显式地将他们移除是十分重要的。在以前,尤其是对于某些浏览器(IE6)是一个至关重要的步骤,因为它们不能很好地管理循环引用(下面的代码描述了更多的细节)。现在,当观察者对象失效的时候便会被回收,即便 listener 没有被明确地移除,绝大多数的浏览器可以或者将会支持这个特性。尽管如此,在对象被销毁之前移除观察者依然是一个好的实践。示例如下:

var element = document.getElementById('button');

 

function onClick(event) {

    element.innerHtml = 'text';

}

 

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.

 

对象观察者和循环引用中一些需要注意的点

观察者和循环引用常常会让 JavaScript 开发者踩坑。以前在 IE 浏览器的垃圾回收器上会导致一个 bug(或者说是浏览器设计上的问题)。旧版本的 IE 浏览器不会发现 DOM 节点和 JavaScript 代码之间的循环引用。这是一种观察者的典型情况,观察者通常保留着一个被观察者的引用(正如上述例子中描述的那样)。换句话说,在 IE 浏览器中,每当一个观察者被添加到一个节点上时,就会发生一次内存泄漏。这也就是开发者在节点或者空的引用被添加到观察者中之前显式移除处理方法的原因。目前,现代的浏览器(包括 IE 和 Microsoft Edge)都使用了可以发现这些循环引用并正确的处理它们的现代化垃圾回收算法。换言之,严格地讲,在废弃一个节点之前调用 removeEventListener 不再是必要的操作。

像是 jQuery 这样的框架和库(当使用一些特定的 API 时候)都在废弃一个结点之前移除了 listener 。它们在内部就已经处理了这些事情,并且保证不会产生内存泄露,即便程序运行在那些问题很多的浏览器中,比如老版本的 IE。

4:闭包

闭包是 JavaScript 开发的一个关键方面:匿名函数可以访问父级作用域的变量。

代码示例:

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);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 的闭包(先前的 replaceThing 又调用了 theThing )。思绪混乱了吗?最重要的事情是,闭包的作用域一旦创建,它们有同样的父级作用域,作用域是共享的。someMethod 可以通过 theThing 使用,someMethod 与 unused 分享闭包作用域,尽管 unused 从未使用,它引用的 originalThing 迫使它保留在内存中(防止被回收)。当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。本质上,闭包的链表已经创建,每一个闭包作用域携带一个指向大数组的间接的引用,造成严重的内存泄漏。

Meteor 的博文 解释了如何修复此种问题。在 replaceThing 的最后添加 originalThing = null 。

垃圾收集器建立了一个“根节点”列表。根节点通常是那些引用被保留在代码中的全局变量。对于 Javascript 而言,“Window” 对象就是一个能作为根节点的全局变量例子。window 对象是一直都存在的(即:不是垃圾)。所有根节点都是检查过的并且被标记为活动的(即:不是垃圾)。所有的子节点也都被递归地检查过。每块可以从根节点访问的内存都不会被视为垃圾。 所有没有被标记为垃圾的内存现在可以被当做垃圾,而垃圾收集器也可以释放这些内存并将它们返还给操作系统。现代垃圾收集器使用不同的方式来改进这些算法,但是它们都有相同的本质:可以访问的内存块被标记为非垃圾而其余的就被视为垃圾。

3: DOM 之外的引用

有些情况下将 DOM 结点存储到数据结构中会十分有用。假设你想要快速地更新一个表格中的几行,如果你把每一行的引用都存储在一个字典或者数组里面会起到很大作用。如果你这么做了,程序中将会保留同一个结点的两个引用:一个引用存在于 DOM 树中,另一个被保留在字典中。如果在未来的某个时刻你决定要将这些行移除,则需要将所有的引用清除。

JavaScript

var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') }; function doStuff() { image.src = ''; button.click(); console.log(text.innerHTML); // Much more logic } function removeButton() { // The button is a direct child of body. document.body.removeChild(document.getElementById('button')); // At this point, we still have a reference to #button in the global // elements dictionary. In other words, the button element is still in // memory and cannot be collected by the GC. }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};
 
function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}
 
function removeButton() {
    // The button is a direct child of body.
    document.body.removeChild(document.getElementById('button'));
 
    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.
}
 

还需要考虑另一种情况,就是对 DOM 树子节点的引用。假设你在 JavaScript 代码中保留了一个表格中特定单元格(一个 <td>标签)的引用。在将来你决定将这个表格从 DOM 中移除,但是仍旧保留这个单元格的引用。凭直觉,你可能会认为 GC 会回收除了这个单元格之外所有的东西,但是实际上这并不会发生:单元格是表格的一个子节点且所有子节点都保留着它们父节点的引用。换句话说,JavaScript 代码中对单元格的引用导致整个表格被保留在内存中。所以当你想要保留 DOM 元素的引用时,要仔细的考虑清除这一点。

3: DOM 之外的引用

有些情况下将 DOM 结点存储到数据结构中会十分有用。假设你想要快速地更新一个表格中的几行,如果你把每一行的引用都存储在一个字典或者数组里面会起到很大作用。如果你这么做了,程序中将会保留同一个结点的两个引用:一个引用存在于 DOM 树中,另一个被保留在字典中。如果在未来的某个时刻你决定要将这些行移除,则需要将所有的引用清除。

var elements = {

    button: document.getElementById('button'),

    image: document.getElementById('image'),

    text: document.getElementById('text')

};

 

function doStuff() {

    image.src = '';

    button.click();

    console.log(text.innerHTML);

    // Much more logic

}

 

function removeButton() {

    // The button is a direct child of body.

    document.body.removeChild(document.getElementById('button'));

 

    // At this point, we still have a reference to #button in the global

    // elements dictionary. In other words, the button element is still in

    // memory and cannot be collected by the GC.

}

 

还需要考虑另一种情况,就是对 DOM 树子节点的引用。假设你在 JavaScript 代码中保留了一个表格中特定单元格(一个 <td>标签)的引用。在将来你决定将这个表格从 DOM 中移除,但是仍旧保留这个单元格的引用。凭直觉,你可能会认为 GC 会回收除了这个单元格之外所有的东西,但是实际上这并不会发生:单元格是表格的一个子节点且所有子节点都保留着它们父节点的引用。换句话说,JavaScript 代码中对单元格的引用导致整个表格被保留在内存中。所以当你想要保留 DOM 元素的引用时,要仔细的考虑清除这一点。

Chrome 内存剖析工具概览

Chrome 提供了一套很棒的检测 JavaScript 内存占用的工具。与内存相关的两个重要的工具:timeline 和 profiles。

不必要的引用就是那些程序员知道这块内存已经没用了,但是出于某种原因这块内存依然存在于活跃的根节点发出的节点树中。在 Javascript 的环境中,不必要的引用是某些不再被使用的代码中的变量。这些变量指向了一块本来可以被释放的内存。一些人认为这是程序员的失误。

4: 闭包

JavaScript 开发中一个重要的内容就是闭包,它是可以获取父级作用域的匿名函数。Meteor 的开发者发现在一种特殊情况下有可能会以一种很微妙的方式产生内存泄漏,这取决于 JavaScript 运行时的实现细节。

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);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)的对象。同时,没有用到的那个变量持有一个引用了 originalThingreplaceThing 调用之前的 theThing)闭包。哈,是不是已经有点晕了?关键的问题是每当在同一个父作用域下创建闭包作用域的时候,这个作用域是被共享的。在这种情况下,someMethod 的闭包作用域和 unused 的作用域是共享的。unused 持有一个 originalThing 的引用。尽管 unused 从来没有被使用过,someMethod 可以在 theThing 之外被访问。而且 someMethodunused 共享了闭包作用域,即便 unused 从来都没有被使用过,它对 originalThing 的引用还是强制它保持活跃状态(阻止它被回收)。当这段代码重复运行时,将可以观察到内存消耗稳定地上涨,并且不会因为 GC 的存在而下降。本质上来讲,创建了一个闭包链表(根节点是 theThing 形式的变量),而且每个闭包作用域都持有一个对大数组的间接引用,这导致了一个巨大的内存泄露。

这是一种人为的实现方式。可以想到一个能够解决这个问题的不同的闭包实现,就像 Metero 的博客里面说的那样。

4: 闭包

JavaScript 开发中一个重要的内容就是闭包,它是可以获取父级作用域的匿名函数。Meteor 的开发者发现在一种特殊情况下有可能会以一种很微妙的方式产生内存泄漏,这取决于 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)的对象。同时,没有用到的那个变量持有一个引用了 originalThingreplaceThing 调用之前的 theThing)闭包。哈,是不是已经有点晕了?关键的问题是每当在同一个父作用域下创建闭包作用域的时候,这个作用域是被共享的。在这种情况下,someMethod 的闭包作用域和 unused 的作用域是共享的。unused 持有一个 originalThing 的引用。尽管 unused 从来没有被使用过,someMethod 可以在 theThing 之外被访问。而且 someMethod 和 unused 共享了闭包作用域,即便 unused 从来都没有被使用过,它对 originalThing 的引用还是强制它保持活跃状态(阻止它被回收)。当这段代码重复运行时,将可以观察到内存消耗稳定地上涨,并且不会因为 GC 的存在而下降。本质上来讲,创建了一个闭包链表(根节点是 theThing 形式的变量),而且每个闭包作用域都持有一个对大数组的间接引用,这导致了一个巨大的内存泄露。

这是一种人为的实现方式。可以想到一个能够解决这个问题的不同的闭包实现,就像 Metero 的博客里面说的那样。

Timeline

图片 1

timeline 可以检测代码中不需要的内存。在此截图中,我们可以看到潜在的泄漏对象稳定的增长,数据采集快结束时,内存占用明显高于采集初期,Node(节点)的总量也很高。种种迹象表明,代码中存在 DOM 节点泄漏的情况。

所以想要理解什么是 Javascript 中最常见的内存泄露,我们需要知道在什么情况下会出现不必要的引用。

垃圾收集器的直观行为

尽管垃圾收集器是便利的,但是使用它们也需要有一些利弊权衡。其中之一就是不确定性。也就是说,GC 的行为是不可预测的。通常情况下都不能确定什么时候会发生垃圾回收。这意味着在一些情形下,程序会使用比实际需要更多的内存。有些的情况下,在很敏感的应用中可以观察到明显的卡顿。尽管不确定性意味着你无法确定什么时候垃圾回收会发生,不过绝大多数的 GC 实现都会在内存分配时遵从通用的垃圾回收过程模式。如果没有内存分配发生,大部分的 GC 都会保持静默。考虑以下的情形:

  1. 大量内存分配发生时。
  2. 大部分(或者全部)的元素都被标记为不可达(假设我们讲一个指向无用缓存的引用置 null 的时候)。
  3. 没有进一步的内存分配发生。

这个情形下,GC 将不会运行任何进一步的回收过程。也就是说,尽管有不可达的引用可以触发回收,但是收集器并不要求回收它们。严格的说这些不是内存泄露,但仍然导致高于正常情况的内存空间使用。

Google 在它们的 JavaScript 内存分析文档中提供一个关于这个行为的优秀例子,见示例#2.

垃圾收集器的直观行为

尽管垃圾收集器是便利的,但是使用它们也需要有一些利弊权衡。其中之一就是不确定性。也就是说,GC 的行为是不可预测的。通常情况下都不能确定什么时候会发生垃圾回收。这意味着在一些情形下,程序会使用比实际需要更多的内存。有些的情况下,在很敏感的应用中可以观察到明显的卡顿。尽管不确定性意味着你无法确定什么时候垃圾回收会发生,不过绝大多数的 GC 实现都会在内存分配时遵从通用的垃圾回收过程模式。如果没有内存分配发生,大部分的 GC 都会保持静默。考虑以下的情形:

  1. 大量内存分配发生时。

  2. 大部分(或者全部)的元素都被标记为不可达(假设我们讲一个指向无用缓存的引用置 null 的时候)。

  3. 没有进一步的内存分配发生。

这个情形下,GC 将不会运行任何进一步的回收过程。也就是说,尽管有不可达的引用可以触发回收,但是收集器并不要求回收它们。严格的说这些不是内存泄露,但仍然导致高于正常情况的内存空间使用。

Google 在它们的 JavaScript 内存分析文档中提供一个关于这个行为的优秀例子,见示例#2.

Profiles

图片 2

Profiles 是你可以花费大量时间关注的工具,它可以保存快照,对比 JavaScript 代码内存使用的不同快照,也可以记录时间分配。每一次结果包含不同类型的列表,与内存泄漏相关的有 summary(概要) 列表和 comparison(对照) 列表。

summary(概要) 列表展示了不同类型对象的分配及合计大小:shallow size(特定类型的所有对象的总大小),retained size(shallow size 加上其它与此关联的对象大小)。它还提供了一个概念,一个对象与关联的 GC root 的距离。

对比不同的快照的 comparison list 可以发现内存泄漏。

3 种常见的 Javascript 内存泄露

Chrome 内存分析工具简介

Chrome 提供了一套很好的工具用来分析 JavaScript 的内存适用。这里有两个与内存相关的重要视图:timeline 视图和 profiles 视图。

Chrome 内存分析工具简介

Chrome 提供了一套很好的工具用来分析 JavaScript 的内存适用。这里有两个与内存相关的重要视图:timeline 视图和 profiles 视图。

实例:使用 Chrome 发现内存泄漏

实质上有两种类型的泄漏:周期性的内存增长导致的泄漏,以及偶现的内存泄漏。显而易见,周期性的内存泄漏很容易发现;偶现的泄漏比较棘手,一般容易被忽视,偶尔发生一次可能被认为是优化问题,周期性发生的则被认为是必须解决的 bug。

以 Chrome 文档中的代码为例:

JavaScript

var x = []; function createSomeNodes() { var div, i = 100, frag = document.createDocumentFragment(); for (;i > 0; i--) { div = document.createElement("div"); div.appendChild(document.createTextNode(i

  • " - " new Date().toTimeString())); frag.appendChild(div); } document.getElementById("nodes").appendChild(frag); } function grow() { x.push(new Array(1000000).join('x')); createSomeNodes(); setTimeout(grow,1000); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var x = [];
 
function createSomeNodes() {
    var div,
        i = 100,
        frag = document.createDocumentFragment();
 
    for (;i > 0; i--) {
        div = document.createElement("div");
        div.appendChild(document.createTextNode(i " - " new Date().toTimeString()));
        frag.appendChild(div);
    }
 
    document.getElementById("nodes").appendChild(frag);
}
 
function grow() {
    x.push(new Array(1000000).join('x'));
    createSomeNodes();
    setTimeout(grow,1000);
}

当 grow 执行的时候,开始创建 div 节点并插入到 DOM 中,并且给全局变量分配一个巨大的数组。通过以上提到的工具可以检测到内存稳定上升。

1: 意外的全局变量

Timeline view

图片 3

timeline 视图是我们用于发现不正常内存模式的必要工具。当我们寻找严重的内存泄漏时,内存回收发生后产生的周期性的不会消减的内存跳跃式增长会被一面红旗标记。在这个截图里面我们可以看到,这很像是一个稳定的对象内存泄露。即便最后经历了一个很大的内存回收,它占用的内存依旧比开始时多得多。节点数也比开始要高。这些都是代码中某处 DOM 节点内存泄露的标志。

Timeline view

图片 4

timeline 视图是我们用于发现不正常内存模式的必要工具。当我们寻找严重的内存泄漏时,内存回收发生后产生的周期性的不会消减的内存跳跃式增长会被一面红旗标记。在这个截图里面我们可以看到,这很像是一个稳定的对象内存泄露。即便最后经历了一个很大的内存回收,它占用的内存依旧比开始时多得多。节点数也比开始要高。这些都是代码中某处 DOM 节点内存泄露的标志。

找出周期性增长的内存

timeline 标签擅长做这些。在 Chrome 中打开例子,打开 Dev Tools ,切换到 timeline,勾选 memory 并点击记录按钮,然后点击页面上的 The Button 按钮。过一阵停止记录看结果:

图片 5

两种迹象显示出现了内存泄漏,图中的 Nodes(绿线)和 JS heap(蓝线)。Nodes 稳定增长,并未下降,这是个显著的信号。

JS heap 的内存占用也是稳定增长。由于垃圾收集器的影响,并不那么容易发现。图中显示内存占用忽涨忽跌,实际上每一次下跌之后,JS heap 的大小都比原先大了。换言之,尽管垃圾收集器不断的收集内存,内存还是周期性的泄漏了。

确定存在内存泄漏之后,我们找找根源所在。

Javascript 语言的设计目标之一是开发一种类似于 Java 但是对初学者十分友好的语言。体现 JavaScript 宽容性的一点表现在它处理未声明变量的方式上:一个未声明变量的引用会在全局对象中创建一个新的变量。在浏览器的环境下,全局对象就是 window,也就是说:

Profiles 视图

图片 6

你将会花费大部分的时间在观察这个视图上。profiles 视图让你可以对 JavaScript 代码运行时的内存进行快照,并且可以比较这些内存快照。它还让你可以记录一段时间内的内存分配情况。在每一个结果视图中都可以展示不同类型的列表,但是对我们的任务最有用的是 summary 列表和 comparison 列表。

summary 视图提供了不同类型的分配对象以及它们的合计大小:shallow size (一个特定类型的所有对象的总和)和 retained size (shallow size 加上保留此对象的其它对象的大小)。distance 显示了对象到达 GC 根(校者注:最初引用的那块内存,具体内容可自行搜索该术语)的最短距离。

comparison 视图提供了同样的信息但是允许对比不同的快照。这对于找到泄露很有帮助。

Profiles 视图

图片 7

你将会花费大部分的时间在观察这个视图上。profiles 视图让你可以对 JavaScript 代码运行时的内存进行快照,并且可以比较这些内存快照。它还让你可以记录一段时间内的内存分配情况。在每一个结果视图中都可以展示不同类型的列表,但是对我们的任务最有用的是 summary 列表和 comparison 列表。

summary 视图提供了不同类型的分配对象以及它们的合计大小:shallow size (一个特定类型的所有对象的总和)和 retained size (shallow size 加上保留此对象的其它对象的大小)。distance 显示了对象到达 GC 根(校者注:最初引用的那块内存,具体内容可自行搜索该术语)的最短距离。

comparison 视图提供了同样的信息但是允许对比不同的快照。这对于找到泄露很有帮助。

保存两个快照

切换到 Chrome Dev Tools 的 profiles 标签,刷新页面,等页面刷新完成之后,点击 Take Heap Snapshot 保存快照作为基准。而后再次点击 The Button 按钮,等数秒以后,保存第二个快照。

图片 8

筛选菜单选择 Summary,右侧选择 Objects allocated between Snapshot 1 and Snapshot 2,或者筛选菜单选择 Comparison ,然后可以看到一个对比列表。

此例很容易找到内存泄漏,看下 (string) 的 Size Delta Constructor,8MB,58个新对象。新对象被分配,但是没有释放,占用了8MB。

如果展开 (string) Constructor,会看到许多单独的内存分配。选择某一个单独的分配,下面的 retainers 会吸引我们的注意。

图片 9

我们已选择的分配是数组的一部分,数组关联到 window 对象的 x 变量。这里展示了从巨大对象到无法回收的 root(window)的完整路径。我们已经找到了潜在的泄漏以及它的出处。

我们的例子还算简单,只泄漏了少量的 DOM 节点,利用以上提到的快照很容易发现。对于更大型的网站,Chrome 还提供了 Record Heap Allocations 功能。

function foo(arg) {

举例: 使用 Chrome 来发现内存泄露

 

有两个重要类型的内存泄露:引起内存周期性增长的泄露和只发生一次且不引起更进一步内存增长的泄露。显而易见的是,寻找周期性的内存泄漏是更简单的。这些也是最麻烦的事情:如果内存会按时增长,泄露最终将导致浏览器变慢或者停止执行脚本。很明显的非周期性大量内存泄露可以很容易的在其他内存分配中被发现。但是实际情况并不如此,往往这些泄露都是不足以引起注意的。这种情况下,小的非周期性内存泄露可以被当做一个优化点。然而那些周期性的内存泄露应该被视为 bug 并且必须被修复。

为了举例,我们将会使用 Chrome 的文档中提供的一个例子。完整的代码在下面可以找到:

JavaScript

var x = []; function createSomeNodes() { var div, i = 100, frag = document.createDocumentFragment(); for (;i > 0; i--) { div = document.createElement("div"); div.appendChild(document.createTextNode(i

  • " - " new Date().toTimeString())); frag.appendChild(div); } document.getElementById("nodes").appendChild(frag); } function grow() { x.push(new Array(1000000).join('x')); createSomeNodes(); setTimeout(grow,1000); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var x = [];
 
function createSomeNodes() {
    var div,
        i = 100,
        frag = document.createDocumentFragment();
    for (;i &gt; 0; i--) {
        div = document.createElement("div");
        div.appendChild(document.createTextNode(i " - " new Date().toTimeString()));
        frag.appendChild(div);
    }
    document.getElementById("nodes").appendChild(frag);
}
function grow() {
    x.push(new Array(1000000).join('x'));
    createSomeNodes();
    setTimeout(grow,1000);
}
 

当调用 grow 的时候,它会开始创建 div 节点并且把他们追加到 DOM 上。它将会分配一个大数组并将它追加到一个全局数组中。这将会导致内存的稳定增长,使用上面提到的工具可以观察到这一点。

垃圾收集语言通常表现出内存用量的抖动。如果代码在一个发生分配的循环中运行时,这是很常见的。我们将要寻找那些在内存分配之后周期性且不会回落的内存增长。

举例: 使用 Chrome 来发现内存泄露

有两个重要类型的内存泄露:引起内存周期性增长的泄露和只发生一次且不引起更进一步内存增长的泄露。显而易见的是,寻找周期性的内存泄漏是更简单的。这些也是最麻烦的事情:如果内存会按时增长,泄露最终将导致浏览器变慢或者停止执行脚本。很明显的非周期性大量内存泄露可以很容易的在其他内存分配中被发现。但是实际情况并不如此,往往这些泄露都是不足以引起注意的。这种情况下,小的非周期性内存泄露可以被当做一个优化点。然而那些周期性的内存泄露应该被视为 bug 并且必须被修复。

为了举例,我们将会使用 Chrome 的文档中提供的一个例子。完整的代码在下面可以找到:

var x = [];

 

function createSomeNodes() {

    var div,

        i = 100,

        frag = document.createDocumentFragment();

    for (;i > 0; i--) {

        div = document.createElement("div");

        div.appendChild(document.createTextNode(i " - " new Date().toTimeString()));

        frag.appendChild(div);

    }

    document.getElementById("nodes").appendChild(frag);

}

function grow() {

    x.push(new Array(1000000).join('x'));

    createSomeNodes();

    setTimeout(grow,1000);

}

 

当调用 grow 的时候,它会开始创建 div 节点并且把他们追加到 DOM 上。它将会分配一个大数组并将它追加到一个全局数组中。这将会导致内存的稳定增长,使用上面提到的工具可以观察到这一点。

垃圾收集语言通常表现出内存用量的抖动。如果代码在一个发生分配的循环中运行时,这是很常见的。我们将要寻找那些在内存分配之后周期性且不会回落的内存增长。

Record heap allocations 找内存泄漏

回到 Chrome Dev Tools 的 profiles 标签,点击 Record Heap Allocations。工具运行的时候,注意顶部的蓝条,代表了内存分配,每一秒有大量的内存分配。运行几秒以后停止。

图片 10

上图中可以看到工具的杀手锏:选择某一条时间线,可以看到这个时间段的内存分配情况。尽可能选择接近峰值的时间线,下面的列表仅显示了三种 constructor:其一是泄漏最严重的(string),下一个是关联的 DOM 分配,最后一个是 Text constructor(DOM 叶子节点包含的文本)。

从列表中选择一个 HTMLDivElement constructor,然后选择 Allocation stack。

图片 11

现在知道元素被分配到哪里了吧(grow -> createSomeNodes),仔细观察一下图中的时间线,发现 HTMLDivElement constructor 调用了许多次,意味着内存一直被占用,无法被 GC 回收,我们知道了这些对象被分配的确切位置(createSomeNodes)。回到代码本身,探讨下如何修复内存泄漏吧。

   bar = "this is a hidden global variable";

查看内存是否周期性增长

对于这个问题,timeline 视图最合适不过了。在 Chrome 中运行这个例子,打开开发者工具,定位到 timeline,选择内存并且点击记录按钮。然后去到那个页面点击按钮开始内存泄露。一段时间后停止记录,然后观察结果:

图片 12

这个例子中每秒都会发生一次内存泄露。记录停止后,在 grow 函数中设置一个断点来防止 Chrome 强制关闭这个页面。

在图中有两个明显的标志表明我们正在泄漏内存。节点的图表(绿色的线)和 JS 堆内存(蓝色的线)。节点数稳定地增长并且从不减少。这是一个明显的警告标志。

JS 堆内存表现出稳定的内存用量增长。由于垃圾回收器的作用,这很难被发现。你能看到一个初始内存的增长的图线,紧接着有一个很大的回落,接着又有一段增长然后出现了一个峰值,接着又是一个回落。这个情况的关键是在于一个事实,即每次内存用量回落时候,堆内存总是比上一次回落后的内存占用量更多。也就是说,尽管垃圾收集器成功地回收了很多的内存,还是有一部分内存周期性的泄露了。

我们现在确定程序中有一个泄露,让我们一起找到它。

查看内存是否周期性增长

对于这个问题,timeline 视图最合适不过了。在 Chrome 中运行这个例子,打开开发者工具,定位到 timeline,选择内存并且点击记录按钮。然后去到那个页面点击按钮开始内存泄露。一段时间后停止记录,然后观察结果:

图片 13

这个例子中每秒都会发生一次内存泄露。记录停止后,在 grow 函数中设置一个断点来防止 Chrome 强制关闭这个页面。

在图中有两个明显的标志表明我们正在泄漏内存。节点的图表(绿色的线)和 JS 堆内存(蓝色的线)。节点数稳定地增长并且从不减少。这是一个明显的警告标志。

JS 堆内存表现出稳定的内存用量增长。由于垃圾回收器的作用,这很难被发现。你能看到一个初始内存的增长的图线,紧接着有一个很大的回落,接着又有一段增长然后出现了一个峰值,接着又是一个回落。这个情况的关键是在于一个事实,即每次内存用量回落时候,堆内存总是比上一次回落后的内存占用量更多。也就是说,尽管垃圾收集器成功地回收了很多的内存,还是有一部分内存周期性的泄露了。

我们现在确定程序中有一个泄露,让我们一起找到它。

另一个有用的特性

在 heap allocations 的结果区域,选择 Allocation。

图片 14

这个视图呈现了内存分配相关的功能列表,我们立刻看到了 grow 和 createSomeNodes。当选择 grow 时,看看相关的 object constructor,清楚地看到 (string), HTMLDivElement 和 Text 泄漏了。

结合以上提到的工具,可以轻松找到内存泄漏。

}

 

延伸阅读

  • Memory Management – Mozilla Developer Network
  • JScript Memory Leaks – Douglas Crockford (old, in relation to Internet Explorer 6 leaks)
  • JavaScript Memory Profiling – Chrome Developer Docs
  • Memory Diagnosis – Google Developers
  • An Interesting Kind of JavaScript Memory Leak – Meteor blog
  • Grokking V8 closures

打赏支持我翻译更多好文章,谢谢!

打赏译者

实际上是:

拍两张快照

 

为了找到这个内存泄漏,我们将使用 Chrome 开发者工具红的 profiles 选项卡。为了保证内存的使用在一个可控制的范围内,在做这一步之前刷新一下页面。我们将使用 Take Heap Snapshot 功能。

刷新页面,在页面加载结束后为堆内存捕获一个快照。我们将要使用这个快照作为我们的基准。然后再次点击按钮,等几秒,然后再拍一个快照。拍完照后,推荐的做法是在脚本中设置一个断点来停止它的运行,防止更多的内存泄露。

图片 15

有两个方法来查看两个快照之间的内存分配情况,其中一种方法需要选择 Summary 然后在右面选取在快照1和快照2之间分配的对象,另一种方法,选择 Comparison 而不是 Summary。两种方法下,我们都将会看到一个列表,列表中展示了在两个快照之间分配的对象。

 

本例中,我们很容易就可以找到内存泄露:它们很明显。看一下(string)构造函数的 Size Delta。58个对象占用了8 MB 内存。这看起来很可疑:新的对象被创建,但是没有被释放导致了8 MB 的内存消耗。

如果我们打开(string)构造函数分配列表,我们会注意到在很多小内存分配中掺杂着的几个大量的内存分配。这些情况立即引起了我们的注意。如果我们选择它们当中的任意一个,我们将会在下面的 retainer 选项卡中得到一些有趣的结果。

图片 16

 

我们发现我们选中的内存分配信息是一个数组的一部分。相应地,数组被变量 x 在全局 window 对象内部引用。这给我们指引了一条从我们的大对象到不会被回收的根节点(window)的完整的路径。我们也就找到了潜在的泄漏点以及它在哪里被引用。

到现在为止,一切都很不错。但是我们的例子太简单了:像例子中这样大的内存分配并不是很常见。幸运的是我们的例子中还存在着细小的 DOM 节点内存泄漏。使用上面的内存快照可以很容易地找到这些节点,但是在更大的站点中,事情变得复杂起来。最近,新的 Chrome 的版本中提供了一个附加的工具,这个工具十分适合我们的工作,这就是堆内存分配记录(Record Heap Allocations)功能

拍两张快照

为了找到这个内存泄漏,我们将使用 Chrome 开发者工具红的 profiles 选项卡。为了保证内存的使用在一个可控制的范围内,在做这一步之前刷新一下页面。我们将使用 Take Heap Snapshot 功能。

刷新页面,在页面加载结束后为堆内存捕获一个快照。我们将要使用这个快照作为我们的基准。然后再次点击按钮,等几秒,然后再拍一个快照。拍完照后,推荐的做法是在脚本中设置一个断点来停止它的运行,防止更多的内存泄露。

图片 17

有两个方法来查看两个快照之间的内存分配情况,其中一种方法需要选择 Summary 然后在右面选取在快照1和快照2之间分配的对象,另一种方法,选择 Comparison 而不是 Summary。两种方法下,我们都将会看到一个列表,列表中展示了在两个快照之间分配的对象。

 

本例中,我们很容易就可以找到内存泄露:它们很明显。看一下(string)构造函数的 Size Delta。58个对象占用了8 MB 内存。这看起来很可疑:新的对象被创建,但是没有被释放导致了8 MB 的内存消耗。

如果我们打开(string)构造函数分配列表,我们会注意到在很多小内存分配中掺杂着的几个大量的内存分配。这些情况立即引起了我们的注意。如果我们选择它们当中的任意一个,我们将会在下面的 retainer 选项卡中得到一些有趣的结果。

图片 18

 

我们发现我们选中的内存分配信息是一个数组的一部分。相应地,数组被变量 x 在全局 window 对象内部引用。这给我们指引了一条从我们的大对象到不会被回收的根节点(window)的完整的路径。我们也就找到了潜在的泄漏点以及它在哪里被引用。

到现在为止,一切都很不错。但是我们的例子太简单了:像例子中这样大的内存分配并不是很常见。幸运的是我们的例子中还存在着细小的 DOM 节点内存泄漏。使用上面的内存快照可以很容易地找到这些节点,但是在更大的站点中,事情变得复杂起来。最近,新的 Chrome 的版本中提供了一个附加的工具,这个工具十分适合我们的工作,这就是堆内存分配记录(Record Heap Allocations)功能

打赏支持我翻译更多好文章,谢谢!

任选一种支付方式

图片 19 图片 20

1 赞 10 收藏 1 评论

function foo(arg) {

通过记录堆内存分配来发现内存泄露

取消掉你之前设置的断点让脚本继续运行,然后回到开发者工具的 Profiles 选项卡。现在点击 Record Heap Allocations。当工具运行时候你将注意到图表顶部的蓝色细线。这些代表着内存分配。我们的代码导致每秒钟都有一个大的内存分配发生。让它运行几秒然后让程序停止(不要忘记在此设置断点来防止 Chrome 吃掉过多的内存)。

图片 21

在这张图中你能看到这个工具的杀手锏:选择时间线中的一片来观察在这段时间片中内存分配发生在什么地方。我们将时间片设置的尽量与蓝色线接近。只有三个构造函数在这个列表中显示出来:一个是与我们的大泄露有关的(string),一个是和 DOM 节点的内存分配相关的,另一个是 Text 构造函数(DOM 节点中的文本构造函数)。

从列表中选择一个 HTMLDivElement 构造函数然后选择一个内存分配堆栈。

图片 22

啊哈!我们现在知道那些元素在什么地方被分配了(grow -> createSomeNodes)。如果我们集中精神观察图像中的每个蓝色线,还会注意到 HTMLDivElement 的构造函数被调用了很多次。如果我们回到快照 comparison 视图就不难发现这个构造函数分配了很多次内存但是没有从未释放它们。也就是说,它不断地分配内存空间,但却没有允许 GC 回收它们。种种迹象表明这是一个泄露,加上我们确切地知道这些对象被分配到了什么地方(createSomeNodes 函数)。现在应该去研究代码,并修复这个泄漏。

通过记录堆内存分配来发现内存泄露

取消掉你之前设置的断点让脚本继续运行,然后回到开发者工具的 Profiles 选项卡。现在点击 Record Heap Allocations。当工具运行时候你将注意到图表顶部的蓝色细线。这些代表着内存分配。我们的代码导致每秒钟都有一个大的内存分配发生。让它运行几秒然后让程序停止(不要忘记在此设置断点来防止 Chrome 吃掉过多的内存)。

图片 23

在这张图中你能看到这个工具的杀手锏:选择时间线中的一片来观察在这段时间片中内存分配发生在什么地方。我们将时间片设置的尽量与蓝色线接近。只有三个构造函数在这个列表中显示出来:一个是与我们的大泄露有关的(string),一个是和 DOM 节点的内存分配相关的,另一个是 Text 构造函数(DOM 节点中的文本构造函数)。

从列表中选择一个 HTMLDivElement 构造函数然后选择一个内存分配堆栈。

图片 24

啊哈!我们现在知道那些元素在什么地方被分配了(grow -> createSomeNodes)。如果我们集中精神观察图像中的每个蓝色线,还会注意到 HTMLDivElement 的构造函数被调用了很多次。如果我们回到快照 comparison 视图就不难发现这个构造函数分配了很多次内存但是没有从未释放它们。也就是说,它不断地分配内存空间,但却没有允许 GC 回收它们。种种迹象表明这是一个泄露,加上我们确切地知道这些对象被分配到了什么地方(createSomeNodes 函数)。现在应该去研究代码,并修复这个泄漏。

关于作者:涂鸦码龙

图片 25

不高级前端攻城狮,原名金龙,不姓郭。【忙时码代码,无事乱涂鸦】 个人主页 · 我的文章 · 3 ·    

图片 26

   window.bar = "this is an explicit global variable";

其他有用的特性

在堆内存分配结果视图中我们可以使用比 Summary 更好的 Allocation 视图。

图片 27

这个视图为我们呈现了一个函数的列表,同时也显示了与它们相关的内存分配情况。我们能立即看到 grow 和 createSomeNodes 凸显了出来。当选择 grow 我们看到了与它相关的对象构造函数被调用的情况。我们注意到了(string),HTMLDivElement 和 Text 而现在我们已经知道是对象的构造函数被泄露了。

这些工具的组合对找到泄漏有很大帮助。和它们一起工作。为你的生产环境站点做不同的分析(最好用没有最小化或混淆的代码)。看看你能不能找到那些比正常情况消耗更多内存的对象吧(提示:这些很难被找到)。

如果要使用 Allocation 视图,需要进入 Dev Tools -> Settings,选中“record heap allocation stack traces”。获取记录之前必须要这么做。

其他有用的特性

在堆内存分配结果视图中我们可以使用比 Summary 更好的 Allocation 视图。

图片 28

这个视图为我们呈现了一个函数的列表,同时也显示了与它们相关的内存分配情况。我们能立即看到 grow 和 createSomeNodes 凸显了出来。当选择 grow 我们看到了与它相关的对象构造函数被调用的情况。我们注意到了(string),HTMLDivElement 和 Text 而现在我们已经知道是对象的构造函数被泄露了。

这些工具的组合对找到泄漏有很大帮助。和它们一起工作。为你的生产环境站点做不同的分析(最好用没有最小化或混淆的代码)。看看你能不能找到那些比正常情况消耗更多内存的对象吧(提示:这些很难被找到)。

如果要使用 Allocation 视图,需要进入 Dev Tools -> Settings,选中“record heap allocation stack traces”。获取记录之前必须要这么做。

}

延伸阅读

  • Memory Management – Mozilla Developer Network
  • JScript Memory Leaks – Douglas Crockford (old, in relation to Internet Explorer 6 leaks)
  • JavaScript Memory Profiling – Chrome Developer Docs
  • Memory Diagnosis – Google Developers
  • An Interesting Kind of JavaScript Memory Leak – Meteor blog
  • Grokking V8 closures

延伸阅读

  • Memory Management – Mozilla Developer Network

  • JScript Memory Leaks – Douglas Crockford (old, in relation to Internet Explorer 6 leaks)

  • JavaScript Memory Profiling – Chrome Developer Docs

  • Memory Diagnosis – Google Developers

  • An Interesting Kind of JavaScript Memory Leak – Meteor blog

  • Grokking V8 closures

如果 bar 是一个应该指向 foo 函数作用域内变量的引用,但是你忘记使用 var 来声明这个变量,这时一个全局变量就会被创建出来。在这个例子中,一个简单的字符串泄露并不会造成很大的危害,但这无疑是错误的。

结论

在垃圾回收语言中,如 JavaScript,确实会发生内存泄露。一些情况下我们都不会意识到这些泄露,最终它们将会带来毁灭性的灾难。正是由于这个原因,使用内存分析工具来发现内存泄露是十分重要的。运行分析工具应该成为开发周期中的一部分,特别是对于中型或大型应用来讲。现在就开始这么做,尽可能地为你的用户提供最好的体验。动手吧!

4 赞 11 收藏 评论

结论

在垃圾回收语言中,如 JavaScript,确实会发生内存泄露。一些情况下我们都不会意识到这些泄露,最终它们将会带来毁灭性的灾难。正是由于这个原因,使用内存分析工具来发现内存泄露是十分重要的。运行分析工具应该成为开发周期中的一部分,特别是对于中型或大型应用来讲。现在就开始这么做,尽可能地为你的用户提供最好的体验。动手吧!

 

另外一种偶然创建全局变量的方式如下:

关于作者:ARIGATO

图片 29

一个 iOS 转前端的开发者 个人主页 · 我的文章 · 15

图片 30

function foo() {

   this.variable = "potential accidental global";

}

// Foo called on its own, this points to the global object (window)

// rather than being undefined.

// 函数自身发生了调用,this 指向全局对象(window),(译者注:这时候会为全局对象 window 添加一个 variable 属性)而不是 undefined。

foo();

为了防止这种错误的发生,可以在你的 JavaScript 文件开头添加 'use strict'; 语句。这个语句实际上开启了解释 JavaScript 代码的严格模式,这种模式可以避免创建意外的全局变量。

全局变量的注意事项

尽管我们在讨论那些隐蔽的全局变量,但是也有很多代码被明确的全局变量污染的情况。按照定义来讲,这些都是不会被回收的变量(除非设置 null 或者被重新赋值)。特别需要注意的是那些被用来临时存储和处理一些大量的信息的全局变量。如果你必须使用全局变量来存储很多的数据,请确保在使用过后将它设置为 null 或者将它重新赋值。常见的和全局变量相关的引发内存消耗增长的原因就是缓存。缓存存储着可复用的数据。为了让这种做法更高效,必须为缓存的容量规定一个上界。由于缓存不能被及时回收的缘故,缓存无限制地增长会导致很高的内存消耗。

2: 被遗漏的定时器和回调函数

在 JavaScript 中 setInterval 的使用十分常见。其他的库也经常会提供观察者和其他需要回调的功能。这些库中的绝大部分都会关注一点,就是当它们本身的实例被销毁之前销毁所有指向回调的引用。在 setInterval 这种情况下,一般情况下的代码是这样的:

var someResource = getData();

setInterval(function() {

   var node = document.getElementById('Node');

   if(node) {

       // Do stuff with node and someResource.

       node.innerHTML = JSON.stringify(someResource));

   }

}, 1000);

这个例子说明了摇晃的定时器会发生什么:引用节点或者数据的定时器已经没用了。那些表示节点的对象在将来可能会被移除掉,所以将整个代码块放在周期处理函数中并不是必要的。然而,由于周期函数一直在运行,处理函数并不会被回收(只有周期函数停止运行之后才开始回收内存)。如果周期处理函数不能被回收,它的依赖程序也同样无法被回收。这意味着一些资源,也许是一些相当大的数据都也无法被回收。

下面举一个观察者的例子,当它们不再被需要的时候(或者关联对象将要失效的时候)显式地将他们移除是十分重要的。在以前,尤其是对于某些浏览器(IE6)是一个至关重要的步骤,因为它们不能很好地管理循环引用(下面的代码描述了更多的细节)。现在,当观察者对象失效的时候便会被回收,即便 listener 没有被明确地移除,绝大多数的浏览器可以或者将会支持这个特性。尽管如此,在对象被销毁之前移除观察者依然是一个好的实践。示例如下:

var element = document.getElementById('button');

function onClick(event) {

   element.innerHtml = 'text';

}

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.

对象观察者和循环引用中一些需要注意的点

观察者和循环引用常常会让 JavaScript 开发者踩坑。以前在 IE 浏览器的垃圾回收器上会导致一个 bug(或者说是浏览器设计上的问题)。旧版本的 IE 浏览器不会发现 DOM 节点和 JavaScript 代码之间的循环引用。这是一种观察者的典型情况,观察者通常保留着一个被观察者的引用(正如上述例子中描述的那样)。换句话说,在 IE 浏览器中,每当一个观察者被添加到一个节点上时,就会发生一次内存泄漏。这也就是开发者在节点或者空的引用被添加到观察者中之前显式移除处理方法的原因。目前,现代的浏览器(包括 IE 和 Microsoft Edge)都使用了可以发现这些循环引用并正确的处理它们的现代化垃圾回收算法。换言之,严格地讲,在废弃一个节点之前调用 removeEventListener 不再是必要的操作。

像是 jQuery 这样的框架和库(当使用一些特定的 API 时候)都在废弃一个结点之前移除了 listener 。它们在内部就已经处理了这些事情,并且保证不会产生内存泄露,即便程序运行在那些问题很多的浏览器中,比如老版本的 IE。

3: DOM 之外的引用

有些情况下将 DOM 结点存储到数据结构中会十分有用。假设你想要快速地更新一个表格中的几行,如果你把每一行的引用都存储在一个字典或者数组里面会起到很大作用。如果你这么做了,程序中将会保留同一个结点的两个引用:一个引用存在于 DOM 树中,另一个被保留在字典中。如果在未来的某个时刻你决定要将这些行移除,则需要将所有的引用清除。

var elements = {

   button: document.getElementById('button'),

   image: document.getElementById('image'),

   text: document.getElementById('text')

};

function doStuff() {

   image.src = '';

   button.click();

   console.log(text.innerHTML);

   // Much more logic

}

function removeButton() {

   // The button is a direct child of body.

   document.body.removeChild(document.getElementById('button'));

   // At this point, we still have a reference to #button in the global

   // elements dictionary. In other words, the button element is still in

   // memory and cannot be collected by the GC.

}

还需要考虑另一种情况,就是对 DOM 树子节点的引用。假设你在 JavaScript 代码中保留了一个表格中特定单元格(一个  标签)的引用。在将来你决定将这个表格从 DOM 中移除,但是仍旧保留这个单元格的引用。凭直觉,你可能会认为 GC 会回收除了这个单元格之外所有的东西,但是实际上这并不会发生:单元格是表格的一个子节点且所有子节点都保留着它们父节点的引用。换句话说,JavaScript 代码中对单元格的引用导致整个表格被保留在内存中。所以当你想要保留 DOM 元素的引用时,要仔细的考虑清除这一点。

4: 闭包

JavaScript 开发中一个重要的内容就是闭包,它是可以获取父级作用域的匿名函数。Meteor 的开发者发现在一种特殊情况下有可能会以一种很微妙的方式产生内存泄漏,这取决于 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)的对象。同时,没有用到的那个变量持有一个引用了 originalThing(replaceThing 调用之前的 theThing)闭包。哈,是不是已经有点晕了?关键的问题是每当在同一个父作用域下创建闭包作用域的时候,这个作用域是被共享的。在这种情况下,someMethod 的闭包作用域和 unused 的作用域是共享的。unused 持有一个 originalThing 的引用。尽管 unused 从来没有被使用过,someMethod 可以在 theThing 之外被访问。而且 someMethod 和 unused 共享了闭包作用域,即便 unused 从来都没有被使用过,它对 originalThing 的引用还是强制它保持活跃状态(阻止它被回收)。当这段代码重复运行时,将可以观察到内存消耗稳定地上涨,并且不会因为 GC 的存在而下降。本质上来讲,创建了一个闭包链表(根节点是 theThing 形式的变量),而且每个闭包作用域都持有一个对大数组的间接引用,这导致了一个巨大的内存泄露。

这是一种人为的实现方式。可以想到一个能够解决这个问题的不同的闭包实现,就像 Metero 的博客里面说的那样。

垃圾收集器的直观行为

尽管垃圾收集器是便利的,但是使用它们也需要有一些利弊权衡。其中之一就是不确定性。也就是说,GC 的行为是不可预测的。通常情况下都不能确定什么时候会发生垃圾回收。这意味着在一些情形下,程序会使用比实际需要更多的内存。有些的情况下,在很敏感的应用中可以观察到明显的卡顿。尽管不确定性意味着你无法确定什么时候垃圾回收会发生,不过绝大多数的 GC 实现都会在内存分配时遵从通用的垃圾回收过程模式。如果没有内存分配发生,大部分的 GC 都会保持静默。考虑以下的情形:

大量内存分配发生时。

大部分(或者全部)的元素都被标记为不可达(假设我们讲一个指向无用缓存的引用置 null 的时候)。

没有进一步的内存分配发生。

这个情形下,GC 将不会运行任何进一步的回收过程。也就是说,尽管有不可达的引用可以触发回收,但是收集器并不要求回收它们。严格的说这些不是内存泄露,但仍然导致高于正常情况的内存空间使用。

Google 在它们的 JavaScript 内存分析文档中提供一个关于这个行为的优秀例子,见示例#2.

Chrome 内存分析工具简介

Chrome 提供了一套很好的工具用来分析 JavaScript 的内存适用。这里有两个与内存相关的重要视图:timeline 视图和 profiles 视图。

Timeline view

timeline 视图是我们用于发现不正常内存模式的必要工具。当我们寻找严重的内存泄漏时,内存回收发生后产生的周期性的不会消减的内存跳跃式增长会被一面红旗标记。在这个截图里面我们可以看到,这很像是一个稳定的对象内存泄露。即便最后经历了一个很大的内存回收,它占用的内存依旧比开始时多得多。节点数也比开始要高。这些都是代码中某处 DOM 节点内存泄露的标志。

Profiles 视图

你将会花费大部分的时间在观察这个视图上。profiles 视图让你可以对 JavaScript 代码运行时的内存进行快照,并且可以比较这些内存快照。它还让你可以记录一段时间内的内存分配情况。在每一个结果视图中都可以展示不同类型的列表,但是对我们的任务最有用的是 summary 列表和 comparison 列表。

summary 视图提供了不同类型的分配对象以及它们的合计大小:shallow size (一个特定类型的所有对象的总和)和 retained size (shallow size 加上保留此对象的其它对象的大小)。distance 显示了对象到达 GC 根(校者注:最初引用的那块内存,具体内容可自行搜索该术语)的最短距离。

comparison 视图提供了同样的信息但是允许对比不同的快照。这对于找到泄露很有帮助。

举例: 使用 Chrome 来发现内存泄露

有两个重要类型的内存泄露:引起内存周期性增长的泄露和只发生一次且不引起更进一步内存增长的泄露。显而易见的是,寻找周期性的内存泄漏是更简单的。这些也是最麻烦的事情:如果内存会按时增长,泄露最终将导致浏览器变慢或者停止执行脚本。很明显的非周期性大量内存泄露可以很容易的在其他内存分配中被发现。但是实际情况并不如此,往往这些泄露都是不足以引起注意的。这种情况下,小的非周期性内存泄露可以被当做一个优化点。然而那些周期性的内存泄露应该被视为 bug 并且必须被修复。

为了举例,我们将会使用 Chrome 的文档中提供的一个例子。完整的代码在下面可以找到:

var x = [];

function createSomeNodes() {

   var div,

       i = 100,

       frag = document.createDocumentFragment();

   for (;i > 0; i--) {

       div = document.createElement("div");

       div.appendChild(document.createTextNode(i " - " new Date().toTimeString()));

       frag.appendChild(div);

   }

   document.getElementById("nodes").appendChild(frag);

}

function grow() {

   x.push(new Array(1000000).join('x'));

   createSomeNodes();

   setTimeout(grow,1000);

}

当调用 grow 的时候,它会开始创建 div 节点并且把他们追加到 DOM 上。它将会分配一个大数组并将它追加到一个全局数组中。这将会导致内存的稳定增长,使用上面提到的工具可以观察到这一点。

垃圾收集语言通常表现出内存用量的抖动。如果代码在一个发生分配的循环中运行时,这是很常见的。我们将要寻找那些在内存分配之后周期性且不会回落的内存增长。

查看内存是否周期性增长

对于这个问题,timeline 视图最合适不过了。在 Chrome 中运行这个例子,打开开发者工具,定位到 timeline,选择内存并且点击记录按钮。然后去到那个页面点击按钮开始内存泄露。一段时间后停止记录,然后观察结果:

这个例子中每秒都会发生一次内存泄露。记录停止后,在 grow 函数中设置一个断点来防止 Chrome 强制关闭这个页面。

在图中有两个明显的标志表明我们正在泄漏内存。节点的图表(绿色的线)和 JS 堆内存(蓝色的线)。节点数稳定地增长并且从不减少。这是一个明显的警告标志。

JS 堆内存表现出稳定的内存用量增长。由于垃圾回收器的作用,这很难被发现。你能看到一个初始内存的增长的图线,紧接着有一个很大的回落,接着又有一段增长然后出现了一个峰值,接着又是一个回落。这个情况的关键是在于一个事实,即每次内存用量回落时候,堆内存总是比上一次回落后的内存占用量更多。也就是说,尽管垃圾收集器成功地回收了很多的内存,还是有一部分内存周期性的泄露了。

我们现在确定程序中有一个泄露,让我们一起找到它。

拍两张快照

为了找到这个内存泄漏,我们将使用 Chrome 开发者工具红的 profiles 选项卡。为了保证内存的使用在一个可控制的范围内,在做这一步之前刷新一下页面。我们将使用 Take Heap Snapshot 功能。

刷新页面,在页面加载结束后为堆内存捕获一个快照。我们将要使用这个快照作为我们的基准。然后再次点击按钮,等几秒,然后再拍一个快照。拍完照后,推荐的做法是在脚本中设置一个断点来停止它的运行,防止更多的内存泄露。

有两个方法来查看两个快照之间的内存分配情况,其中一种方法需要选择 Summary 然后在右面选取在快照1和快照2之间分配的对象,另一种方法,选择 Comparison 而不是 Summary。两种方法下,我们都将会看到一个列表,列表中展示了在两个快照之间分配的对象。

本例中,我们很容易就可以找到内存泄露:它们很明显。看一下(string)构造函数的 Size Delta。58个对象占用了8 MB 内存。这看起来很可疑:新的对象被创建,但是没有被释放导致了8 MB 的内存消耗。

如果我们打开(string)构造函数分配列表,我们会注意到在很多小内存分配中掺杂着的几个大量的内存分配。这些情况立即引起了我们的注意。如果我们选择它们当中的任意一个,我们将会在下面的 retainer 选项卡中得到一些有趣的结果。

我们发现我们选中的内存分配信息是一个数组的一部分。相应地,数组被变量 x 在全局 window 对象内部引用。这给我们指引了一条从我们的大对象到不会被回收的根节点(window)的完整的路径。我们也就找到了潜在的泄漏点以及它在哪里被引用。

到现在为止,一切都很不错。但是我们的例子太简单了:像例子中这样大的内存分配并不是很常见。幸运的是我们的例子中还存在着细小的 DOM 节点内存泄漏。使用上面的内存快照可以很容易地找到这些节点,但是在更大的站点中,事情变得复杂起来。最近,新的 Chrome 的版本中提供了一个附加的工具,这个工具十分适合我们的工作,这就是堆内存分配记录(Record Heap Allocations)功能

通过记录堆内存分配来发现内存泄露

取消掉你之前设置的断点让脚本继续运行,然后回到开发者工具的 Profiles 选项卡。现在点击 Record Heap Allocations。当工具运行时候你将注意到图表顶部的蓝色细线。这些代表着内存分配。我们的代码导致每秒钟都有一个大的内存分配发生。让它运行几秒然后让程序停止(不要忘记在此设置断点来防止 Chrome 吃掉过多的内存)。

在这张图中你能看到这个工具的杀手锏:选择时间线中的一片来观察在这段时间片中内存分配发生在什么地方。我们将时间片设置的尽量与蓝色线接近。只有三个构造函数在这个列表中显示出来:一个是与我们的大泄露有关的(string),一个是和 DOM 节点的内存分配相关的,另一个是 Text 构造函数(DOM 节点中的文本构造函数)。

从列表中选择一个 HTMLDivElement 构造函数然后选择一个内存分配堆栈。

啊哈!我们现在知道那些元素在什么地方被分配了(grow -> createSomeNodes)。如果我们集中精神观察图像中的每个蓝色线,还会注意到 HTMLDivElement 的构造函数被调用了很多次。如果我们回到快照 comparison 视图就不难发现这个构造函数分配了很多次内存但是没有从未释放它们。也就是说,它不断地分配内存空间,但却没有允许 GC 回收它们。种种迹象表明这是一个泄露,加上我们确切地知道这些对象被分配到了什么地方(createSomeNodes 函数)。现在应该去研究代码,并修复这个泄漏。

其他有用的特性

在堆内存分配结果视图中我们可以使用比 Summary 更好的 Allocation 视图。

这个视图为我们呈现了一个函数的列表,同时也显示了与它们相关的内存分配情况。我们能立即看到 grow 和 createSomeNodes 凸显了出来。当选择 grow 我们看到了与它相关的对象构造函数被调用的情况。我们注意到了(string),HTMLDivElement 和 Text 而现在我们已经知道是对象的构造函数被泄露了。

这些工具的组合对找到泄漏有很大帮助。和它们一起工作。为你的生产环境站点做不同的分析(最好用没有最小化或混淆的代码)。看看你能不能找到那些比正常情况消耗更多内存的对象吧(提示:这些很难被找到)。

如果要使用 Allocation 视图,需要进入 Dev Tools -> Settings,选中“record heap allocation stack traces”。获取记录之前必须要这么做。

延伸阅读

Memory Management – Mozilla Developer Network

JScript Memory Leaks – Douglas Crockford (old, in relation to Internet Explorer 6 leaks)

JavaScript Memory Profiling – Chrome Developer Docs

Memory Diagnosis – Google Developers

An Interesting Kind of JavaScript Memory Leak – Meteor blog

Grokking V8 closures

结论

在垃圾回收语言中,如 JavaScript,确实会发生内存泄露。一些情况下我们都不会意识到这些泄露,最终它们将会带来毁灭性的灾难。正是由于这个原因,使用内存分析工具来发现内存泄露是十分重要的。运行分析工具应该成为开发周期中的一部分,特别是对于中型或大型应用来讲。

本文由pc28.am发布于前端技术,转载请注明出处:内部存储器分析工具简要介绍,内部存款和储蓄

上一篇:十分钟介绍mobx与react,状态管理入门及实例 下一篇:没有了
猜你喜欢
热门排行
精彩图文
  • 内部存储器分析工具简要介绍,内部存款和储蓄
    内部存储器分析工具简要介绍,内部存款和储蓄
    4类 JavaScript 内存泄漏及如何避免 2016/05/26 · JavaScript· 1 评论 ·内存泄漏 本文由 伯乐在线 -涂鸦码龙翻译。未经许可,禁止转载! 英文出处:SebastiánPeyro
  • HTML也可以静态编译,损害了复用性
    HTML也可以静态编译,损害了复用性
    React.Component 损害了复用性? 2016/09/07 · 底蕴技能 ·binding.scala,data-binding,React,scala.js 本文笔者: 伯乐在线 -ThoughtWorks。未经笔者许可,防止转发! 接待插足
  • 品质的法门
    品质的法门
    9 种改革 AngularJS 品质的艺术 2017/07/20 · JavaScript· AngularJS 初藳出处: JustinSpencer   译文出处:oschina    AngularJS 是当下利用非常遍布的 web app应用框架,随
  • 高质量滚动,实例解析防抖动和节流阀
    高质量滚动,实例解析防抖动和节流阀
    实例解析防抖动和节流阀 2016/04/26 · JavaScript· DOM 本文由 伯乐在线 -涂鸦码龙翻译。未经许可,幸免转发! 立陶宛共和国(Republic of Lithuania卡塔尔语出处:
  • 安插最棒执行,营造打包优化_javascript技术_脚本
    安插最棒执行,营造打包优化_javascript技术_脚本
    Webpack 4 配置最佳实践 2018/06/22 · JavaScript· webpack 原文出处:Zindex    Webpack 4 发布已经有一段时间了。Webpack 的版本号已经来到了4.12.x。但因为 Webpack官方还