一文带你了解如何排查内存泄漏导致的页面卡顿现象

不知道在座的各位有没有被问到过这样一个问题:如果页面卡顿,你觉得可能是什么原因造成的?有什么办法锁定原因并解决吗?

这是一个非常宽泛而又有深度的问题,他涉及到很多的页面性能优化问题,我依稀还记得当初面试被问到这个问题时我是这么回答的:

  1. 先会检查是否是网络请求太多,导致数据返回较慢,可以适当做一些缓存
  2. 也有可能是某块资源的bundle太大,可以考虑拆分一下
  3. 然后排查一下js代码,是不是某处有过多循环导致占用主线程时间过长
  4. 浏览器某帧渲染的东西太多,导致的卡顿
  5. 在页面渲染过程中,可能有很多重复的重排重绘
  6. emmmmmm....不知道了

后来了解到了,感官上的长时间运行页面卡顿也有可能是因为内存泄漏引起的

🌟 内存泄漏的定义

那什么是内存泄漏呢?借助别的大佬给出的定义,内存泄漏就是指由于疏忽或者程序的某些错误造成未能释放已经不再使用的内存的情况。简单来讲就是假设某个变量占用100M的内存,而你又用不到这个变量,但是这个变量没有被手动的回收或自动回收,即仍然占用100M的内存空间,这就是一种内存的浪费,即内存泄漏

🌼 JS的数据存储

JavaScript的内存空间分为栈内存堆内存,前者用来存放一些简单变量,后者用来存放复杂对象

  • 简单变量指的是JS的基本数据类型,例如:StringNumberBooleannullundefinedSymbolBigInt
  • 复杂对象指的是JS的引用数据类型,例如:ObjectArrayFunction...

🌴 JS垃圾回收机制

根据内存泄漏的定义,有些变量或数据不再被使用或不需要了,那么它就是垃圾变量或垃圾数据,如果其一直保存在内存中,最终可能会导致内存占用过多的情况。那么此时就需要对这些垃圾数据进行回收,这里引入了垃圾回收机制的概念

垃圾回收的机制分为手动自动两种

例如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()
复制代码

以上代码的调用栈如下图所示:

img

图中左侧为栈空间,用于存放一些执行上下文和基本类型数据;右侧为堆空间,用于存放一些复杂对象数据

当代码执行到fn2()时,栈空间内的执行上下文从上往下依次是 fn2函数执行上下文 => fn1函数执行上下文 => 全局执行上下文

fn2函数内部执行完毕以后,就该退出fn2函数执行上下文了,即箭头向下移动,此时fn2函数执行上下文会被清除并释放栈内存空间,如图所示:

img

fn1函数内部执行完毕以后,就该退出fn1函数执行上下文了,即箭头再向下移动,此时fn1函数执行上下文会被清除并释放相应的栈内存空间,如图所示:

img

此时处于全局的执行上下文中。JavaScript的垃圾回收器会每隔一段时间遍历调用栈,假设此时触发了垃圾回收机制,当遍历调用栈时发现变量b和变量c没有被任何变量所引用,所以认定它们是垃圾数据并给它们打上标记。因为fn1函数执行完后将变量a返回了出去,并存储在全局变量res中,所以认定其为活动数据并打上相应标记。待空闲时刻就会将标记上垃圾数据的变量给全部清除掉,释放相应的内存,如图所示:

img

从这我们得出几点结论:

  1. JavaScript的垃圾回收机制是自动执行的,并且会通过标记来识别并清除垃圾数据
  2. 在离开局部作用域后,若该作用域内的变量没有被外部作用域所引用,则在后续会被清除

补充: JavaScript的垃圾回收机制有着很多的步骤,上述只讲到了标记-清除,其实还有其它的过程,这里简单介绍一下就不展开讨论了。例如:标记-整理,在清空部分垃圾数据后释放了一定的内存空间后会可能会留下大面积的不连续内存片段,导致后续可能无法为某些对象分配连续内存,此时需要整理一下内存空间;交替执行,因为JavaScript是运行在主线程上的,所以执行垃圾回收机制时会暂停js的运行,若垃圾回收执行时间过长,则会给用户带来明显的卡顿现象,所以垃圾回收机制会被分成一个个的小任务,穿插在js任务之中,即交替执行,尽可能得保证不会带来明显的卡顿感

👋 Chrome devTools查看内存情况

在了解一些常见的内存泄漏的场景之前,先简单介绍一下如何使用Chrome的开发者工具来查看js内存情况

首先打开Chrome的无痕模式,这样做的目的是为了屏蔽掉Chrome插件对我们之后测试内存占用情况的影响

img

然后打开开发者工具,找到Performance这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等等

img

简单录制一下百度页面,看看我们能获得什么,如下动图所示:

img

从上图中我们可以看到,在页面从零到加载完成这个过程中JS Heap(js堆内存)documents(文档)Nodes(DOM节点)Listeners(监听器)GPU memory(GPU内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点

再来看看开发者工具中的Memory一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况

img

堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录,如图所示:

img

如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为13.9MB,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为13.4MB。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)

然后我们还可以看一下页面动态的内存变化情况,如图所示:

img

在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中蓝色表示当前时间线下占用着的内存;灰色表示之前占用的内存空间已被清除释放。

从上图过程来看,我们可以看到刚开始处于的tab所对应显示的页面中占用了一定的堆内存空间,成蓝色柱形,在点击别的tab后,原tab对应的内容消失,并且原来蓝色的柱形变成灰色(表示原占用的内存空间得到了释放),同时新tab所对应显示的页面也占用了一定的堆内存空间。因此后续我们就可以针对这个图来查看内存的占用与清除情况

🔥 内存泄漏的场景

那么到底有哪些情况会出现内存泄漏的情况呢?这里列举了常见的几种:

  1. 闭包使用不当引起内存泄漏
  2. 全局变量
  3. 分离的DOM节点
  4. 控制台的打印
  5. 遗忘的定时器

接下来介绍一下各种情况,并尝试用刚才讲到的两种方法来捕捉问题所在

1.闭包使用不当

文章开头的例子中,在退出fn1函数执行上下文后,该上下文中的变量a本应被当作垃圾数据给回收掉,但因fn1函数最终将变量a返回并赋值给全局变量res,其产生了对变量a的引用,所以变量a被标记为活动变量并一直占用着相应的内存,假设变量res后续用不到,这就算是一种闭包使用不当的例子

接下来尝试使用PerformanceMemory来查看一下闭包导致的内存泄漏问题,为了使内存泄漏的结果更加明显,我们稍微改动一下文章开头的例子,代码如下:

<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的曲线图中看出效果,如图所示:

img

在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量res中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的JS Heap曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题

在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题

首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:

img

在我们每次点击按钮后,动态内存分配情况图上都会出现一个蓝色的柱形,并且在我们触发垃圾回收后,蓝色柱形都没变成灰色柱形,即之前分配的内存并未被清除

所以此时我们就可以更明确得确认内存泄漏的问题是存在的了,接下来就精准定位问题,可以利用Heap snapshot来定位问题,如图所示:

img

第一次先点击快照记录初始的内存情况,然后我们多次点击按钮后再次点击快照,记录此时的内存情况,发现从原来的1.1M内存空间变成了1.4M内存空间,然后我们选中第二条快照记录,可以看到右上角有个All objects的字段,其表示展示的是当前选中的快照记录所有对象的分配情况,而我们想要知道的是第二条快照与第一条快照的区别在哪,所以选择Object allocated between Snapshot1 and Snapshot2,即展示第一条快照和第二条快照存在差异的内存对象分配情况,此时可以看到Array的百分比很高,初步可以判断是该变量存在问题,点击查看详情后就能查看到该变量对应的具体数据了

以上就是一个判断闭包带来内存泄漏问题并简单定位的方法了

2.全局变量

全局的变量一般是不会被垃圾回收掉的,在文章开头也提到过了。当然这并不是说变量都不能存在全局,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:

function fn1() {
    // 此处变量name未被声明
    name = new Array(99999999)
}

fn1()
复制代码

此时这种情况就会在全局自动创建一个变量name,并将一个很大的数组赋值给name,又因为是全局变量,所以该内存空间就一直不会被释放

解决办法的话,自己平时要多加注意,不要在变量未声明前赋值,或者也可以开启严格模式,这样就会在不知情犯错时,收到报错警告,例如:

function fn1() {
    'use strict';
    name = new Array(99999999)
}

fn1()
复制代码

3.分离的DOM节点

什么叫DOM节点?假设你手动移除了某个dom节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况:

<div id="root">
    <div class="child">我是子元素</div>
    <button>移除</button>
</div>
<script>

    let btn = document.querySelector('button')
    let child = document.querySelector('.child')
    let root = document.querySelector('#root')
    
    btn.addEventListener('click', function() {
        root.removeChild(child)
    })

</script>
复制代码

该代码所做的操作就是点击按钮后移除.child的节点,虽然点击后,该节点确实从dom被移除了,但全局变量child仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用Memory的快照功能来检测一下,如图所示:

img

同样的先记录一下初始状态的快照,然后点击移除按钮后,再点击一次快照,此时内存大小我们看不出什么变化,因为移除的节点占用的内存实在太小了可以忽略不计,但我们可以点击第二条快照记录,在筛选框里输入detached,于是就会展示所有脱离了却又未被清除的节点对象

解决办法如下图所示:

<div id="root">
    <div class="child">我是子元素</div>
    <button>移除</button>
</div>
<script>
    let btn = document.querySelector('button')

    btn.addEventListener('click', function() {  
        let child = document.querySelector('.child')
        let root = document.querySelector('#root')

        root.removeChild(child)
    })

</script>
复制代码

改动很简单,就是将对.child节点的引用移动到了click事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了,我们来验证一下,如下图所示:

img

结果很明显,这样处理过后就不存在内存泄漏的情况了

4.控制台的打印

控制台的打印也会造成内存泄漏吗????是的呀,如果浏览器不一直保存着我们打印对象的信息,我们为何能在每次打开控制的Console时看到具体的数据呢?先来看一段测试代码:

<button>按钮</button>
<script>
    document.querySelector('button').addEventListener('click', function() {
        let obj = new Array(1000000)

        console.log(obj);
    })
</script>
复制代码

我们在按钮的点击回调事件中创建了一个很大的数组对象并打印,用performance来验证一下:

img

开始录制,先触发一次垃圾回收清除初始的内存,然后点击三次按钮,即执行了三次点击事件,最后再触发一次垃圾回收。查看录制结果发现JS Heap曲线成阶梯上升,并且最终保持的高度比初始基准线高很多,这说明每次执行点击事件创建的很大的数组对象obj都因为console.log被浏览器保存了下来并且无法被回收

接下来注释掉console.log,再来看一下结果:

<button>按钮</button>
<script>
    document.querySelector('button').addEventListener('click', function() {
        let obj = new Array(1000000)

        // console.log(obj);
    })
</script>
复制代码

performance如图所示:

img

可以看到没有打印以后,每次创建的obj都立马被销毁了,并且最终触发垃圾回收机制后跟初始的基准线同样高,说明已经不存在内存泄漏的现象了

其实同理,console.log也可以用Memory来进一步验证

  • 未注释console.log
img
  • 注释掉了console.log
img

最后简单总结一下:在开发环境下,可以使用控制台打印便于调试,但是在生产环境下,尽可能得不要在控制台打印数据。所以我们经常会在代码中看到类似如下的操作:

// 如果在开发环境下,打印变量obj
if(isDev) {
    console.log(obj)
}
复制代码

这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了console.log之外,console.errorconsole.infoconsole.dir等等都不要在生产环境下使用

5.遗忘的定时器

其实定时器也是平时很多人会忽略的一个问题,比如定义了定时器后就再也不去考虑清除定时器了,这样其实也会造成一定的内存泄漏。来看一个代码示例:

<button>开启定时器</button>
<script>

    function fn1() {
        let largeObj = new Array(100000)

        setInterval(() => {
            let myObj = largeObj
        }, 1000)
    }

    document.querySelector('button').addEventListener('click', function() {
        fn1()
    })
</script>
复制代码

这段代码是在点击按钮后执行fn1函数,fn1函数内创建了一个很大的数组对象largeObj,同时创建了一个setInterval定时器,定时器的回调函数只是简单的引用了一下变量largeObj,我们来看看其整体的内存分配情况吧:

img

按道理来说点击按钮执行fn1函数后会退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中performance的录制结果显示似乎是存在内存泄漏问题的,即最终曲线高度比基准线高度要高,那么再用Memory来确认一次:

img

在我们点击按钮后,从动态内存分配的图上看到出现一个蓝色柱形,说明浏览器为变量largeObj分配了一段内存,但是之后这段内存并没有被释放掉,说明的确存在内存泄漏的问题,原因其实就是因为setInterval的回调函数内对变量largeObj有一个引用关系,而定时器一直未被清除,所以变量largeObj的内存也自然不会被释放

那么我们如何来解决这个问题呢,假设我们只需要让定时器执行三次就可以了,那么我们可以改动一下代码:

<button>开启定时器</button>
<script>
    function fn1() {
        let largeObj = new Array(100000)
        let index = 0

        let timer = setInterval(() => {
            if(index === 3) clearInterval(timer);
            let myObj = largeObj
            index ++
        }, 1000)
    }

    document.querySelector('button').addEventListener('click', function() {
        fn1()
    })
</script>
复制代码

现在我们再通过performancememory来看看还不会存在内存泄漏的问题

  • performance
img

这次的录制结果就能看出,最后的曲线高度和初始基准线的高度一样,说明并没有内存泄漏的情况

  • memory
img

这里做一个解释,图中刚开始出现的蓝色柱形是因为我在录制后刷新了页面,可以忽略;然后我们点击了按钮,看到又出现了一个蓝色柱形,此时就是为fn1函数中的变量largeObj分配了内存,3s后该内存又被释放了,即变成了灰色柱形。所以我们可以得出结论,这段代码不存在内存泄漏的问题

简单总结一下: 大家在平时用到了定时器,如果在用不到定时器后一定要清除掉,否则就会出现本例中的情况。除了setTimeoutsetInterval,其实浏览器还提供了一个API也可能就存在这样的问题,那就是requestAnimationFrame

👍 总结

在项目过程中,如果遇到了某些性能问题可能跟内存泄漏有关时,就可以参照本文列举的5种情况去排查,一定能找到问题所在并给到解决办法的。

虽然JavaScript的垃圾回收是自动的,但我们有时也是需要考虑要不要手动清除某些变量的内存占用的,例如你明确某个变量在一定条件下再也不需要,但是还会被外部变量引用导致内存无法得到释放时,你可以用null对该变量重新赋值就可以在后续垃圾回收阶段释放该变量的内存了。

😊 我也是因为在业务中一次因内存泄漏而引起页面卡顿,所以才想起写这样一篇文章。当然并不是说页面卡顿都是因为内存泄漏引起的,可能还有其它原因

大家对于内存泄漏排查的方法有什么建议或者问题的话可以在评论区讨论鸭~ ✌️

原创不易,记得点赞 👍 支持一下哦~ 😘