使用谷歌开发者工具分析内存泄漏

795 阅读16分钟

JS内存机制

内存生命周期

image.png

JS 环境中分配的内存有如下生命周期:

  • 内存分配:当我们声明变量、函数、对象的时候,系统会自动为他们分配内存
  • 内存使用:即读写内存,也就是使用变量、函数等
  • 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

内存分配

为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存
var o = {
 a: 1,
 b: null
}; // 给对象及其包含的值分配内存
// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"]; 
function f(a){
 return a + 2;
} // 给函数(可调用的对象)分配内存
// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
 someElement.style.backgroundColor = 'blue';
}, false);

有些函数调用结果是分配对象内存:

var d = new Date(); // 分配一个 Date 对象
var e = document.createElement('div'); // 分配一个 DOM 元素

有些方法分配新变量或者新对象:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。
var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组有四个元素,是 a 连接 a2 的结果

内存使用

使用值的过程实际上是对分配内存进行读取与写入的操作。

读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

var a = 10; // 分配内存
console.log(a); // 对内存的使用

内存回收

JS 有自动垃圾回收机制,那么这个自动垃圾回收机制的原理是什么呢?

其实很简单,就是找出那些不再继续使用的值,然后释放其占用的内存。

大多数内存管理的问题都在这个阶段。

在这里最艰难的任务是找到不再需要使用的变量。

不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在,当函数运行结束,没有其他引用(闭包),那么该变量会被标记回收。

全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。

因为自动垃圾回收机制的存在,开发人员可以不关心也不注意内存释放的有关问题,但对无用内存的释放这件事是客观存在的。

不幸的是,即使不考虑垃圾回收对性能的影响,目前最新的垃圾回收算法,也无法智能回收所有的极端情况。

内存泄漏

JS里已经分配内存地址的对象,但是由于长时间没有释放或没办法清除,造成长期占用内存的现象,会让内存资源大幅防雷,最终导致运行速度慢,甚至崩溃的现象。

JavaScript,会在创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收

因为自动垃圾回收机制的存在,让大多Javascript开发者感觉他们可以不关心内存管理,所以会在一些情况下导致内存泄漏。

定义

程序的运行需要内存。只要程序提出要求,操作系统或运行时(runtime)就必须提供内存。对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些不再使用的内存,照成内存的浪费。

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。它的一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄露了。

垃圾回收

引用

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

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。

例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数垃圾收集

这是最初级的垃圾回收算法。

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。

如果没有其他对象指向它了,说明该对象已经不再需了。

var o = { 
 a: {
     b:2
 }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
var o2 = o; // o2变量是第二个对“这个对象”的引用
o = 1; // 现在,“这个对象”的原始引用o被o2替换了
var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa
o2 = "yo"; // 最初的对象现在已经是零引用了
 // 他可以被垃圾回收了
 // 然而它的属性a的对象还在被oa引用,所以还不能回收
oa = null; // a属性的那个对象现在也是零引用了
 // 它可以被垃圾回收了

由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。

如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。

来看一个循环引用的例子:

function f(){
 var o1 = {};
 var o2 = {};
 o1.a = o2; // o 引用 o2
 o2.a = o1; // o2 引用 o1 这里
 return "azerty";
}
f();

上面我们申明了一个函数 f ,其中包含两个相互引用的对象。

在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。

但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

再来看一个实际的例子:

var div = document.createElement("div");
div.onclick = function() {
 console.log("click");
};

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。

此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。

一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。

为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收

标记清除法

标记清除算法将“不再使用的对象”定义为“无法达到的对象”。

简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。

凡是能从根部到达的对象,都是还需要使用的。

那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。

但反之未必成立。

工作流程:

  1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
  2. 从根部出发将能触及到的对象的标记清除。
  3. 那些还存在标记的变量被视为准备删除的变量。
  4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。

循环引用不再是问题了

再看之前循环引用的例子

function f(){
 var o = {};
 var o2 = {};
 o.a = o2; // o 引用 o2
 o2.a = o; // o2 引用 o
 return "azerty";
}
f();

函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取他们的引用。

因此,他们将会被垃圾回收器回收。

谷歌分析内存泄漏

查看整体内存占用

Shift + Esc打开谷歌的任务管理器。或点击右上角菜单--更多工具---任务管理器。关注内存占用空间、javascript使用的内存。

image.png

操作步骤

  • 内存占用空间: 本机内存,如果这个值在增加,说明正在创建DOM节点
  • Javascript使用的内存:JS堆,跳动的数字表示网页上活动对象使用的内存,如果这个数字在增加,说明正在创建新对象或现有对象正在增长

什么是活动对象 ?
有引用的对象为可获得的对象,也称为活动对象。没有引用的对象是不可获得的对象,会被垃圾机制回收。
如:
var arr = new Array();
new Array()创建了一个新的数组对象,arr是它的引用,如果我们这时把arr = null
那数组对象就无法再被我们调用了,这时就会被当成垃圾回收

常见的几种情况及原因 (1)内存占用空间一直在增长,JavaScipt使用的内存没增长:浏览器还没有进行垃圾回收
(2)内存占用空间一直在增长,JavaScipt使用的内存增长很慢:JS变量引用了DOM,影响了其他DOM节点

Performance面板

  1. 打开开发者工具,选择 Performance 面板
  2. 在顶部勾选 Memory
  3. 点击左上角的 record 按钮
  4. 在页面上进行各种操作,模拟用户的使用情况
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况(如下图)

image.png

  • 启用Memory(内存)复选框
  • 点击垃圾回收 -> 点击开始录制 -> 点击垃圾回收 -> 点击结束录制

会得到如下图 image.png

(1)Heap堆:曲线表示JS堆,在后面的Memory面板会重点说明
(2)Call stack 调用栈:横向表示调用时间,如果调用时间过长,就要进行优化。垂直方向无意义,主要表示函数嵌套较深。
(3)Counter 计数器:显示内存的使用情况,分别显示

  • JS Heap:JS堆
  • Documents:文档
  • Nodes: DOM 节点
  • Listeners:侦听器
  • GPU Memory:GPU内存

主要关注JS Heap、Nodes、Listeners。如果这三个中有一个在持续上涨,并且没有下降的趋势,就有可能是内存泄漏。

Memory 面板

基本使用

image.png

操作步骤:

  • 选择快照堆类型:一般选Heap snapshot(JS堆快照)和Allocation instrumentation on timeline(JS堆分配时间线)
  • 点击垃圾回收 -> 开始录制
  • 录制完成后得到堆快照

Heap snapshot(JS堆快照):发现DOM泄漏
Allocation instrumentation on timeline(JS堆分配时间线): 显示了对象什么时候被创建,什么时候存在内存泄漏(蓝色线表示未回收、灰色线表示已回收)

视图类型

image.png

  • Summary:总览视图,
    按构造函数分组。用于捕捉对象及其使用的内存。对于定位DOM内存泄露特别有用。
  • Comparison:对比视图
    用于对比不同操作之后的堆快照,查看内存的释放及引用计数,来分析内存是否泄露及其原因。
  • Containment:内容视图
    查看堆内容。更适合查看对象结构,有助于分析对象的引用情况。适用于分析闭包以及深入分析对象。
  • Statistics:统计视图
    总览堆的统计信息。

记得每次录制之前要先点击垃圾回收

高度代表这个对象的大小
颜色代表这个对象的内存释放情况:蓝色线代表在录制结束前未被回收的,灰色线代表已经被回收了。

我们可以重复执行某个动作,如果有不少蓝色柱被保留,那就发生了内存泄漏

选中某段时间内的时间线,可以具体分析在这个时间点变化的对象

相关概念

  • 浅层大小 Shallow size:对象本身持有的内存大小
  • 保留大小 Retained size:定义:在删除对象本身及其从 GC Root中无法访问的依赖对象时释放的内存大小。也就是当前对象自身大小加上对象直接或间接引用的其他对象的大小总和
  • GC Root:当前时刻存活的对象。pic1.zhimg.com/v2-f79a0f0a…
  • 距离Distance:与根节点的距离。使用节点的最短简单路径显示到根的距离。
  • Objects Count::  对象个数及百分占比。

点击展开构造函数,可以看到所有构造函数相关的对象实例,@后面的数字是该对象实例的唯一标识符。

常见的顶层构造函数:

  • (global property): 全局对象和普通对象的中间对象,和常规思路不同。比如在Window上定义了一个Person对象,那么他们之间的关系就是[global] => (global property) => Person。之所以使用中间对象,是出于性能的考虑。
  • (closure): 使用函数闭包的对象。
  • (array, string, number, regexp): 一系列对象类型,其属性指向Array/String/Number/Regexp。
  • HTMLDivElement/HTMLAnchorElement/DocumentFragment: 元素的引用或者代码引用的指定文档对象。

记住,黄色的对象实例表示它被JS代码引用,红色的对象实例表示被黄色节点引用的游离节点。新版本(测试过69)的好像不会有颜色标识

补充

在服务器环境中使用 Node 提供的 process.memoryUsage 方法查看内存情况

console.log(process.memoryUsage());
// { 
// rss: 27709440,
// heapTotal: 5685248,
// heapUsed: 3449392,
// external: 8772 
// }

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。

该对象包含四个字段,单位是字节,含义如下:

  • rss(resident set size):所有内存占用,包括指令区和堆栈。
  • heapTotal:"堆"占用的内存,包括用到的和没用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎内部的 C++ 对象占用的内存。 判断内存泄漏,以heapUsed字段为准。

常见的内存泄露案例

1. 常见JS内存泄漏

  • 全局变量引起的内存泄漏
  • 闭包引起的泄漏
  • DOM删除时没有解绑事件
  • 定时器没有清除

2. Vue常见内存泄漏 vue在比较大的项目中容易出现内存泄漏

全局变量引起的内存泄漏

function foo() {
 bar1 = 'some text'; // 没有声明变量 实际上是全局变量 => window.bar1
 this.bar2 = 'some text' // 全局变量 => window.bar2
}
foo();

在这个例子中,意外的创建了两个全局变量 bar1 和 bar2

定时器和回调函数

在很多库中, 如果使用了观察者模式, 都会提供回调方法, 来调用一些回调函数。

要记得回收这些回调函数。举一个 setInterval的例子:

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

如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。

但如果你没有回收定时器,整个定时器依然有效, 不但定时器无法被内存回收,

定时器函数中的依赖也无法回收。在这个案例中的 serverData 也无法被回收。

闭包

在 JS 开发中,我们会经常用到闭包,一个内部函数,有权访问包含其的外部函数中的变量。

下面这种情况下,闭包也会造成内存泄露:

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 的闭包。

这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用,但是 someMethod 可能会被调用,就会导致无法对其内存进行回收。

当这段代码被反复执行时,内存会持续增长。

DOM引用

很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中。

var elements = {
 image: document.getElementById('image')
};
function doStuff() {
 elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
 document.body.removeChild(document.getElementById('image'));
 // 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收.
}

上述案例中,即使我们对于 image 元素进行了移除,但是仍然有对 image 元素的引用,依然无法对齐进行内存回收。

另外需要注意的一个点是,对于一个 Dom 树的叶子节点的引用。

举个例子: 如果我们引用了一个表格中的td元素,一旦在 Dom 中删除了整个表格,我们直观的觉得内存回收应该回收除了被引用的 td 外的其他元素。

但是事实上,这个 td 元素是整个表格的一个子元素,并保留对于其父元素的引用。

这就会导致对于整个表格,都无法进行内存回收。所以我们要小心处理对于 Dom 元素的引用。

如何避免内存泄漏

记住一个原则:不用的东西,及时归还。

  1. 减少不必要的全局变量,使用严格模式避免意外创建全局变量。
  2. 在你使用完数据后,及时解除引用(闭包中的变量,dom引用,定时器清除)。
  3. 组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。