内存泄漏是指计算机可用内存的逐渐减少。当程序持续无法释放其使用的临时内存时就会发生。JavaScript的web应用也会经常遇到在原生应用程序中出现的内存相关的问题,如泄漏和溢出,web应用也需要应对垃圾回收停顿。
你需要思考的问题
总体来说,当你觉得你遇到了内存泄漏问题时,你需要思考三个问题:
- 我的页面是否占用了过多的内存? - Timeline内存查看工具(Timeline memory view) 和 Chrome任务管理(Chrome task manager) 能帮助你确认你是否使用了过多的内存。Memory view 能跟踪页面渲染过程中DOM节点计数,documents文档计数和JS事件监听计数。作为一个经验法则:避免对不再需要用到的DOM元素的引用,移除不需要的事件监听并且在存储你可能不会用到的大块数据时要留意。
- 我的页面有没有内存泄漏? - 对象分配跟踪(Object allocation tracker)通过实时查看JS对象的分配来帮助你定位泄漏。你也可以使用堆分析仪(Heap Profiler)生成JS堆快照,通过分析内存图和比较快照之间的差异,来找出没有被垃圾回收清理掉的对象。
- 我的页面垃圾强制回收有多频繁? - 如果你的页面垃圾回收很频繁,那说明你的页面可能内存使用分配太频繁了。Timeline内存查看工具(Timeline memory view) 能够帮助你发现感兴趣的停顿。
术语和基本概念
本小节介绍在内存分析时使用的常用术语,这些术语在为其它语言做内存分析的工具中也适用。这里的术语和概念用在了堆分析仪(Heap Profiler)UI工具和相关的文档中。
这些能够帮助我们熟悉如何有效的使用内存分析工具。如果你曾用过像Java、.NET等语言的内存分析工具的话,那么这将是一个复习。
对象大小(Object sizes)
把内存想象成一个包含基本类型(像数字和字符串)和对象(关联数组)的图表。它可能看起来像下面这幅一系列相关联的点组成的图。
一个对象有两种使用内存的方法:
- 对象自身直接使用
- 隐含的保持对其它对象的引用,这种方式会阻止垃圾回收(简称GC)对那些对象的自动回收处理。
当你使用DevTools中的堆分析仪(Heap Profiler,用来分析内存问题的工具,在DevTools的”Profile”标签下)时,你可能会惊喜的发现一些显示各种信息的栏目。其中有两项是:直接占用内存(Shallow Size)和占用总内存(Retained Size),那它们是什么意思呢?
直接占用内存(Shallow Size,不包括引用的对象占用的内存)
这个是对象本身占用的内存。
典型的JavaScript对象都会有保留内存用来描述这个对象和存储它的直接值。一般,只有数组和字符串会有明显的直接占用内存(Shallow Size)。但字符串和数组常常会在渲染器内存中存储主要数据部分,仅仅在JavaScript对象栈中暴露一个很小的包装对象。
渲染器内存指你分析的页面在渲染的过程中所用到的所有内存:页面本身的内存 + 页面中的JS堆用到的内存 + 页面触发的相关工作进程(workers)中的JS堆用到的内存。然而,通过阻止垃圾自动回收别的对象,一个小对象都有可能间接占用大量的内存。
占用总内存(Retained Size,包括引用的对象所占用的内存)
一个对象一但删除后它引用的依赖对象就不能被GC根(GC root)引用到,它们所占用的内存就会被释放,一个对象占用总内存包括这些依赖对象所占用的内存。
GC根是由控制器(handles)组成的,这些控制器(不论是局部还是全局)是在建立由build-in函数(native code)到V8引擎之外的JavaScript对象的引用时创建的。所有这些控制器都能够在堆快照的GC roots(GC根) > Handle scope 和 GC roots >Global handlers中找到。如果不深入了解浏览器的实现原理,在这篇文章中介绍这些控制器可能会让人不能理解。GC根和控制器你都不需要过多关心。
有很多内部的GC根对用户来说都是不重要的。从应用的角度来说有下面几种情况:
- Window 全局对象 (所有iframe中的)。在堆快照中有一个distance字段,它是从window对象到达对应对象的最短路径长度。
- 由所有document能够遍历到的DOM节点组成的文档DOM树。不是所有节点都会被对应的JS引用,但有JS引用的节点在document存在的情况下都会被保留。
- 有很多对象可能是在调试代码时或者DevTools console中(比如:console中的一些代码执行结束后)创建出来的。
注意:我们推荐用户在创建堆快照时,不要在console中执行代码,也不要启用调试断点。
内存图由一个根部开始,可能是浏览器的window
对象或Node.js模块Global
对象。这些对象如何被内存回收不受用户的控制。
不能被GC根遍历到的对象都将被内存回收。
注意:直接占用内存和占用总内存字段中的数据是用字节表示的。
对象的占用总内存树
之前我们已经了解到,堆是由各种互相关联的对象组成的网状结构。在数字领域,这种结构被称为图或内存图。图是由边缘(edges)连接着的节点(nodes)组成的,他们都被贴了标签。
- 节点(Nodes) (或对象) 节点的标签名是由创建他们的构造(constructor)函数的名称确定
- 边缘(Edges) 标签名就是属性名
本文档的后面你将了解到如何使用堆分析仪生成快照。从下图的堆分析仪生成的快照中,我们能看到距离(distance)这个字段:是指对象到GC根的距离。如果同一个类型的所有对象的距离都一样,而有一小部分的距离却比较大,那么就可能出了些你需要进行调查的问题了。
支配对象(Dominators)
支配对象就像一个树结构,因为每个对象都有一个支配者。一个对象的支配者可能不会直接引用它支配的对象,就是说,支配对象树结构不是图中的生成树。
在上图中:
- 节点1支配节点2
- 节点2支配节点3,4和6
- 节点3支配节点5
- 节点5支配节点8
- 节点6支配节点7
在下图的例子中,节点#3
是#10
的支配者,但#7
也在每个从GC到#10
的路经中都出现了。像这样,如果B对象在每个从根节点到A对象的路经中都出现,那么B对象就是A对象的支配对象。
V8介绍
在本节,我们将描述一些内存相关的概念,这些概念是和V8 JavaScript虚拟机(V8 VM 或VM)有关的。当分析内存时,了解这些概念对理解堆快照是有帮助的。
JavaScript对象描述
有三个原始类型:
- 数字(Numbers) (如 3.14159..)
- 布尔值(Booleans) (true或false)
- 字符型(Strings) (如 ‘Werner Heisenberg’)
它们不会引用别的值,它们只会是叶子节点或终止节点。
数字(Numbers)以下面两种方式之一被存储:
- 31位整数直接值,称做:小整数(small integers)(SMIs),或
- 堆对象,引用为堆值。堆值是用来存储不适合用SMI形式存储的数据,像双精度数(doubles),或者当一个值需要被打包(boxed)时,如给这个值再设置属性值。
字符型数据会以下面两种方式存储:
- VM堆,或
- 外部的渲染器内存中。这时会创建一个包装对象用来访问存储的位置,比如,Web页面包存的脚本资源和其它内容,而不是直接复制至VM堆中。
新创建的JavaScript对象会被在JavaScript堆上(或VM堆)分配内存。这些对象由V8的垃圾回收器管理,只要还有一个强引用他们就会在内存中保留。
本地对象是所有不在JavaScript堆中的对象,与堆对象不同的是,在它们的生命周期中,不会被V8垃圾加收器处理,只能通过JavaScript包装对象引用。
连接字符串是由一对字符串合并成的对象,是合并后的结果。连接字符串只在有需要时合并。像一连接字符串的子字符串需要被构建时。
比如:如果你连接a和b,你得到字符串(a, b)这用来表示连接的结果。如果你之后要再把这个结果与d连接,你就得到了另一个连接字符串((a, b), d)。
数组(Arrays) - 数组是数字类型键的对象。它们在V8引擎中存储大数据量的数据时被广泛的使用。像字典这种有键-值对的对象就是用数组实现的。
一个典型的JavaScript对象可以通过两种数组类型之一的方式来存储:
- 命名属性,和
- 数字化的元素
如果只有少量的属性,它们会被直接存储在JavaScript对象本身中。
Map - 一种用来描述对象类型和它的结构的对象。比如,maps会被用来描述对象的结构以实现对对象属性的快速访问
对象组
每个本地对象组都是由一组之间相互关联的对象组成的。比如一个DOM子树,每个节点都能访问到它的父元素,下一个子元素和下一个兄弟元素,它们构成了一个关联图。需要注意的是本地元素没有在JavaScript堆中表现-这就是它们的大小是零的原因,而它的包装对象被创建了。
每个包装对象都会有一个到本地对象的引用,用来传递对这些本地对象的操作。这些本地对象也有到包装对象的引用。但这并不会创造无法收回的循环,GC是足够智能的,能够分辨出那些已经没有引用包装对象的本地对象并释放它们的。但如果有一个包装对象没有被释放那它将会保留所有对象组和相关的包装对象。
先决条件和有用提示
Chrome 任务管理器
注意: 当使用Chrome做内存分析时,最好设置一个洁净的测试环境
打开Chrome的内存管理器,观察内存字段,在一个页面上做相关的操作,你可以很快定位这个操作是否会导致页面占用很多内存。你可以从Chrome菜单 > 工具或按Shift + Esc,找到内存管理器。
打开后,在标头右击选用 JavasScript使用的内存 这项。
通过DevTools Timeline来定位内存问题
解决问题的第一步就是要能够证明问题存在。这就需要创建一个可重现的测试来做为问题的基准度量。没有可再现的程序,就不能可靠的度量问题。换句话说如果没有基准来做为对比,就无法知道是哪些改变使问题出现的。
时间轴面版(Timeline panel)对于发现程序什么时候出了问题很用帮助。它展示了你的web应用或网站加载和交互的时刻。所有的事件:从加载资源到解JavaScript,样式计算,垃圾回收停顿和页面重绘。都在时间轴上表示出来了。
当分析内存问题时,时间轴面版上的内存视图(Memory view)能用来观察:
- 使用的总内存 – 内存使用增长了么?
- DOM节点数
- 文档(documents)数
- 注册的事件监听器(event listeners)数
更多的关于在内存分析时,定位内存泄漏的方法,请阅Zack Grossbart的Memory profiling with the Chrome DevTools
证明一个问题的存在
首先要做的事情是找出你认为可能导致内存泄漏的一些动作。可以是发生在页面上的任何事件,鼠标移入,点击,或其它可能会导致页面性能下降的交互。
在时间轴面版上开始记录(Ctrl+E 或 Cmd+E)然后做你想要测试的动作。想要强制进行垃圾回收点面版上的垃圾筒图标([图片上传失败...(image-459677-1569383413904)] )。
下面是一个内存泄漏的例子,有些点没有被垃圾回收:
如果经过一些反复测试后,你看到的是锯齿状的图形(在内存面版的上方),说明你的程序中有很多短时存在的对象。而如果一系列的动作没有让内存保持在一定的范围,并且DOM节点数没有返回到开始时的数目,你就可以怀疑有内存泄漏了。
一旦确定了存在内存上的问题,你就可以使用分析面板(Profiles panel)上的堆分析仪(heap profiler)来定位问题的来源。
例子: 尝试一下memory growth的例子,能帮助你有效的练习通过时间轴分析内存问题。
内存回收
内存回收器(像V8中的)需要能够定位哪些对象是活的(live),而那些被认为是死的(垃圾)的对象是无法引用到的(unreachable)。
如果垃圾回收 (GC)因为JavaScript执行时有逻辑错误而没有能够回收到垃圾对象,这些垃圾对象就无法再被重新回收了。像这样的情况最终会让你的应用越来越慢。
比如你在写代码时,有的变量和事件监听器已经用不到了,但是却仍然被有些代码引用。只要引用还存在,那被引用的对象就无法被GC正确的回收。
当你的应用程序在运行中,有些DOM对象可能已经更新/移除了,要记住检查引用了DOM对象的变量并将其设null。检查可能会引用到其它对象(或其它DOM元素)的对象属性。双眼要盯着可能会越来越增长的变量缓存。
堆分析仪
拍一个快照
在Profiles面板中,选择Take Heap Snapshot,然后点击Start或者按Cmd + E或者Ctrl + E:
快照最初是保存在渲染器进程内存中的。它们被按需导入到了DevTools中,当你点击快照按钮后就可以看到它们了。当快照被载入DevTools中显示后,快照标题下面的数字显示了能够被引用到的(reachable)JavaScript对象占有内存总数。
例子:尝试一下garbage collection in action的例子,在时间轴(Timeline)面板中监控内存的使用。
清除快照
点击Clear all按钮图标([图片上传失败...(image-dbe8df-1569383413904)] ),就能清除掉所有快照:
注意:关闭DevTools窗口并不能从渲染内存中删除掉收集的快照。当重新打开DevTools后,之前的快照列表还在。
记住我们之前提到的,当你生成快照时你可以强制执行在DevTools中GC。当我们拍快照时,GC是自动执行的。在时间轴(Timeline)中点击垃圾桶(垃圾回收)按钮([图片上传失败...(image-2ed01-1569383413904)] )就可以轻松的执行垃圾回收了。
例子:尝试一下scattered objects并用堆分析仪(Heap Profiler)分析它。你可以看到(对象)项目的集合。
切换快照视图
一个快照可以根据不同的任务切换视图。可以通过如图的选择框切换:
下面是三个默认视图:
- Summary(概要) - 通过构造函数名分类显示对象;
- Comparison(对照) - 显示两个快照间对象的差异;
- Containment(控制) - 可用来探测堆内容;
Dominators(支配者)视图可以在Settings面板中开启 – 显示dominators tree. 可以用来找到内存增长点。
通过不同颜色区分对象
对象的属性和属性值有不同的类型并自动的通过颜么进行了区分。每个属性都是以下四种之一:
- a:property - 通过名称索引的普通属性,由.(点)操作符,或引用,如["foo bar"];
- 0:element - 通过数字索引的普通属性,由引用;
- a:context var - 函数内的属性,在函数上下文内,通过名称引用;
- a:system prop - 由JavaScript VM 添加的属性,JavaScript代码不能访问。
命名为System
的对象没有对应的JavaScript类型。它们是JavaScript VM对象系统内置的。V8将大多数内置对象和用户JS对象放在同一个堆中。但它们只是V8的内部对象。
视图详解
Summary view(概要视图)
打开一个快照,默认是以概要视图显示的,显示了对象总数,可以展开显示具体内容: Initially, a snapshot opens in the Summary view, displaying object totals, which can be expanded to show instances:
第一层级是”总体”行,它们显示了:
- Constructor(构造函数)表示所有通过该构造函数生成的对象
- 对象的实例数在Objects Count列上显示
- Shallow size列显示了由对应构造函数生成的对象的shallow sizes(直接占用内存)总数
- Retained size列展示了对应对象所占用的最大内存
- Distance列显示的是对象到达GC根的最短距离
展开一个总体行后,会显示所有的对象实例。没一个实例的直接占用内存和占用总内存都被相应显示。@符号后的数字不对象的唯一ID,有了它你就可以逐个对象的在不同快照间作对比。
例子:尝试这个例子(在新tab标签中打开)来了解如何使用概要视图。
记住黄色的对象被JavaScript引用,而红色的对象是由黄色背景色引用被分离了的节点。
Comparison view(对照视图)
该视图用来对照不同的快照来找到快照之间的差异,来发现有内存泄漏的对象。来证明对应用的某个操作没有造成泄漏(比如:一般一对操作和撤消的动作,像找开一个document,然后关闭,这样是不会造成泄漏的),你可以按以下的步骤尝试:
- 在操作前拍一个堆快照;
- 执行一个操作(做你认为会造成泄漏的动作);
- 撤消之前的操作(上一个操作相反的操作,多重复几次);
- 拍第二个快照,将视图切换成对照视图,并同快照1进行对比。
在对照视图下,两个快照之间的不同就会展现出来了。当展开一个总类目后,增加和删除了的对象就显示出来了:
例子:尝试例子(在新tab标签中打开)来了解如何使用对照视图来定位内存泄漏。
Containment view(控制视图)
控制视图可以称作对你的应用的对象结构的”鸟瞰视图(bird’s eys view)”。它能让你查看function内部,跟你的JavaScript对象一样的观察VM内部对象,能让你在你的应用的非常低层的内存使用情况。
该视图提供了几个进入点:
- DOMWindow 对象 - 这些对象是JavaScript代码的”全局”对象;
- GC根 - VM的垃圾回收器真正的GC根;
- Native对象 - 浏览器对象对”推入”JavaScript虚拟机中来进行自动操作,如:DOM节点,CSS规则(下一节会有详细介绍。)
下图是一个典型的控制视图:
例子:尝试例子(在新tab标签中打开)来了解如何使用控制视图来查看闭包内部和事件处理。
关于闭包的建议
给函数命名对你在快照中的闭包函数间作出区分会很用帮助。如:下面的例子中没有给函数命名: