内存泄漏与业务场景
内存泄漏是一个累积的过程,只有页面生命周期略长的时候才算是个问题(所谓“刷新一下满血复活”)。频繁交互能够加快累积过程,偏展示的页面很难把这样的问题暴露出来
实际上,稍微有点交互功能(比如局部刷新)的简单页面,都可能存在内存问题,例如:
- 公交切换起终点 交互操作(比如点击按钮)可以触发重复渲染(局部)页面
- 组队每10秒刷新页面 轮询刷新数据
原因大多不在业务代码,但要保持警惕,对类似的业务场景敏感一些,自测阶段重点关注内存性能
一.工具环境
工具:
- Chrome Task Manager工具
- Chrome DevTools Performance面板
- Chrome DevTools Memory面板
环境:
- 稳定,去掉网络等变化因素(用假数据)
- 操作易重复,降低“累积”难度(简化操作步骤,比如短信验证之类的环节考虑去掉)
- 无干扰,排除插件影响(开隐身模式)
也就是说(Mac下):
- Command + Shift + N进隐身模式
- Command + Alt + I打开DevTools
- 输入URL打开页面
二.术语概念
先要具备基本的内存知识,了解DevTools提供的各项记录含义Mark-and-sweep
JS相关的GC算法主要是引用计数(IE的BOM、DOM对象)和标记清除(主流做法),各有优劣:
- 引用计数回收及时(引用数为0立即释放掉),但循环引用就永远无法释放
- 标记清除不存在循环引用的问题(不可访问就回收掉),但回收不及时需要Stop-The-World
标记清除算法步骤如下:
- GC维护一个root列表,root通常是代码中持有引用的全局变量。JS中,window对象就是一例作为root的全局变量。window对象一直存在,所以GC认为它及其所有孩子一直存在(非垃圾)
- 所有root都会被检查并标记为活跃(非垃圾),其所有孩子也被递归检查。能通过root访问到的所有东西都不会被当做垃圾
- 所有没被标记为活跃的内存块都被当做垃圾,GC可以把它们释放掉归还给操作系统
现代GC技术对这个算法做了各种改进,但本质都一样:可访问的内存块被这样标记出来后,剩下的就是垃圾
Shallow Size & Retained Size
可以把内存看做由基本类型(如数字和字符串)与对象(关联数组)构成的图。形象一点,可以把内存表示为一个由多个互连的点组成的图,如下所示:
3—>5->7 ^ ^ /| | 1 | 6—>8 | / v / 2—>4
对象可以通过两种方式占用内存:
- 直接通过对象自身占用
- 通过持有对其它对象的引用隐式占用,这种方式会阻止这些对象被垃圾回收器(简称GC)自动处理
在DevTools的堆内存快照分析面板会看到Shallow Size和Retained Size分别表示对象通过这两种方式占用的内存大小
Shallow Size
对象自身占用内存的大小。通常,只有数组和字符串会有明显的Shallow Size。不过,字符串和外部数组的主存储一般位于renderer内存中,仅将一个小包装器对象置于JavaScript堆上
renderer内存是渲染页面进程的内存总和:原生内存 + 页面的JS堆内存 + 页面启动的所有专用worker的JS堆内存。尽管如此,即使一个小对象也可能通过阻止其他对象被自动垃圾回收进程处理的方式间接地占用大量内存
Retained Size
对象自身及依赖它的对象(从GC root无法再访问到的对象)被删掉后释放的内存大小
有很多内部GC root,其中大部分都不需要关注。从应用角度来看,GC root有以下几类:
- Window全局对象(位于每个iframe中)。堆快照中有一个distance字段,表示从window出发的最短保留路径上的属性引用数量。
- 文档DOM树,由可以通过遍历document访问的所有原生DOM节点组成。并不是所有的节点都有JS包装器,不过,如果有包装器,并且document处于活动状态,包装器也将处于活动状态
- 有时,对象可能会被调试程序上下文和DevTools console保留(例如,在console求值计算后)。所以在创建堆快照调试时,要清除console并去掉断点
内存图从root开始,root可以是浏览器的window对象或Node.js模块的Global对象,我们无法控制root对象的垃圾回收方式
3—>5->7 9—>10 ^ ^ /| | 1 | 6—>8 | / v / 2—>4
其中,1是root(根节点),7和8是基本值(叶子节点),9和10将被GC掉(孤立节点),其余的都是对象(非根非叶子节点)
Object’s retaining tree
堆是一个由互连的对象组成的网络。在数学领域,这样的结构被称为“图”或内存图。图由通过边连接的节点组成,两者都以给定标签表示出来:
- 节点(或对象)用构造函数(用来构建节点)的名称标记
- 边用属性名标记
distance是指与GC root之间的距离。如果某类型的绝大多数对象的distance都相同,只有少数对象的距离偏大,就有必要仔细查查
三.工具用法
Task Manager
用来粗略地查看内存使用情况
入口在右上角三个点 -> 更多工具 -> 任务管理器,然后右键表头 -> 勾选JS使用的内存,主要关注两列:
- 内存列表示原生内存。DOM节点存储在原生内存中,如果此值正在增大,则说明正在创建DOM节点
JS使用的内存列表示JS堆。此列包含两个值,需要关注的是实时值(括号中的数值)。实时数值表示页面上的可访问对象正在使用的内存量。如果该数值在增大,要么是正在创建新对象,要么是现有对象正在增长
Performance
用来观察内存变化趋势
入口在DevTools的Performance面板,然后勾选Memory,如果想看页面首次加载过程内存使用情况的话,Command + R刷新页面,会自动记录整个加载过程。想看某些操作前后的内存变化的话,操作前点“黑点”按钮开始记录,操作完毕点“红点”按钮结束记录
记录完毕后勾选中部的JS Heap,蓝色折线表示内存变化趋势,如果总体趋势不断上涨,没有大幅回落,就再通过手动GC来确认:再操作记录一遍,操作结束前或者过程中做几次手动GC(点“黑色垃圾桶”按钮),如果GC的时间点折线没有大幅回落,整体趋势还是不断上涨,就有可能存在内存泄漏
或者更粗暴的确认方式,开始记录 -> 重复操作50次 -> 看有没有自动GC引发的大幅下降,在使用的内存大小达到阈值时会自动GC,如果有泄漏的话,操作n次总会达到阈值,也可以用来确认内存泄漏问题是否已修复
P.S.还能看到document数量(可能针对iframe),节点数量、事件监听器数量、占用GPU内存的变化趋势,其中节点数量及事件监听器数量变化也有指导意义Memory
这个面板有3个工具,分别是堆快照、内存分配情况和内存分配时间轴:
堆快照(Take Heap Snapshot),用来具体分析各类型对象存活情况,包括实例数量、引用路径等等
- 内存分配情况(Record Allocation Profile),用来查看分配给各函数的内存大小
- 内存分配时间轴(Record Allocation Timeline),用来查看实时的内存分配及回收情况
其中内存分配时间轴和堆快照比较有用,时间轴用来定位内存泄漏操作,对快照用来具体分析问题
关于具体用法的更多介绍请查看解决内存问题
Record Allocation Timeline
点开时间轴,对页面进行各种交互操作,出现的蓝色柱子表示新内存分配,灰色的表示释放回收,如果时间轴上存在规律性的蓝色柱子,那就有很大可能存在内存泄漏
然后再反复操作观察,看是什么操作导致蓝色柱子残留,剥离出具体的某个操作
Take Heap Snapshot
堆快照用来进一步分析,找到泄漏的具体对象类型
到这里应该已经锁定可疑的操作了,通过不断重复该操作,观察堆快照各项的数量变化来定位泄漏对象类型
堆快照有3种查看模式:
- Summary:摘要视图,展开并选中子项查看Object’s retaining tree(引用路径)
- Comparison:对比视图,与其它快照对比,看增、删、Delta数量及内存大小
- Containment:俯瞰视图,自顶向下看堆的情况,根节点包括window对象,GC root,原生对象等等
其中最常用的是对比视图和摘要视图,对比视图可以把2次操作和1次操作的快照做diff,看Delta增量,找出哪类对象一直在增长。摘要视图用来分析这类可疑对象,看Distance,找出奇怪的长路径上,哪一环忘记断开了
看摘要视图有个小常识是新增的东西是黄底黑字,删除的是红底黑字,本来就有的是白底黑字,这一点很关键
关于对快照用法的更多图示,请查看如何记录堆快照
四.排查步骤
先确认是否真的存在内存泄漏:
- 切换到Performance面板,开始记录(有必要从头记的话)
- 开始记录 -> 操作 -> 停止记录 -> 分析 -> 重复确认
- 确认存在内存泄漏的话,缩小范围,确定是什么交互操作引起的
也可以进一步通过Memory面板的内存分配时间轴来确认问题,Performance面板的优势是能看到DOM节点数和事件监听器的变化趋势,甚至在没有确定是内存问题拉低性能时,还可以通过Performance面板看网络响应速度、CPU使用率等因素
锁定可疑的交互操作后,通过内存快照进一步深入:
- 切换到Memory面板,截快照1
- 做一次可疑的交互操作,截快照2
- 对比快照2和1,看数量Delta是否正常
- 再做一次可疑的交互操作,截快照3
- 对比3和2,看数量Delta是否正常,猜测Delta异常的对象数量变化趋势
- 做10次可疑的交互操作,截快照4
- 对比4和3,验证猜测,确定什么东西没有被按预期回收
锁定可疑对象后,再进一步定位问题:
- 该类型对象的Distance是否正常,大多数实例都是3级4级,个别到10级以上算异常
- 看路径深度10级以上(或者明显比其它同类型实例深)的实例,什么东西引用着它
到这里基本找到问题了,接下来解决问题:
- 想办法断开这个引用
- 梳理逻辑流程,看其它地方是否存在不会再用的引用,都释放掉
- 修改验证,没解决的话重新定位
当然,梳理逻辑流程在一开始就可以做,边用工具分析,边确认逻辑流程漏洞,双管齐下,最后验证可以看Performance面板的趋势折线或者Memory面板的时间轴
五.常见案例
1.隐式全局变量
function foo(arg) { bar = “this is a hidden global variable”; }
bar就被挂到window上了,如果bar指向一个巨大的对象,或者一个DOM节点,就会代码内存隐患
另一种不太明显的方式是构造函数被直接调用(没有通过new来调用):
function foo() { this.variable = “potential accidental global”; } // Foo called on its own, this points to the global object (window) // rather than being undefined. foo();
或者匿名函数里的this,在非严格模式也指向global。可以通过lint检查或者开启严格模式来避免这些显而易见的问题
2.被忘记的timer或callback
var someResource = getData(); setInterval(function() { var node = document.getElementById(‘Node’); if(node) { // Do stuff with node and someResource. node.innerHTML = JSON.stringify(someResource)); } }, 1000);
如果后续id为Node的节点被移除了,定时器里的node变量仍然持有其引用,导致游离的DOM子树无法释放
回调函数的场景与timer类似:
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.
移除节点之前应该先移除节点身上的事件监听器,因为IE6没处理DOM节点和JS之间的循环引用(因为BOM和DOM对象的GC策略都是引用计数),可能会出现内存泄漏,现代浏览器已经不需要这么做了,如果节点无法再被访问的话,监听器会被回收掉
3.游离DOM的引用
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节点引用(性能考虑或代码简洁考虑),但移除节点的时候,应该同步释放缓存的引用,否则游离子树无法释放
另一个更隐蔽的场景是:
var select = document.querySelector; var treeRef = select(“#tree”); var leafRef = select(“#leaf”); var body = select(“body”); body.removeChild(treeRef); //#tree can’t be GC yet due to treeRef treeRef = null; //#tree can’t be GC yet due to indirect //reference from leafRef leafRef = null; //#NOW can be #tree GC
如下图:
游离子树上任意一个节点引用没有释放的话,整棵子树都无法释放,因为通过一个节点就能找到(访问)其它所有节点,都给标记上活跃,不会被清除
4.闭包
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);
粘到console执行,再通过Performance面板趋势折线或者Memory面板时间轴看内存变化,能够发现非常规律的内存泄漏(折线稳步上升,每秒一根蓝色柱子笔直笔直的)
因为闭包的典型实现方式是每个函数对象都有一个指向字典对象的关联,这个字典对象表示它的词法作用域。如果定义在replaceThing里的函数都实际使用了originalThing,那就有必要保证让它们都取到同样的对象,即使originalThing被一遍遍地重新赋值,所以这些(定义在replaceThing里的)函数都共享相同的词法环境
但V8已经聪明到把不会被任何闭包用到的变量从词法环境中去掉了,所以如果把unused删掉(或者把unused里的originalThing访问去掉),就能解决内存泄漏
只要变量被任何一个闭包使用了,就会被添到词法环境中,被该作用域下所有闭包共享。这是闭包引发内存泄漏的关键
P.S.关于这个有意思的内存泄漏问题的详细信息,请查看An interesting kind of JavaScript memory leak
六.其它内存问题
除了内存泄漏,还有两种常见的内存问题:
- 内存膨胀
- 频繁GC
内存膨胀是说占用内存太多了,但没有明确的界限,不同设备性能不同,所以要以用户为中心。了解什么设备在用户群中深受欢迎,然后在这些设备上测试页面。如果体验很差,那么页面可能存在内存膨胀的问题
频繁GC很影响体验(页面暂停的感觉,因为Stop-The-World),可以通过Task Manager内存大小数值或者Performance趋势折线来看:
- Task Manager中如果内存或JS使用的内存数值频繁上升下降,就表示频繁GC
- 趋势折线中,如果JS堆大小或者节点数量频繁上升下降,表示存在频繁GC