本文收录于 gitbook.dasu.fun前端
Q:什么是内存泄漏?git
字面上的意思,申请的内存没有及时回收掉,被泄漏了数组
Q:为何会发生内存泄漏?浏览器
虽然前端有垃圾回收机制,但当某块无用的内存,却没法被垃圾回收机制认为是垃圾时,也就发生内存泄漏了网络
而垃圾回收机制一般是使用标志清除策略,简单说,也就是引用从根节点开始是否可达来断定是不是垃圾闭包
上面是发生内存泄漏的根本缘由,直接缘由则是,当不一样生命周期的两个东西相互通讯时,一方生命到期该回收了,却被另外一方还持有时,也就发生内存泄漏了函数
因此,下面就来说讲,哪些场景会形成内存泄漏工具
全局变量的生命周期最长,直到页面关闭前,它都存活着,因此全局变量上的内存一直都不会被回收post
当全局变量使用不当,没有及时回收(手动赋值 null),或者拼写错误等将某个变量挂载到全局变量时,也就发生内存泄漏了性能
setTimeout 和 setInterval 是由浏览器专门线程来维护它的生命周期,因此当在某个页面使用了定时器,当该页面销毁时,没有手动去释放清理这些定时器的话,那么这些定时器仍是存活着的
也就是说,定时器的生命周期并不挂靠在页面上,因此当在当前页面的 js 里经过定时器注册了某个回调函数,而该回调函数内又持有当前页面某个变量或某些 DOM 元素时,就会致使即便页面销毁了,因为定时器持有该页面部分引用而形成页面没法正常被回收,从而致使内存泄漏了
若是此时再次打开同个页面,内存中实际上是有双份页面数据的,若是屡次关闭、打开,那么内存泄漏会愈来愈严重
并且这种场景很容易出现,由于使用定时器的人很容易遗忘清除
函数自己会持有它定义时所在的词法环境的引用,但一般状况下,使用完函数后,该函数所申请的内存都会被回收了
但当函数内再返回一个函数时,因为返回的函数持有外部函数的词法环境,而返回的函数又被其余生命周期东西所持有,致使外部函数虽然执行完了,但内存却没法被回收
因此,返回的函数,它的生命周期应尽可能不宜过长,方便该闭包可以及时被回收
正常来讲,闭包并非内存泄漏,由于这种持有外部函数词法环境本就是闭包的特性,就是为了让这块内存不被回收,由于可能在将来还须要用到,但这无疑会形成内存的消耗,因此,不宜烂用就是了
DOM 元素的生命周期正常是取决因而否挂载在 DOM 树上,当从 DOM 树上移除时,也就能够被销毁回收了
但若是某个 DOM 元素,在 js 中也持有它的引用时,那么它的生命周期就由 js 和是否在 DOM 树上二者决定了,记得移除时,两个地方都须要去清理才能正常回收它
某些场景中,在某个页面发起网络请求,并注册一个回调,且回调函数内持有该页面某些内容,那么,当该页面销毁时,应该注销网络的回调,不然,由于网络持有页面部份内容,也会致使页面部份内容没法被回收
内存泄漏是能够分红两类的,一种是比较严重的,泄漏的就一直回收不回来了,另外一种严重程度稍微轻点,就是没有及时清理致使的内存泄漏,一段时间后仍是能够被清理掉
无论哪种,利用开发者工具抓到的内存图,应该都会看到一段时间内,内存占用不断的直线式降低,这是由于不断发生 GC,也就是垃圾回收致使的
针对第一种比较严重的,会发现,内存图里即便不断发生 GC 后,所使用的内存总量仍旧在不断增加
另外,内存不足会形成不断 GC,而 GC 时是会阻塞主线程的,因此会影响到页面性能,形成卡顿,因此内存泄漏问题仍是须要关注的
咱们假设这么一种场景,而后来用开发者工具查看下内存泄漏:
场景一:在某个函数内申请一块内存,而后该函数在短期内不断被调用
// 点击按钮,就执行一次函数,申请一块内存
startBtn.addEventListener("click", function() {
var a = new Array(100000).fill(1);
var b = new Array(20000).fill(1);
});
复制代码
一个页面可以使用的内存是有限的,当内存不足时,就会触发垃圾回收机制去回收没用的内存
而在函数内部使用的变量都是局部变量,函数执行完毕,这块内存就没用能够被回收了
因此当咱们短期内不断调用该函数时,能够发现,函数执行时,发现内存不足,垃圾回收机制工做,回收上一个函数申请的内存,由于上个函数已经执行结束了,内存无用可被回收了
因此图中呈现内存使用量的图表就是一条横线过去,中间出现多处竖线,其实就是表示内存清空,再申请,清空再申请,每一个竖线的位置就是垃圾回收机制工做以及函数执行又申请的时机
场景二:在某个函数内申请一块内存,而后该函数在短期内不断被调用,但每次申请的内存,有一部分被外部持有
// 点击按钮,就执行一次函数,申请一块内存
var arr = [];
startBtn.addEventListener("click", function() {
var a = new Array(100000).fill(1);
var b = new Array(20000).fill(1);
arr.push(b);
});
复制代码
看一下跟第一张图片有什么区别?
再也不是一条横线了吧,并且横线中的每一个竖线的底部也不是同一水平了吧
其实这就是内存泄漏了
咱们在函数内申请了两个数组内存,但其中有个数组却被外部持有,那么,即便每次函数执行完,这部分被外部持有的数组内存也依旧回收不了,因此每次只能回收一部份内存
这样一来,当函数调用次数增多时,无法回收的内存就越多,内存泄漏的也就越多,致使内存使用量一直在增加
另外,也可使用 performance monitor 工具,在开发者工具里找到更多的按钮,在里面打开此功能面板,这是一个能够实时监控 cpu,内存等使用状况的工具,会比上面只能抓取一段时间内工具更直观一点:
梯状上升的就是发生内存泄漏了,每次函数调用,总有一部分数据被外部持有致使没法回收,然后面平滑状的则是每次使用完均可以正常被回收
这张图须要注意下,第一个红框末尾有个直线式下滑,这是由于,我修改了代码,把外部持有函数内申请的数组那行代码去掉,而后刷新页面,手动点击 GC 才触发的效果,不然,不管你怎么点 GC,有部份内存一直没法回收,是达不到这样的效果图的
以上,是监控是否发生内存泄漏的一些工具,但下一步才是关键,既然发现内存泄漏,那该如何定位呢?如何知道,是哪部分数据没被回收致使的泄漏呢?
分析内存泄漏的缘由,仍是须要借助开发者工具的 Memory 功能,这个功能能够抓取内存快照,也能够抓取一段时间内,内存分配的状况,还能够抓取一段时间内触发内存分配的各函数状况
利用这些工具,咱们能够分析出,某个时刻是因为哪一个函数操做致使了内存分配,分析出大量重复且没有被回收的对象是什么
这样一来,有嫌疑的函数也知道了,有嫌疑的对象也知道了,再去代码中分析下,这个函数里的这个对象究竟是不是就是内存泄漏的元凶,搞定
先举个简单例子,再举个实际内存泄漏的例子:
场景一:在某个函数内申请一块内存,而后该函数在短期内不断被调用,但每次申请的内存,有一部分被外部持有
// 每次点击按钮,就有一部份内存没法回收,由于被外部 arr 持有了
var arr = [];
startBtn.addEventListener("click", function() {
var a = new Array(100000).fill(1);
var b = new Array(20000).fill(1);
arr.push(b);
});
复制代码
能够抓取两份快照,两份快照中间进行内存泄漏操做,最后再比对两份快照的区别,查看增长的对象是什么,回收的对象又是哪些,如上图。
也能够单独查看某个时刻快照,从内存占用比例来查看占据大量内存的是什么对象,以下图:
还能够从垃圾回收机制角度出发,查看从 GC root 根节点出发,可达的对象里,哪些对象占用大量内存:
从上面这些方式入手,均可以查看到当前占用大量内存的对象是什么,通常来讲,这个就是嫌疑犯了
固然,也并不必定,当有嫌疑对象时,能够利用屡次内存快照间比对,中间手动强制 GC 下,看下该回收的对象有没有被回收,这是一种思路
这个方式,能够有选择性的查看各个内存分配时刻是由哪一个函数发起,且内存存储的是什么对象
固然,内存分配是正常行为,这里查看到的还须要借助其余数据来判断某个对象是不是嫌疑对象,好比内存占用比例,或结合内存快照等等
这个能看到的内容不多,比较简单,目的也很明确,就是一段时间内,都有哪些操做在申请内存,且用了多少
总之,这些工具并无办法直接给你答复,告诉你 xxx 就是内存泄漏的元凶,若是浏览器层面就能肯定了,那它干吗不回收它,干吗还会形成内存泄漏
因此,这些工具,只能给你各类内存使用信息,你须要本身借助这些信息,根据本身代码的逻辑,去分析,哪些嫌疑对象才是内存泄漏的元凶
来个网上不少文章都出现过的内存泄漏例子:
var t = null;
var replaceThing = function() {
var o = t
var unused = function() {
if (o) {
console.log("hi")
}
}
t = {
longStr: new Array(100000).fill('*'),
someMethod: function() {
console.log(1)
}
}
}
setInterval(replaceThing, 1000)
复制代码
也许你还没看出这段代码是否是会发生内存泄漏,缘由在哪,不急
先说说这代码用途,声明了一个全局变量 t 和 replaceThing 函数,函数目的在于会为全局变量赋值一个新对象,而后内部有个变量存储全局变量 t 被替换前的值,最后定时器周期性执行 replaceThing 函数
咱们先利用工具看看,是否是会发生内存泄漏:
三种内存监控图表都显示,这发生内存泄漏了:反复执行同个函数,内存却梯状式增加,手动点击 GC 内存也没有降低,说明函数每次执行都有部份内存泄漏了
这种手动强制垃圾回收都没法将内存将下去的状况是很严重的,长期执行下去,会耗尽可用内存,致使页面卡顿甚至崩掉
既然已经肯定有内存泄漏了,那么接下去就该找出内存泄漏的缘由了
首先经过 sampling profile,咱们把嫌疑定位到 replaceThing 这个函数上
接着,咱们抓取两分内存快照,比对一下,看看可否获得什么信息:
比对两份快照能够发现,这过程当中,数组对象一直在增长,并且这个数组对象来自 replaceThing 函数内部建立的对象的 longStr 属性
其实这张图信息不少了,尤为是下方那个嵌套图,嵌套关系是反着来,你倒着看的话,就能够发现,从全局对象 Window 是如何一步步访问到该数组对象的,垃圾回收机制正是由于有这样一条可达的访问路径,才没法回收
其实这里就能够分析了,为了多使用些工具,咱们换个图来分析吧
咱们直接从第二分内存快照入手,看看:
从第一份快照到第二份快照期间,replaceThing 执行了 7 次,恰好建立了 7 份对象,看来这些对象都没有被回收
那么为何不会被回收呢?
replaceThing 函数只是在内部保存了上份对象,但函数执行结束,局部变量不该该是被回收了么
继续看图,能够看到底下还有个闭包占用很大内存,看看:
为何每一次 replaceThing 函数调用后,内部建立的对象都没法被回收呢?
由于 replaceThing 的第一次建立,这个对象被全局变量 t 持有,因此回收不了
后面的每一次调用,这个对象都被上一个 replaceThing 函数内部的 o 局部变量持有而回收不了
而这个函数内的局部变量 o 在 replaceThing 首次调用时被建立的对象的 someMethod 方法持有,该方法挂载的对象被全局变量 t 持有,因此也回收不了
这样层层持有,每一次函数的调用,都会持有函数上次调用时内部建立的局部变量,致使函数即便执行结束,这些局部变量也没法回收
口头说有点懵,盗张图(侵权删),结合垃圾回收机制的标记清除法(俗称可达法)来看,就很明了了:
根据利用内存分析工具,能够获得以下信息:
以上,就是结论,但咱们还得分析为何会出现这种状况,是吧
其实,这就涉及到闭包的知识点了:
MDN 对闭包的解释是,函数块以及函数定义时所在的词法环境二者的结合就称为闭包
而函数定义时,自己就会有一个做用域的内部属性存储着当前的词法环境,因此,一旦某个函数被比它所在的词法环境还长的生命周期的东西所持有,此时就会形成函数持有的词法环境没法被回收
简单说,外部持有某个函数内定义的函数时,此时,若是内部函数有使用到外部函数的某些变量,那么这些变量即便外部函数执行结束了,也没法被回收,由于转而被存储在内部函数的属性上了
还有一个知识点,外部函数里定义的全部函数共享一个闭包,也就是 b 函数使用外部函数 a 变量,即便 c 函数没使用,但 c 函数仍旧会存储 a 变量,这就叫共享闭包
回到这道题
由于 replaceThing 函数里,手动将内部建立的字面量对象赋值给全局变量,并且这个对象还有个 someMethod 方法,因此 someMethod 方法就由于闭包特性存储着 replaceThing 的变量
虽然 someMethod 内部并无使用到什么局部变量,但 replaceThing 内部还有一个 unused 函数啊,这个函数就使用了局部变量 o,由于共享闭包,致使 someMethod 也存储着 o
而 o 又存着全局变量 t 替换前的值,因此就致使了,每一次函数调用,内部变量 o 都会有人持有它,因此没法回收
想要解决这个内存泄漏,就是要砍断 o 的持有者,让局部变量 o 可以正常被回收
因此有两个思路:要么让 someMethod 不用存储 o;要么使用完 o 就释放;
若是 unused 函数没有用,那能够直接去掉这个函数,而后看看效果:
这里之因此还会梯状式上升是由于,当前内存还足够,尚未触发垃圾回收机制工做,你能够手动触发 GC,或者运行一段时间等到 GC 工做后查看一下,内存是否降低到初始状态,这代表,这些内存均可以被回收的
或者拉分内存快照看看,拉快照时,会自动先强制进行 GC 再拉取快照:
是吧,即便周期性调用 replaceThing 函数,函数内的局部变量 o 即便存储着上个全局变量 t 的值,但毕竟是局部变量,函数执行完毕,若是没有外部持有它的引用,也就能够被回收掉了,因此最终内存就只剩下全局变量 t 存储的对象了
固然,若是 unused 函数不能去掉,那么就只能是使用完 o 变量后须要记得手动释放掉:
var unused = function() {
if (o) {
console.log("hi")
o = null;
}
}
复制代码
但这种作法,不治本,由于在 unused 函数执行前,这堆内存仍是一直存在着的,仍是一直泄漏没法被回收的,与最开始的区别就在于,至少在 unused 函数执行后,就能够释放掉而已
其实,这里应该考虑的代码有没有问题,为何须要局部变量存储,为何须要 unused 函数的存在,这个函数的目的又是什么,若是只是为了在未来某个时刻用来判断上个全局变量 t 是否可用,那么为何不直接再使用个全局变量来存储,为何选择了局部变量?
因此,当写代码时,当涉及到闭包的场景时,应该要特别注意,若是使用不当,极可能会形成一些严重的内存泄漏场景
应该铭记,闭包会让函数持有外部的词法环境,致使外部词法环境的某些变量没法被回收,还有共享一个闭包这种特性,只有清楚这两点,才能在涉及到闭包使用场景时,正确考虑该如何实现,避免形成严重的内存泄漏