深入浅出 js内存泄漏

·  阅读 822
深入浅出 js内存泄漏

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第19天,点击查看活动详情

以前我们说的内存泄漏,通常发生在服务端,但是不代表前端就不会有内存泄漏。特别是当前端项目变得越来越复杂后,前端也逐渐称为内存泄漏的高发区。本文就带你认识一下Javascript的内存泄漏。

什么是内存泄漏

什么是内存?内存其实就是程序在运行时,系统为其分配的一块存储空间。每一块内存都有对应的生命周期:

  • 内存分配:在声明变量、函数时,系统分配的内存空间
  • 内存使用:对分配到的内存进行读/写操作,即访问并使用变量、函数等
  • 释放内存:内存使用完毕后,释放掉不再被使用的内存

不像C语言等底层语言需要程序员在开发的时候自己通过mallocfree来申请或者释放内存,JavaScript同大多数现代编程语言一样,都实现了给变量自动分配内存,并且在不使用变量的时候“自动”释放内存,这个释放内存的过程就被称为垃圾回收。

每一个程序的运行都需要一块内存空间,如果某一块内存空间在使用后未被释放,并且持续累积,导致未释放的内存空间越积越多,直至用尽全部的内存空间。程序将无法正常运行,直观体现就是程序卡死,系统崩溃,这一现象就被称为内存泄漏。

我们来举几个例子说明一下:

内存分配

const obj = {
    name: '张三'
} // 给{name: '张三'}分配堆内存
function foo() {
    console.log('hello world')
} // 给函数分配内存
let date = new Date(); // 根据函数返回的结果创建变量,会分配一个Date对象

可能发生内存泄漏

function foo() {
    const obj = {name: '张三'}
    window.obj = obj;
    console.log(obj)
}
foo(); // foo()执行完毕,{name: '张三'}对应的内存空间本应该被释放,但是由于又被全局变量所引用,因此其对应的内存空间不会被垃圾回收

闭包的内存占用

function bar() {
    const data = {}
    return {
        get(key) {
            return data[key]
        },
        set(key, value) {
            data[key] = value
        }
    }; // 闭包对象
}
const {get, set} = bar; // 结构
set('name', '张三')
get('name'); // 张三
// 函数执行完毕,data对象并不会被垃圾回收,这是闭包的机制。如果被回收了,也就没有必要有闭包了

内存泄漏听起来可能会有点抽象,怎么能比较直观的看到内存泄漏的过程呢?

怎么检测内存泄漏

内存泄漏主要是指的是内存持续升高,但是如果是正常的内存增长的话,不应该被当作内存泄漏来排查。排查内存泄漏,我们可以借助Chrome DevToolsPerformanceMemory选项。举个栗子:

我们新建一个memory.html的文件,完整代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      text-align: center;
    }
  </style>
</head>
<body>
  <p>检测内存变化</p>
  <button id="btn">开始</button>
  <script>
    const arr = [];
    // 数组中添加100万个数据
    for (let i = 0; i < 100 * 10000; i++) {
      arr.push(i)
    }
    function bind() {
      const obj = {
        str: JSON.stringify(arr) // 浅拷贝的方式创建一个比较大的字符串
      }
      // 每次调用bind函数,都在全局绑定一个onclick监听事件,不一定非要执行
      // 使用绑定事件,主要是为了保持obj被全局标记
      window.addEventListener('click', () => {
        // 引用对象obj
        console.log(obj);
      })
    }
    let n = 0;
    function start() {
      setTimeout(() => {
        bind(); // 调用bind函数
        n++; // 循环次数增加
        if (n < 50) {
          start(); // 循环执行50次,注意这里并没有使用setInterval定时器
        } else {
          alert('done');
        }
      }, 200);
    }
    document.getElementById('btn').addEventListener('click', () => {
      start();
    })
  </script>
</body>
</html>

页面上有一个按钮用来开始函数调用,方便我们控制。点击按钮,每个200毫秒执行一次bind函数,即在全局监听click事件,循环次数为50次。

在无法确定是否发生内存泄漏时,我们可以先使用Performance来录制一段页面加载的性能变化,先判断是否有内存泄漏发生。

Performance

本次案例仅以Chrome浏览器展开描述,其他浏览器可能会有些许差异。首先我们鼠标右键选择检查或者直接F12进入DevTools页面,面板上选择Performance,选择后应该是如下页面:

image-20220616121026225.png

在开始之前,我们先点击一下Collect garbageclear来保证内存干净,没有其他遗留内存的干扰。然后我们点击Record来开始录制,并且同时我们也要点击页面上的开始按钮,让我们的代码跑起来。等到代码结束后,我们再点击Record按钮以停止录制,录制的时间跟代码执行的时间相比会有出入,只要保证代码是完全执行完毕的即可。停止录制后,我们会得到如下的结果:

image-20220616122203431.png

Performance的内容很多,我们只需要关注内存的变化,由此图可见,内存这块区域的曲线是在一直升高的并且到达顶点后并没有回落,这就有可能发生了内存泄漏。因为正常的内存变化曲线应该是类似于“锯齿”,也就是有上有下,正常增长后会有一定的回落,但不一定回落到和初始值一样。而且我们还可以隐约看到程序运行结束后,内存从初始的6.2MB增加到了差不多351MB,这个数量级的增加还是挺明显的。我们只是执行了50次循环,如果执行的次数更多,将会耗尽浏览器的内存空间,导致页面卡死。

虽然是有内存泄漏,但是如果我们想进一步看内存泄漏发生的地方,那么Performance就不够用了,这个时候我们就需要使用Memory面板。

Memory

DevTools的Memory选项主要是用来录制堆内存的快照,为的是进一步分析内存泄漏的详细信息。有人可能会说,为啥不一开始就直接使用Memory呢,反而是先使用Performance。因为我们刚开始就说了,内存增长不表示就一定出现了内存泄漏,有可能是正常的增长,直接使用Memory来分析可能得不到正确的结果。

我们先来看一下怎么使用Memory

image-20220616124723450.png

首先选择Memory选项,然后清除缓存,在配置选项中选择堆内存快照。内存快照每次点击录制按钮都会记录当前的内存使用情况,我们可以在程序开始前点击一下记录初始的内存使用,代码结束后再点一下记录最终的内存使用,中间可以点击也可以不点击。最后在快照列表中至少可以得到两个内存记录:

image-20220616132009694.png

初始内存我们暂时不深究,我们选择列表的最后一条记录,然后在筛选下拉框选择最后一个,即第一个快照和第二个快照的差异。

image-20220616134059058.png

这里我们重点说一下Shallow SizeRetained Size的区别:

  • Shallow Size:对象自身占用的内存大小,一般来说字符串、数组的Shallow Size都会比较大
  • Retained Size:这个是对象自身占用的内存加上无法被GC释放的内存的大小,如果Retained Size和Shallow Size相差不大,基本上可以判定没有发生内存泄漏,但是如果相差很大,例如上图的Object,这就表明发生了内存泄漏。

我们再来细看一下Object,任意展开一个对象,可以在树结构中发现每一个对象都有一个全局事件绑定,并且占用了较大的内存空间。解决本案例涉及的内存泄漏也比较简单,就是及时释放绑定的全局事件。

image-20220616132758063.png

关于PerformanceMemory的详细使用可以参考:手把手教你排查Javascript内存泄漏

内存泄漏的场景

大多数情况下,垃圾回收器会帮我们及时释放内存,一般不会发生内存泄漏。但是有些场景是内存泄漏的高发区,我们在使用的时候一定要注意:

  • 我们在开发的时候经常会使用console在控制台打印信息,但这也会带来一个问题:被console使用的对象是不能被垃圾回收的,这就可能会导致内存泄漏。因此在生产环境中不建议使用console.log()的理由就又可以加上一条避免内存泄漏了。

  • 被全局变量、全局函数引用的对象,在Vue组件销毁时未清除,可能会导致内存泄漏

    // Vue3
    <script setup>
    import {onMounted, onBeforeUnmount, reactive} from 'vue'
    const arr = reactive([1,2,3]);
    onMounted(() => {
        window.arr = arr; // 被全局变量引用
        window.arrFunc = () => {
            console.log(arr); // 被全局函数引用
        }
    })
    // 正确的方式
    onBeforeUnmount(() => {
        window.arr = null;
        window.arrFunc = null;
    })
    </script>
    
  • 定时器未及时在Vue组件销毁时清除,可能会导致内存泄漏

    // Vue3
    <script setup>
    import {onMounted, onBeforeUnmount, reactive} from 'vue'
    const arr = reactive([1,2,3]);
    const timer = reactive(null);
    onMounted(() => {
        setInterval(() => {
            console.log(arr); // arr被定时器占用,无法被垃圾回收
        }, 200);
        // 正确的方式
        timer = setInterval(() => {
            console.log(arr);
        }, 200);
    })
    // 正确的方式
    onBeforeUnmount(() => {
        if (timer) {
            clearInterval(timer);
            timer = null;
        }
    })
    </script>
    

    setTimeoutsetInterval两个定时器在使用时都应该注意是否需要清理定时器,特别是setInterval,一定要注意清除。

  • 绑定的事件未及时在Vue组件销毁时清除,可能会导致内存泄漏

    绑定事件在实际开发中经常遇到,我们一般使用addEventListener来创建。

    // Vue3
    <script setup>
    import {onMounted, onBeforeUnmount, reactive} from 'vue'
    const arr = reactive([1,2,3]);
    const printArr = () => {
        console.log(arr)
    }
    onMounted(() => {
        // 监听事件绑定的函数为匿名函数,将无法被清除
        window.addEventListener('click', () => {
            console.log(arr); // 全局绑定的click事件,arr被引用,将无法被垃圾回收
        })
        // 正确的方式
        window.addEventListener('click', printArr);
    })
    // 正确的方式
    onBeforeUnmount(() => {
        // 注意清除绑定事件需要前后是同一个函数,如果函数不同将不会清除
        window.removeEventListener('click', printArr);
    })
    </script>
    
  • 被自定义事件引用,在Vue组件销毁时未清除,可能会导致内存泄漏

    自定义事件通过emit/on来发起和监听,清除自定义事件和绑定事件差不多,不同的是需要调用off方法

    // Vue3
    <script setup>
    import {onMounted, onBeforeUnmount, reactive} from 'vue'
    import event from './event.js'; // 自定义事件
    const arr = reactive([1,2,3]);
    const printArr = () => {
        console.log(arr)
    }
    onMounted(() => {
        // 使用匿名函数,会导致自定义事件无法被清除
        event.on('printArr', () => {
            console.log(arr)
        })
        // 正确的方式
        event.on('printArr', printArr)
    })
    // 正确的方式
    onBeforeUnmount(() => {
        // 注意清除自定义事件需要前后是同一个函数,如果函数不同将不会清除
        event.off('printArr', printArr)
    })
    </script>
    

除了及时清除监听器、事件等,对于全局变量的引用,我们可以选择WeakMapWeakSet等弱引用数据类型。这样的话,即使我们引用的对象数据要被垃圾回收,弱引用的全局变量并不会阻止GC。

垃圾回收算法

我们知道了内存泄漏的含义,也知道了怎么来检测内存泄漏,甚至可以一定程度上规避内存泄漏了。除了那些容易产生内存泄漏的场景,js是使用什么样的机制来保证垃圾会被尽可能的回收呢?垃圾回收算法早期使用的是引用计数,现在主流都采用标记清除的方式了。

引用计数

我们创建一个对象,js会在堆内存中分配一块区域用于存储对象信息,并且在栈中存在对象数据的引用地址。举个栗子:

let obj = {name: '张三'};
let obj2 = obj;
// 对象数据{name: '张三'}被obj和obj2引用,引用计数为2,此时{name: '张三'}不能被垃圾回收
obj = 0; // obj虽然不引用{name: '张三'},但是obj2还在引用,此时{name: '张三'}也不能被垃圾回收
obj2 = 0; // 此时的{name: '张三'}已经是零引用了,可以被垃圾回收

下图为创建变量时的内存管理:

js内存管理-声明变量.png

对于函数来说,正常情况下函数执行完毕,其占用的内存就会被垃圾回收:

function foo() {
    const obj = {name: '张三'}
    console.log(obj)
}
foo(); // 函数执行完毕,obj作为局部变量,会随着函数的结束而结束,{name: '张三'}由于零引用,其占用的内存空间会被释放

但是如果函数中存在全局引用,那么函数结束后,全局引用占用的内存将无法被释放

function foo() {
    const obj = {name: '张三'}
    window.obj = obj;
    console.log(obj)
}
foo(); // 函数执行完毕,{name: '张三'}被全局引用

引用计数有一个缺陷,那就是无法处理循环引用。

循环引用

循环引用指的是两个或者多个对象之间,存在相互引用,并形成了一个循环。如果是在函数中,函数运行结束后应该释放掉所有局部变量引用的对象,但是按照引用计数算法,循环引用间并不是零引用,因此它们就不会被释放。举个例子:

function foo() {
    const obj = {name: '张三'};
    const obj2 = {age: 0};
    obj.a = obj2; // obj对象引用了obj2
    obj2.a = obj; // obj2引用了obj
}
foo(); // 由于存在循环引用,{name: '张三'}和{age: 0}所占用的堆内存都不会被释放

上面的例子可能比较抽象,我们再来举一个实际的例子。在IE6、7版本中(IE已于2022年6月15日正式退出了历史舞台),在对DOM对象进行垃圾回收时,就有可能因为循环引用导致内存泄漏。

let div = document.getElementById('div1');
div.a = div; // 循环引用自己
div.someBigData = new Array(10000).fill('*');

在上述例子中,div对象的a属性引用了div对象自身,造成了最简单的循环引用,并且该属性并没有被移除或者显示设置为null,这对于引用计数器来说就是一个有意义的引用。因此,div对象会一直保持在内存中,即使在DOM树中将div1删除,而且div对象的someBigData所引用的数据也会一直保持在内存中,不会被释放。如果div对象本身很大,或者其属性引用的数据很大,那么持续累积就可能造成内存泄漏 。

标记清除

标记清除算法是对引用计数算法的改进,如果说引用计数算法是判断对象是否不再需要,那么标记清楚算法就是判断对象是否可以获得。可以获得的对象就保留在内存中,不可获得的对象就会被垃圾回收。

垃圾回收并不是实时的,使用标记清除算法的垃圾回收器,会定期从根对象开始,在js中就是从window对象开始,找出所有从根开始引用的对象,以及找到这些对象引用的对象,直到全部遍历。垃圾回收器就可以收集到所有可获得的对象以及所有不可获得的对象,然后将不可获得的对象回收。

使用标记清除算法可以有效解决引用计数算法的循环引用问题,还是刚刚那个例子:

function foo() {
    const obj = {name: '张三'};
    const obj2 = {age: 0};
    obj.a = obj2; // obj对象引用了obj2
    obj2.a = obj; // obj2引用了obj
}
foo();

foo()函数执行完毕后,垃圾回收器从window对象开始找,发现obj和obj2是不可获得的对象,那么其引用的数据就会被回收。我们简单修改一下代码:

function foo() {
    const obj = {name: '张三'};
    const obj2 = {age: 0};
    obj.a = obj2; // obj对象引用了obj2
    obj2.a = obj; // obj2引用了obj
    window.obj = obj;
}
foo();
console.log(window.obj)

foo()函数执行完毕,垃圾回收期从window对象开始找,发现可以在window对象上找到obj属性,obj和obj2存在循环引用,最终obj和obj2引用的数据都不会被垃圾回收。

image-20220617134223143.png

这个例子也告诉我们,使用全局变量时一定要注意是否可能造成内存泄漏,详细可查看内存泄漏的场景。使用标记清楚算法就基本上能满足垃圾回收的需求了,而且从2012年开始,现代主流浏览器的垃圾回收器都实现了标记清除算法,后续的改进也是基于该算法来实现的。

闭包是内存泄漏吗

使用过闭包的都知道,其数据是保持在内存中的,不会被回收,那么闭包是内存泄漏吗?答案:闭包不是内存泄漏。因为我们说的内存泄漏是不符合预期的内存持续增长,闭包虽然也会占着内存不释放,但是这个是符合我们预期的效果。

闭包不是内存泄漏,难道就可以随便使用了吗。那肯定不是的,闭包中的数据如果很大,也会消耗大量的内存,造成网页卡顿,甚至崩溃。因此我们不能滥用闭包,在闭包函数退出前,一些不必要的局部变量该清除的还是要清除。

总结

本文详细的解释了什么是内存泄漏,泄漏了该怎么检测,浏览器是怎么来进行垃圾回收的,以及一些常见的内存泄漏场景。总的来说,内存泄漏就是指发生非预期的内存占用持续增长,以至于耗尽内存,导致系统崩溃。内存泄漏在实际开发过程中还是比较容易犯的,在写代码的时候一定要留意高发场景,尽可能在写代码的时候就避免潜在的内存泄漏,而不是等到系统崩溃了才来一句:重启(刷新)大法好。

原创不易,转载请注明出处

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改