导航
转载自:https://xie.infoq.cn/article/aa6b0d97f38a1f8b98a61b024
不知道在座的各位有没有被问到过这样一个问题:如果页面卡顿,你觉得可能是什么原因造成的?有什么办法锁定原因并解决吗?
这是一个非常宽泛而又有深度的问题,他涉及到很多的页面性能优化问题,我依稀还记得当初面试被问到这个问题时我是这么回答的:
后来了解到了,感官上的长时间运行页面卡顿也有可能是因为内存泄漏引起的
那什么是内存泄漏呢?借助别的大佬给出的定义,内存泄漏就是指由于疏忽或者程序的某些错误造成未能释放已经不再使用的内存的情况。简单来讲就是假设某个变量占用 100M 的内存,而你又用不到这个变量,但是这个变量没有被手动的回收或自动回收,即仍然占用 100M 的内存空间,这就是一种内存的浪费,即内存泄漏。
JavaScript 的内存空间分为栈内存和堆内存,前者用来存放一些简单变量,后者用来存放复杂对象
根据内存泄漏的定义,有些变量或数据不再被使用或不需要了,那么它就是垃圾变量或垃圾数据,如果其一直保存在内存中,最终可能会导致内存占用过多的情况。那么此时就需要对这些垃圾数据进行回收,这里引入了垃圾回收机制的概念
垃圾回收的机制分为手动和自动两种
例如C/C++采用的就是手动回收的机制,即先用代码为某个变量分配一定的内存,然后在不需要了后,再用代码手动释放掉内存
而JavaScript采用的则是自动回收的机制,即我们不需要关心何时为变量分配多大的内存,也不需要关心何时去释放内存,因为这一切都是自动的。但这不表示我们不需要关心内存的管理!!!!否则也不会有本文讨论的内存泄露了
接下来就讲一下JavaScript的垃圾回收机制
通常全局状态(window)下的变量是不会被自动回收的,所以我们来讨论一下局部作用域下的内存回收情况
function fn1 () {
let a = {
name: '零一'
}
let b = 3
function fn2() {
let c = [1, 2, 3]
}
fn2()
return a
}
let res = fn1()
以上代码的调用栈如下图所示:
图中左侧为栈空间,用于存放一些执行上下文和基本类型数据;右侧为堆空间,用于存放一些复杂对象数据
当代码执行到fn2()时,栈空间内的执行上下文从上往下依次是 fn2函数执行上下文 => fn1函数执行上下文 => 全局执行上下文
待fn2函数内部执行完毕以后,就该退出fn2函数执行上下文了,即箭头向下移动,此时fn2函数执行上下文会被清除并释放栈内存空间,如图所示:
待fn1函数内部执行完毕以后,就该退出fn1函数执行上下文了,即箭头再向下移动,此时fn1函数执行上下文会被清除并释放相应的栈内存空间,如图所示:
此时处于全局的执行上下文中。JavaScript的垃圾回收器会每隔一段时间遍历调用栈,假设此时触发了垃圾回收机制,当遍历调用栈时发现变量b和变量c没有被任何变量所引用,所以认定它们是垃圾数据并给它们打上标记。因为fn1函数执行完后将变量a返回了出去,并存储在全局变量res中,所以认定其为活动数据并打上相应标记。待空闲时刻就会将标记上垃圾数据的变量给全部清除掉,释放相应的内存,如图所示:
从这我们得出几点结论:
**补充:**JavaScript的垃圾回收机制有着很多的步骤,上述只讲到了标记-清除,其实还有其它的过程,这里简单介绍一下就不展开讨论了。例如:标记-整理,在清空部分垃圾数据后释放了一定的内存空间后会可能会留下大面积的不连续内存片段,导致后续可能无法为某些对象分配连续内存,此时需要整理一下内存空间;交替执行,因为JavaScript是运行在主线程上的,所以执行垃圾回收机制时会暂停js的运行,若垃圾回收执行时间过长,则会给用户带来明显的卡顿现象,所以垃圾回收机制会被分成一个个的小任务,穿插在js任务之中,即交替执行,尽可能得保证不会带来明显的卡顿感
在了解一些常见的内存泄漏的场景之前,先简单介绍一下如何使用Chrome的开发者工具来查看 js 内存情况
首先打开 Chrome 的无痕模式,这样做的目的是为了屏蔽掉 Chrome 插件对我们之后测试内存占用情况的影响
然后打开开发者工具,找到 Performance 这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化 js 内存、节点、事件监听器按钮;触发垃圾回收机制按钮等等
简单录制一下百度页面,看看我们能获得什么,如下动图所示:
从上图中我们可以看到,在页面从零到加载完成这个过程中JS Heap(js堆内存)、documents(文档)、Nodes(DOM节点)、Listeners(监听器)、GPU memory(GPU内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点
再来看看开发者工具中的Memory一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况
堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录,如图所示:
如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为13.9MB,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为13.4MB。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)
然后我们还可以看一下页面动态的内存变化情况,如图所示:
在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中蓝色表示当前时间线下占用着的内存;灰色表示之前占用的内存空间已被清除释放。
从上图过程来看,我们可以看到刚开始处于的tab所对应显示的页面中占用了一定的堆内存空间,成蓝色柱形,在点击别的tab后,原tab对应的内容消失,并且原来蓝色的柱形变成灰色(表示原占用的内存空间得到了释放),同时新tab所对应显示的页面也占用了一定的堆内存空间。因此后续我们就可以针对这个图来查看内存的占用与清除情况
那么到底有哪些情况会出现内存泄漏的情况呢?这里列举了常见的几种:
接下来介绍一下各种情况,并尝试用刚才讲到的两种方法来捕捉问题所在
文章开头的例子中,在退出 fn1 函数执行上下文后,该上下文中的变量 a 本应被当作垃圾数据给回收掉,但因 fn1函数最终将变量 a 返回并赋值给全局变量 res ,其产生了对变量 a 的引用,所以变量 a 被标记为活动变量并一直占用着相应的内存,假设变量 res 后续用不到,这就算是一种闭包使用不当的例子
接下来尝试使用 Performance 和 Memory 来查看一下闭包导致的内存泄漏问题,为了使内存泄漏的结果更加明显,我们稍微改动一下文章开头的例子,代码如下:
<button onclick="myClick()">执行fn1函数</button>
<script>
function fn1 () {
let a = new Array(10000) // 这里设置了一个很大的数组对象
let b = 3
function fn2() {
let c = [1, 2, 3]
}
fn2()
return a
}
let res = []
function myClick() {
res.push(fn1())
}
</script>
设置了一个按钮,每次执行就会将fn1函数的返回值添加到全局数组变量 res 中,是为了能在 performacne 的曲线图中看出效果,如图所示:
在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量res中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的 JS Heap 曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题