如何定位前端内存泄漏问题

2,930 阅读12分钟

一、 什么是内存泄漏?

本质上,内存泄露可以定义为:应用程序不再需要占用内存的时候,由于某些原因,内存没有被操作系统或可用内存池回收。

二、术语概念

需要了解Chrome DevTools提供的各项记录含义

Mark-and-sweep(标记清除算法)

JS相关的GC算法主要是引用计数(IE的BOM、DOM对象)和标记清除(主流做法),各有优劣

  • 引用计数回收及时(引用数为0立即释放掉),但循环引用就永远无法释放
  • 标记清除不存在循环引用的问题(不可访问就回收掉),但回收不及时需要Stop-The-World

标记清除算法步骤如下:

  1. GC维护一个root列表,root通常是代码中持有引用的全局变量。JS中,window对象就是一例作为root的全局变量。window对象一直存在,所以GC认为它及其所有孩子一直存在(非垃圾)
  2. 所有root都会被检查并标记为活跃(非垃圾),其所有孩子也被递归检查。能通过root访问到的所有东西都不会被当做垃圾
  3. 所有没被标记为活跃的内存块都被当做垃圾,GC可以把它们释放掉归还给操作系统。

现代GC技术对这个算法做了各种改进,但本质都一样:可访问的内存块被这样标记出来后,剩下的就是垃圾

Shallow Size & Retained Size

可以把内存看做由基本类型(如数字和字符串)与对象(关联数组)构成的图。形象一点,可以把内存表示为一个由多个互连的点组成的图

Shallow Size

对象自身占用内存的大小。通常,只有数组和字符串会有明显的Shallow Size。不过,字符串和外部数组的主存储一般位于renderer内存中,仅将一个小包装器对象置于JS堆上

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对象的垃圾回收方式

######1是root(根节点),7和8是基本值(叶子节点),9和10将被GC掉(孤立节点),其余的都是对象(非根非叶子节点); Note: Shallow 和 Retained size 都用字节为单位来表示数据

Object’s retaining tree

堆是一个由互连的对象组成的网络。在数学领域,这样的结构被称为“图”或内存图。图由通过边连接的节点组成,两者都以给定标签表示出来:

  • 节点(或对象)用构造函数(用来构建节点)的名称标记
  • 边用属性名标记

distance是指与GC root之间的距离。如果某类型的绝大多数对象的distance都相同,只有少数对象的距离偏大,就有必要仔细查查

三、使用工具定位

Task Manager

打开方式: 按 Shift+Esc或者Google浏览器右上角三个点 -> 更多工具 -> 任务管理器 ,然后 右键表头 -> 勾选JS使用的内存,主要关注这两列,如下图所示

  • 内存占用空间表示原生内存。DOM 节点存储在原生内存中。 如果值正在增大,则说明正在创建 DOM 节点。
  • JavaScript使用的内存列表示 JS 堆。此列包含两个值有实时数字(括号中的数字)。 实时数字表示页面上的可到达对象正在使用的内存量。 如果此数字在增大,要么是正在创建新对象,要么是现有对象正在增长。

Note:这里只做参考作用,不做实际的内存泄漏判断依据,在定位内存泄漏中遇到过因为console输出等增加内存的问题,导致误以为是内存泄漏

Performance

用来观察内存变化趋势 打开方式: F12 -> 在DevTools的Performance面板,然后勾选Memory; 下面是一个简单的内存泄漏例子说明:

开始排查:

  1. 开始记录 -> 页面操作 -> 停止记录 -> 分析 -> 重复确认;
  2. 确认存在内存泄漏的话,缩小范围,确定是什么交互操作引起的

分析: 从上图的Performance图例中可以看出,上方的时间轴内存变化一直缓慢上升无回落; 下方选中的JS Heap的内存变化总体趋势在上升,没有大幅回落,说明存在内存泄漏。 其他勾选的的Documnerts(文档)、Nodes(DOM节点)、Listeners(侦听器)、GPU Memory(GPU内存)的变化有较大回落; 重复操作几次,操作结束前或者过程中做几次手动GC,如果GC的时间点折线没有大幅回落,整体趋势还是不断上涨,就有可能存在内存泄漏。

记录多次操作页面的内存变化, 看有没有自动GC引发的大幅下降,在使用的内存大小达到阈值时会自动GC;如果有泄漏的话,操作n次总会达到阈值,也可以用来确认内存泄漏问题是否已修复。

除此之外还能看到DOM节点数和事件监听器的变化趋势,甚至在没有确定是内存问题拉低性能时,还可以通过Performance面板看网络响应速度、CPU使用率等因素。 进一步再通过Memory面板的内存分配时间轴来确认内存泄漏问题

Memory

这个面板有3个工具,分别是堆快照、内存分配情况和内存分配时间轴:

  • 堆快照(Heap Snapshot),用来具体分析各类型对象存活情况,包括实例数量、引用路径等等
  • 内存分配情况(Allocation sampling),用来查看分配给各函数的内存大小
  • 内存分配时间轴(Allocation instrumenttation on Timeline),用来查看实时的内存分配及回收情况 内存分配时间轴和堆快照比较有用,时间轴用来定位内存泄漏操作,堆快照用来具体分析问题位置
Allocation instrumenttation on Timeline

点开时间轴,对页面进行各种交互操作,出现的蓝色柱子表示新内存分配,灰色的表示释放回收,如果时间轴上存在规律性的蓝色柱子,那就有很大可能存在内存泄漏 然后再反复操作观察,看是什么操作导致蓝色柱子残留,剥离出具体的某个操作;

图例是对点击按钮增加arr的内存分配时间轴变化记录;例子中当每次点击按钮时会遗留一个蓝色柱子,当进行可疑操作时,如果存在内存泄漏,就可以得到是什么操作导致的内存泄漏

Heap Snapshot

再通过记录堆快照进行分析,打开快照默认已Summary视图显示,共有4种查看模式:

  • Summary:摘要视图,展开并选中子项查看Object’s retaining tree(引用路径)
  • Comparison:对比视图,与其它快照对比,看增、删、Delta数量及内存大小
  • Containment:俯瞰视图,自顶向下看堆的情况,根节点包括window对象,GC root,原生对象等等
  • Statistics:总览堆的统计信息。

我们主要关注Summary视图和Comparison视图。

Summary 视图

视图中的顶层栏目对应表示的含义:

  • Constructor 表示使用此构造函数创建的所有对象。 对象实例数显示在 # 列中。
  • Shallow Size 列显示通过特定构造函数创建的所有对象浅层大小的总和。浅层大小是指对象自身占用的内存大小(一般来说,数组和字符串的浅层大小比较大)
  • Retained Size 列显示同一组对象中最大的保留大小。某个对象删除后(其依赖项不再可到达)可以释放的内存大小称为保留大小。另请参阅对象大小。
  • Distance 显示使用节点最短简单路径时距根节点的距离,一般的类型对象的Distance是否正常,大多数实例都是3级4级,个别到10级以上算异常

各个构造函数(Constructor)条目在堆分析器中对应含义:

  • (global property)– 全局对象(例如“window”)与其引用的对象之前的中间对象。如果对象使用构造函数 Person 创建且由某个全局对象占用,那么保留路径将类似于 [global] >(全局属性)> Person。这与常规相反,常规情况下对象直接引用彼此。我们出于性能原因而采用中间对象。全局项会定期修改,而属性访问优化则非常适合不适用于全局项的非全局对象。

  • (Roots)– 保留树中的根条目是引用选定对象的条目。这些条目也可能是引擎出于其自身目的创建的引用。引擎具有引用对象的缓存,但所有此类引用非常弱,并且如果没有很强的引用,无法阻止对象被回收。

  • (closure)– 使用函数闭包的对象

  • (array、string、number、regexp) – 不同对象类型的列表,这些类型具有引用 Array、String、Number 或正则表达式的属性。

  • (compiled code)– 简单地说就是与已编译代码相关的任何内容。脚本与函数类似,但对应于

  • HTMLDivElement、HTMLAnchorElement、DocumentFragment等 – 引用元素或者您的代码所引用特定类型的文本对象等

Comparison视图

Comparison视图中增加的顶层栏意义:

  • New - Comparison 特有 - 新增项
  • Deleted - Comparison 特有 - 删除项
  • Delta - Comparison 特有 - 增量
  • Alloc. Size - Comparison 特有 - 内存分配大小
  • Freed Size - Comparison 特有 - 释放大小
  • Size Delta - Comparison 特有 - 内存增量

通过相互比较多个快照,查找内存泄漏的对象;可疑进行以下操作:

  1. 在执行任何操作前拍摄一个堆快照1。
  2. 做一次可疑的交互操作,截快照2
  3. 对比快照2和1,看数量Delta是否正常
  4. 再做一次可疑的交互操作,截快照3
  5. 对比3和2,看数量Delta是否正常,猜测Delta异常的对象数量变化趋势
  6. 做10次以上可疑的交互操作,截快照4
  7. 对比4和3,验证猜测,确定什么东西没有被按预期回收
附图例1

另附上在智能相机开发中定位内存泄漏时发现的Comparison视图,附图例2

分析

从图例1中可以看出,我们在本文中进行的例子说明只有Array对象的增加导致的内存泄漏,进行多次快照截取后进行对比后得出,只有Array和array两个Constructor的Delta有增加;并查看对象后得出增加的 ,当然也有我们在已知的情况下进行的例子说明。

从图例2中我们可以得出,在之前智能相机项目中遇到的内存泄漏问题主要是有表格元素的不断增加导致的内存泄漏,查看下方的Object对象可以看出,路径深度(Distance)有七八十层;后面定位到是因为不断增加表格行内容,而没有设置上限导致内存不断增加;最后达到上限;导致页面崩溃现象;所以需要限制此类操作上限。

常见问题

  1. 隐式全局变量
  2. 被忘记的timer或callback
  3. 游离DOM的引用
  4. 闭包

总结

在智能相机开发中遇到页面崩溃或浏览器崩溃现象,一开始不怎么清除怎么去定位到具体的位置导致崩溃现象发生;所以就仔细的查阅相关资料,并使用Google DevTools工具中的 Performance和Memory中的工具定位到页面崩溃的原因及具体位置;并修改逻辑解决内存不断增加导致页面崩溃的现象;一般我们在写前端代码时并不会特别注意内存泄漏相关问题; 但是出现问题需要定位并解决,以上步骤及说明应该可以比较容易的找到页面可疑操作并找到相关代码位置导致的错误;需要注意的是定位前需将console的输出删除避免影响判断; 任务管理器中(Task Manager)的内存变化只做参考作用;无法作为主要依据;主要判断可通过Performance和Memory这两部分去仔细排查分析,判断内存是否泄漏并找到相关对象及位置;最后通过代码层或其他手段解决问题