js内存泄漏场景、如何监控及分析详解

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

js内存泄漏场景、如何监控及分析详解
⽬录
前⾔
哪些情况会引起内存泄漏
1. 意外的全局变量
2. 遗忘的定时器
3. 使⽤不当的闭包
4. 遗漏的 DOM 元素
5. ⽹络回调
如何监控内存泄漏
如何分析内存泄漏,找出有问题的代码
实例分析
总结
前⾔
Q:什么是内存泄漏?
字⾯上的意思,申请的内存没有及时回收掉,被泄漏了
Q:为什么会发⽣内存泄漏?
虽然前端有垃圾回收机制,但当某块⽆⽤的内存,却⽆法被垃圾回收机制认为是垃圾时,也就发⽣内存泄漏了
⽽垃圾回收机制通常是使⽤标志清除策略,简单说,也就是引⽤从根节点开始是否可达来判定是否是垃圾
上⾯是发⽣内存泄漏的根本原因,直接原因则是,当不同⽣命周期的两个东西相互通信时,⼀⽅⽣命到期该回收了,却被另⼀⽅还持有时,也就发⽣内存泄漏了
所以,下⾯就来讲讲,哪些场景会造成内存泄漏
哪些情况会引起内存泄漏
1. 意外的全局变量
全局变量的⽣命周期最长,直到页⾯关闭前,它都存活着,所以全局变量上的内存⼀直都不会被回收
当全局变量使⽤不当,没有及时回收(⼿动赋值 null),或者拼写错误等将某个变量挂载到全局变量时,也就发⽣内存泄漏了
2. 遗忘的定时器
setTimeout 和 setInterval 是由浏览器专门线程来维护它的⽣命周期,所以当在某个页⾯使⽤了定时器,当该页⾯销毁时,没有⼿动去释放清理这些定时器的话,那么这些定时器还是存活着的
也就是说,定时器的⽣命周期并不挂靠在页⾯上,所以当在当前页⾯的 js ⾥通过定时器注册了某个回调函数,⽽该回调函数内⼜持有当前页⾯某个变量或某些 DOM 元素时,就会导致即使页⾯销毁了,由于定时器持有该页⾯部分引⽤⽽造成页⾯⽆法正常被回收,从⽽导致内存泄漏了
如果此时再次打开同个页⾯,内存中其实是有双份页⾯数据的,如果多次关闭、打开,那么内存泄漏会越来越严重
⽽且这种场景很容易出现,因为使⽤定时器的⼈很容易遗忘清除
3. 使⽤不当的闭包
函数本⾝会持有它定义时所在的词法环境的引⽤,但通常情况下,使⽤完函数后,该函数所申请的内存都会被回收了
但当函数内再返回⼀个函数时,由于返回的函数持有外部函数的词法环境,⽽返回的函数⼜被其他⽣命周期东西所持有,导致外部函数虽然执⾏完了,但内存却⽆法被回收
所以,返回的函数,它的⽣命周期应尽量不宜过长,⽅便该闭包能够及时被回收
正常来说,闭包并不是内存泄漏,因为这种持有外部函数词法环境本就是闭包的特性,就是为了让这块内存不被回收,因为可
能在未来还需要⽤到,但这⽆疑会造成内存的消耗,所以,不宜烂⽤就是了
4. 遗漏的 DOM 元素
DOM 元素的⽣命周期正常是取决于是否挂载在 DOM 树上,当从 DOM 树上移除时,也就可以被销毁回收了
但如果某个 DOM 元素,在 js 中也持有它的引⽤时,那么它的⽣命周期就由 js 和是否在 DOM 树上两者决定了,记得移除时,两个地⽅都需要去清理才能正常回收它
5. ⽹络回调
某些场景中,在某个页⾯发起⽹络请求,并注册⼀个回调,且回调函数内持有该页⾯某些内容,那么,当该页⾯销毁时,应该注销⽹络的回调,否则,因为⽹络持有页⾯部分内容,也会导致页⾯部分内容⽆法被回收
如何监控内存泄漏
内存泄漏是可以分成两类的,⼀种是⽐较严重的,泄漏的就⼀直回收不回来了,另⼀种严重程度稍微轻点,就是没有及时清理导致的内存泄漏,⼀段时间后还是可以被清理掉
不管哪⼀种,利⽤开发者⼯具抓到的内存图,应该都会看到⼀段时间内,内存占⽤不断的直线式下降,这是因为不断发⽣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 持有,所以也回收不了
这样层层持有,每⼀次函数的调⽤,都会持有函数上次调⽤时内部创建的局部变量,导致函数即使执⾏结束,这些局部变量也⽆法回收
⼝头说有点懵,),结合垃圾回收机制的标记清除法(俗称可达法)来看,就很明了了:
整理结论
根据利⽤内存分析⼯具,可以得到如下信息:
1. 同⼀个函数调⽤,内存占⽤却呈现梯状式上升,且⼿动 GC 内存都⽆法下降,说明内存泄漏了
2. 抓取⼀段时间的内存申请情况,可以确定嫌疑函数是 replaceThing
3. ⽐对内存快照发现,没有回收的是 replaceThing 内部创建的对象(包括存储数组的 longStr 属性和⽅法 someMethod)
4. 进⼀步分析内存快照发现,之所以不回收,是因为每次函数调⽤创建的这个对象会被存储在函数上⼀次调⽤时内部创建
的局部变量 o 上
5. ⽽局部变量 o 在函数执⾏结束没被回收,是因为,它被创建的对象的 someMethod ⽅法所持有
以上,就是结论,但我们还得分析为什么会出现这种情况,是吧
其实,这就涉及到闭包的知识点了:
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 是否可⽤,那么为什么不直接再使⽤个全局变量来存储,为什么选择了局部变量?
所以,当写代码时,当涉及到闭包的场景时,应该要特别注意,如果使⽤不当,很可能会造成⼀些严重的内存泄漏场景
应该铭记,闭包会让函数持有外部的词法环境,导致外部词法环境的某些变量⽆法被回收,还有共享⼀个闭包这种特性,只有清楚这两点,才能在涉及到闭包使⽤场景时,正确考虑该如何实现,避免造成严重的内存泄漏
总结
到此这篇关于js内存泄漏场景、如何监控及分析的⽂章就介绍到这了,更多相关js内存泄漏场景监控内容请搜索以前的⽂章或继续浏览下⾯的相关⽂章希望⼤家以后多多⽀持!。

相关文档
最新文档