理了一理,内存泄漏如何查找

2,025 阅读7分钟

 引言

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

本篇提供了查询内存泄漏的方法,阅读时间大约15分钟。

导致内存泄漏的原因

要想治病,得先找到病因。所以先说说导致内存泄漏的原因吧。

以下情况的原因足以覆盖大部分的场景:

遗忘的存储

这个原因多半是开发人员粗心导致的。举个现实中活生生的例子:

某开发人员苟文,开发了一个组件,他想在devtools中测试一下组件的api,于是他写了这样的代码:

{
    //...组件实现
    initialize: function() {
        window.testComp = this;
    },
    //...组件实现
}

写完这段代码,他便去参加了个会。

1 hours later ......

这个改动已然抛之于脑后,然后代码上线了。虽然组件再次被创建时,会替换这个变量,致使上一个组件实例被回收掉。但是如果不再创建呢?并且这个组件是一个完整的业务组件,其中依赖了多少组件以及数据,这么多的内存占着不被释放,性能可想而知。

闭包导致的内存泄漏

闭包,额......每个前端人都应该掌握的概念。不懂,先请移步 这里

function createClosure() {
    var obj = {};
    return function() {
        obj.name = 233;
    }
}
var closureFn = createClosure();

 当发生上述现象时,就会产生闭包。因为obj会保存在闭包函数的作用域链当中,函数不销毁,obj不销毁。此时,便会产生内存泄漏。

需要注意的是,函数的函数体中必须去操作obj :

function createClosure() {
    var obj = {};
    return function() {
        
    }
}
var closureFn = createClosure();

像这样,不会产生闭包,就不会发生内存泄漏。因为返回出来的函数并没有访问自由变量。

未解除事件绑定的dom销毁时导致的内存泄漏

这个需要了解一些浏览器的底层知识,我也在学习当中,但是并不妨碍我知道一些实现上的细节。

dom元素在绑定事件的时候,引擎会为其创建一个 V8EventListener 类的实例对象,这个实例对象会保存着对dom的引用以及事件处理函数的引用。如下列的代码:

var dom = document.getElementById('app');
dom.addEventListener('click', function(){
    //...
});
dom.remove();
dom = null;

虽然dom变量被置空,但是点击事件没有解除绑定,V8EventListener对象 还存在于内存当中,导致 DOM对象 和 与事件处理函数 都没有被回收。

计时器导致的内存泄漏

setInterval(function() {
    console.log(123);
}, 1000)

这个例子很简单,就是触发一个重复的计时器。既然他一直在工作,那么浏览器引擎必然会保留计时器这个对象。首先会创建一个 DOMTimer 的对象,它被挂在window的内部节点(该内部节点通过控制台是访问不到的)上,DOMTimer里引用了 ScheduledAction 对象,ScheduledAction里引用了 V8Function 对象,V8Function对象引用了 重复调用的函数

如果计时器不清除,这些为了处理计时器的对象又怎么可能销毁呢?

再严重一些,如果重复函数是个闭包函数,它所访问的所有对象都将永远保存在内存当中,无法被回收。

var obj = {
    start() {
        var me = this;
        setInterval(function() {
            me.name = 123;
        }, 1000)
    }
}
obj.start();
obj = null;

console.log会导致内存泄漏

这个我之前也不清楚,还是在一次排查内存泄漏的时候意味发现的。具体的验证过程看本文最后一个章节console.log会导致内存泄漏?

查找内存泄露的神器

不得不佩服google,其chrome浏览器在市场的份额遥遥领先(牛逼 * N)。光是开发者工具,就提供出多种web场景的调试工具,其中也包含内存泄漏查询工具 memory。

devtools -> memory 面板的使用学习请戳 这里(感谢这位大佬)。

这里开始画重点了。

查看面板的技巧

在排查内存泄漏时,我们的重点在于 找到没有被回收的对象,然后再顺着这个对象的 引用链路 查到具体的原因。

我们先学习一下查找思路。

这是没有发生内存泄漏时的内存情况:

  

snapshot 0:某个组件未加载时的内存情况

snapshot 1:第一次加载时的内存情况

snapshot 2:第一次重新加载时的内存情况

snapshot 3:第二次重新加载时的内存情况

snapshot 4:销毁后的内存情况

不难发现 ,每一次重新加载,会有对象更新,但是总量不变;组件销毁后,之前创建的所有对象都会被回收。

如果发生了内存泄漏,情况又是怎样的?

对象一直在增加,从未被回收;即使组件销毁,也还是保留在内存中。基于这个现象,我们的策略就是 在某次快照中找到之前相邻两个快照间分配的对象 ,这些对象一定是未回收的。例如:我们在snapshot 3中去查找snapshot1 -> snapshot2中分配的对象(蓝色圈圈),都snapshot3了,蓝色圈圈还在,那么内存泄漏了。

面板操作如下:

这样我们就可以在 Constructor区域 中看到所有没有被回收的对象。

实际情况下,Constructor 区域中会存在很多对象。没有必要查看所有的对象,我们只需要 查看发生内存泄漏的组件中存在的未回收对象的类 ,例如:它是Vue组件还是React组件?亦或者是自己实现的某个类?然后再根据下一个章节列举的特征,便可以定位到泄漏的具体原因。

内存泄漏在memory面板上的特征

学会阅读面板,有助于我们更好的定位问题。

闭包

代码如下:

var fn = (function() {
    var obj = {};
    return function() {
        obj.aa = 123
    }
})();

Retainers区域的图示:

不难发现,出现闭包的时候,fn.context.obj 就是从闭包函数到自由对象的引用关系。fn没有销毁,obj也不会销毁。

计时器

代码如下:

setInterval(function() {
    console.log(123);
}, 1000)

Retainers区域的图示:

不难发现,存在计时器的时候,同时产生了V8Function、ScheduledAction、DOMTimer 三个对象,并且被挂载在window的内置节点上。

计时器 + 闭包

代码如下:

var obj = {
    start() {
        var me = this;
        setInterval(function() {
            me.name = 123;
        }, 1000)
    }
}
obj.start();
obj = null;

Retainers区域的图示

这个链路就比较长了,先是闭包的链路,再是定时器的链路,obj仍然不会被回收。

事件未解绑

代码如下:

var dom = document.getElementById('demo');
dom.addEventListener('click', function() {
    console.log(233);
});
dom.remove();
dom = null;

Retainers区域的图示:

dom对象从文档流中被移除时,会被标记为 Detached,与之绑定的事件也会被标记为 Detached 。如果不接触绑定,这两个对象将会始终存在window的链路上,无法被回收。

我们再来看个复杂的例子吧。

事件 + 闭包

代码如下:

var dom = document.getElementById('app');
var fn = (function(){
    var obj1 = {};
    return function() {
        obj1.name = 123;
        this.name = 456;
    }
})();
dom.addEventListener('click', fn);
dom.remove();
dom = null;

Retainers区域的图示:

这个链路稍微长了一点。obj1通过闭包被点击事件处理函数引用,而事件处理函数又会被detached V8EventListener实例引用。仅仅因为事件没有解绑,导致整个链路上的所有对象都得不到回收。bind也是如此,我们继续看看:

链路上的引用关系从 context in () 变成了 bound_this in native_bind(),被bind作为作用域的对象同样也得不到回收。

console.log会导致内存泄漏?

为什么要加个问号呢?为了让这个问题在你的脑海里多逗留一会儿,加深你的记忆。之前的一段时间里,我自己团队的开发人员都忽视了console.log的“能耐”。

打开控制台,我尝试以下代码:

var obj = {
    name: 2333
};
console.log(obj);
obj = null

通过memory查看:

显而易见,没有被回收,竟然被devtools引用了。

那devtools关闭之后,会发生什么?这个对象会不会被回收?我关闭devtools,再次进行尝试:

(为了在面板上constructor区域快速搜索到这个对象,我定义了一个新类)

<!DOCTYPE html>
<html lang="en">
<link>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>测试内存泄漏</title>
</head>
<body>
    <p id="demo">hello world</p>
</body>
<script>
    function Person() {
        this.name = '苟文';
    }
    document.getElementById('demo').addEventListener('click', function() {
        console.log(new Person())
    })
</script>   
</html>

点击n次之后,我打开控制台:

真是残忍,一点都没有回收!!

目前还不清楚v8引擎为什么要保留参数中的对象。先记住这条原因吧,日后有缘知道答案,再来补充。

总结

内存泄漏,我们不得不重视,小小的疏漏就会导致崩溃问题。

在查找内存泄漏的时候,我们需要 牢记内存泄漏的原因了解面板上的泄漏特征。基于这些来追溯问题的源头。希望这篇文章能够给您带来收获。

喜欢博主的小伙伴可以加个关注、点个赞哦,今日起持续更新中!

愿天下没有泄漏的内存。

Peace And Love。