likes
comments
collection
share

JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

作者站长头像
站长
· 阅读数 49

一、概述

我们知道JavaScript的自动垃圾回收,不用程序员手动回收内存,为了更加了解内存的回收机制,本文浅浅探讨 JavaScript 和 Google V8引擎的垃圾回收机制以及那些操作会造成内存泄漏

  • 了解JavaScript和V8引擎垃圾回收机制
  • 了解常见的内存泄漏操作以及排查方法

该文章会分为以下三个方面来讲解:

  1. 垃圾回收机制
  2. V8引擎对垃圾回收机制的优化
  3. 常见的内存泄漏

这是文章的 第三部分 《常见的内存泄漏》

二、JavaScript的内存泄漏

2.1 什么是内存泄漏

前面讲到 JavaScript 的垃圾回收机制,它主要针对一些程序中不再使用的对象,对其清理回收释放内存。

那么垃圾回收机制能把不再使用的对象(垃圾)回收掉吗?

虽然引擎针对垃圾回收做了各种优化从而尽可能确保垃圾得以回收,在我们编写代码的过程中,有些操作不利于引擎的垃圾回收,那当不再用到的对象,没有及时被回收时,这种场景称之为内存泄漏(Memory leak)

2.2 常见的内存泄漏

2.2.1 不正当的闭包

闭包是指有权访问另一个函数作用域中变量的函数

看个例子:

function func1() {
  let x = new Array(1000).fill("winter");
  return function () {
    console.log("winter");
  };
}

let func1Child = func1();
func1Child();

上面的例子是闭包吗?它造成内存泄漏了吗?

显然它是一个典型闭包,但是它并没有造成内存泄漏,因为返回的函数中并没有对func1函数内部的引用,也就是说,函数func1内部的x变量完全是可以被回收的,再来看:

function func2() {
  let x = new Array(1000).fill("winter");
  return function () {
    console.log(x);
    return x;
  };
}

let func2Child = func2();
func2Child();

上面的例子是闭包吗?它造成内存泄漏了吗?

显然它也是闭包,并且因为 return 的函数中存在函数 func2 中的 x 变量引用,所以 x 并不会被回收,也就造成了内存泄漏。

怎么解决呢?

其实在函数调用后,把外部的引用关系置空就好了,如下:

function func2() {
  let x = new Array(1000).fill("winter");
  return function () {
    console.log(x);
    return x;
  };
}

let func2Child = func2();
func2Child();
// 将引用关系质空
func2Child = null;

2.2.2 隐式全局变量

JavaScript 的垃圾回收是自动执行的,垃圾回收器每隔一段时间就会找出那些不再使用的数据,并释放其所占用的内存空间。

再来看全局变量和局部变量,函数中的局部变量在函数执行结束后这些变量已经不再被需要,所以垃圾回收器会识别并释放它们。但是对于全局变量,垃圾回收器很难判断这些变量什么时候才不被需要,所以全局变量通常不会被回收,我们使用全局变量是 OK 的,但同时我们要避免一些额外的全局变量产生,如下:

function fn() {
  // test1
  test1 = new Array(1000).fill("winter");

  // window
  this.test2 = new Array(1000).fill("winter");
}

fn();

调用函数 fn ,因为没有声明函数中this的问题造成了两个额外的隐式全局变量,这两个变量不会被回收,这种情况我们要尽可能的避免,在开发中我们可以使用严格模式或者通过 lint 检查来避免这些情况的发生,从而降低内存成本。

除此之外,我们在程序中也会不可避免的使用全局变量,这些全局变量除非被取消或者重新分配之外也是无法回收的,这也就需要我们额外的关注,也就是说当我们在使用全局变量存储数据时,要确保使用后将其置空或者重新分配,当然也很简单,在使用完将其置为null即可,特别是在使用全局变量做持续存储大量数据的缓存时,我们一定要记得设置存储上限并及时清理,不然的话数据量越来越大,内存压力也会随之增高。

// 定义了一个全局变量
var test = new Array(1000);

// do someting

// 清除
test = null;

2.2.3 游离的DOM引用

考虑到性能或代码简洁方面,我们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放:

<div id="root">
    <ul id="ul">
        <li>1</li>
        <li>2</li>
        <li id="li3">3</li>
        <li>4</li>
    </ul>
</div>
<script>
    let root = document.getElementById('root')
    let ul = document.getElementById('ul')
    let li3 = document.getElementById('li3')

    // 移除节点
    root.removeChild(ul)
</script>

如上所示,当我们使用变量缓存 DOM 节点引用后删除了节点,如果不将缓存引用的变量置空,依然进行不了 GC,也就会出现内存泄漏。

假如我们将父节点置空,但是被删除的父节点其子节点引用也缓存在变量里,那么就会导致整个父 DOM 节点树下整个游离节点树均无法清理,还是会出现内存泄漏,解决办法就是将引用子节点的变量也置空,代码如下:

<script>
    let root = document.getElementById('root')
    let ul = document.getElementById('ul')
    let li3 = document.getElementById('li3')

    // 移除节点
    root.removeChild(ul)

    // 将ul和li3的引用也置空
    ul = null
    li3 = null
</script>

2.2.4 定时器

开发时我们经常会用到计时器,也就是setTimeoutsetInterval,先来看一个例子:

// 假设获取数据
let data = getDate();

// setInterval
setInterval(() => {
  const node = document.getElementById("node");

  // 添加数据
  node.innerHTML = JSON.stringify(data);
}, 1000);

代码中每隔一秒就将得到的数据放入到 Node 节点中去,但是在setInterval没有结束前,回调函数里的变量以及回调函数本身都无法被回收。

什么才叫结束呢?

也就是调用了clearInterval。如果没有被clear掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。所以在上例中,someResource就没法被回收。

同样, setTiemout 也会有同样的问题,所以,当不需要interval或者timeout时,最好调用 clearInterval 或者clearTimeout 来清除,另外,浏览器中的requestAnimationFrame也存在这个问题,我们需要在不需要的时候用cancelAnimationFrame API 来取消使用。

2.2.5 事件监听器

当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。

例如在 Vue 中:

<template>
    <div>demo</div>
</template>

<script>
    export default {
        create() {
            // 监听事件
            window.addEventListener('resize', this.doSometing)
        },
        beforeDestroy() {
            // 移除事件
            window.removeEventListener('resize', this.doSometing)
        }

        methods: {
            doSometing() {
                // do someting
            }
        }
    }
</script>

2.2.6 Map、Set对象

当使用MapSet存储对象时,同Object都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收。

如果使用 Map ,对于键为对象的情况,可以采用 WeakMap,WeakMap 对象同样用来保存键值对,对于键是弱引用注:WeakMap只对于键是弱引用),且必须为一个对象,而值可以是任意的对象或者原始值,由于是对于对象的弱引用,不会干扰 Js 的垃圾回收。

如果需要使用Set引用对象,可以采用 WeakSet,WeakSet 对象允许存储对象弱引用的唯一值,WeakSet对象中的值同样不会重复,且只能保存对象的弱引用,同样由于是对于对象的弱引用,不会干扰 Js 的垃圾回收。

这里可能需要简单介绍下,谈弱引用,我们先来说强引用,之前我们说 JS 的垃圾回收机制是如果我们持有对一个对象的引用,那么这个对象就不会被垃圾回收,这里的引用,指的就是强引用,而弱引用就是一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,因此可能在任何时刻被回收。

看个例子:

let obj = {
  id: 1,
};

let user = { info: obj };

let set = new Set([obj]);
let map = new Map([[obj, "winter"]]);

// 置空
obj = null;

// 引用还在
console.log(user.info); // {id: 1}
console.log(set);
console.log(map);

这里重写了 obj 以后, {id: 1} 依然会存在于内存中,因为 user 对象以及后面的 set/map 都强引用了它,Set/Map对象、数组对象等都是强引用,所以我们仍然可以获取到 {id: 1} ,我们想要清除那就只能重写所有引用将其置空了。

接下来我们来看 WeakMapWeakSet

let obj = {
  id: 1,
};

let set = new WeakSet([obj]);
let map = new WeakMap([[obj, "winter"]]);

// 置空
obj = null;

// 没有报错,但obj的引用不在了
console.log(set);
console.log(map);

使用了 WeakMap 以及 WeakSet 即为弱引用,将 obj 引用置为 null 后,对象 {id: 1} 将在下一次 GC 中被清理出内存。

2.2.7 console

在一些小团队中可能项目上线也不清理这些console,殊不知这些 console 也是隐患,同时也是容易被忽略的,我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console 如果输出了对象也会造成内存泄漏。

所以,开发环境下我们可以使用控制台输出来便于我们调试,但是在生产环境下,一定要及时清理掉输出。

2.3 内存泄漏排查、定位和修复

先来看个例子:

<!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>内存泄漏</title>
</head>
<body>
    
    <div id="content">0</div>
    <button id="click"> click me!</button>

    <script>
        const click = document.getElementById('click')
        const content = document.getElementById('content')
        const arr = []

        // 使用闭包
        function add() {
            let test = new Array(10000).fill('winter')
            return function() {
                return test
            }
        }

        // 点击
        click.addEventListener('click', function() {

            // 执行 10次
            arr.push(add())
            arr.push(add())
            arr.push(add())
            arr.push(add())
            arr.push(add())
            arr.push(add())
            arr.push(add())
            arr.push(add())
            arr.push(add())
            arr.push(add())

            content.innerHTML = arr.length
        })
    </script>
</body>
</html>

这是一个由不正当使用闭包构成的内存泄漏例子。

有一个 closures 函数,这是一个闭包函数,我们为页面中的 button 元素绑定了一个点击事件,每次点击都将执行 10 次闭包函数并将其执行结果 push 到全局数组 arr 中,由于闭包函数执行结果也是一个函数并且存在对原闭包函数内部数组test 的引用,所以 arr 数组中每一项元素都使得其引用的闭包内部 test 数组对象无法回收,arr 数组有多少元素,也就代表着我们存在多少次闭包引用,所以此程序点击次数越多,push 的越多,内存消耗越大,页面也会越来越卡。

2.3.1 排查问题

我们以 Chrome 的 devTool 工具来进行问题排查

找到 Performance 这一面板,它可以记录并分析在网站的生命周期内所发生的各类事件,我们就可以通过它监控我们程序中的各种性能情况并分析,其中就包括内存,如下图:

JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

第一步: 先清理一下GC,点击开始录制进入录制状态,接着快速点击按钮10次,这时候页面上的数值应该是 100

第二步: 手动点击一下小垃圾桶,触发GC

第三步: 再快速点击页面上的click 按钮 10次,这时候页面上的数值应该是200,然后停止录制

这时候我们发现尽管我们在点击10次之后执行GC,但JS Heap 并没有清空,这就是内存泄漏

2.3.2 查看问题和分析定位

Chrome Devtool 还为我们提供了 Memory 面板,它可以为我们提供更多详细信息,比如记录 JS CPU 执行时间细节、显示 JS 对象和相关的DOM节点的内存消耗、记录内存的分配细节等。

其中的Heap Profiling可以记录当前的堆内存 heap 的快照,并生成对象的描述文件,该描述文件给出了当下 JS 运行所用的所有对象,以及这些对象所占用的内存大小、引用的层级关系等等,用它就可以定位出引起问题的具体原因以及位置。 JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

重新刷新一下页面,清除GC(点击下垃圾桶),生成第一次快照,如下图所示:

JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

左侧列表中的 Snapshot 1 代表了我们生成的快照1,也就是刚刚那一刻的内存状态。

选中 Snapshot 1 后就是右侧视图表格了,表格左上方有一个下拉框,它有三个值(有些有四个值,我的只有三个):

  1. Summary:按照构造函数进行分组,捕获对象和其使用内存的情况,可理解为一个内存摘要,用于跟踪定位DOM节点的内存泄漏;

2. Comparison:对比某个操作前后的内存快照区别,分析操作前后内存释放情况等,便于确认内存是否存在泄漏及造成原因;

  1. Containment:探测堆的具体内容,提供一个视图来查看对象结构,有助分析对象引用情况,可分析闭包及更深层次的对象分析;

  2. Statistics:统计视图;

JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

该下拉默认会为我们选择Summary,所以下方表格展示的就是快照1中数据的内存摘要,简单理解就是快照1生成的那一刻,内存中都存了什么,包括占用内存的信息等等。

接下来来简单了解下 Summary 选项数据表格的列都表示什么

  1. Constructor:显示所有的构造函数,点击每一个构造函数可以查看由该构造函数创建的所有对象;
  2. Distance:显示通过最短的节点路径到根节点的距离,引用层级;
  3. Shallow Size:显示对象所占内存,不包含内部引用的其他对象所占的内存;
  4. Retained Size:显示对象所占的总内存,包含内部引用的其他对象所占的内存;

然后同上述操作一样,清除GC,点击一次click,再生成快照,总共生成四次快照。

接下来可以比较第三次和第四次快照中的数据对比:

JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

这样可以过滤很多没有变化的数据,方便对比。

其中,Constructor列是构造函数,每一个构造函数点击都可以查看由该构造函数创建的所有对象,还是要先介绍下此列中常见的构造函数大致代表什么:

  1. system、system/Context 表示引擎自己创建的以及上下文创建的一些引用,这些不用太关注,不重要;
  2. closure 表示一些函数闭包中的对象引用;
  3. array、string、number、regexp 这一系列也能看出,就是引用了数组、字符串、数字或正则表达式的对象类型;
  4. HTMLDivElement 、 HTMLAnchorElement 、 DocumentFragment 等等这些其实就是你的代码中对元素的引用或者指定的 DOM 对象引用;

2.3.3 closure

首先看闭包(也就是closure),可以发现有10次闭包,指向第22行:

JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

点进 (index):22 就可以看到造成内存泄漏的地方: JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

说明哪怕清除GC后,还是有闭包数据导致占用内存。

2.3.4 Array

这里array可以发现,是由于每次执行10次arr.push(add())导致的,数量也是10,且在GC清除后,数据内容都为上次基础之上添加的"winter"。

JavaScript 垃圾回收机制和内存泄漏 - 第三部分(3/3)

2.3.5 总结

至此,可以确认,错误有两点:

  1. 22行的闭包引用数组造成的内存泄漏;
  2. 全局变量 arr 的元素不断增多造成的内存泄漏;

在实际项目中,比如全局对象一直增大这个问题,全局对象我们无法避免,但是可以限制一下全局对象的大小,根据场景可以超出就清理一部分。

比如闭包引用的问题,不让它引用,或者执行完置空。

总之,一切都需要根据具体场景选择解决方案,解决之后重复上面排查流程看内存即可。

三、常见的前端内存问题

  1. 内存泄漏:主要注意上述问题;

  2. 内存膨胀:即在短时间内内存占用极速上升到达一个峰值,想要避免需要使用技术手段减少对内存的占用;

  3. 频繁 GC:GC 执行的特别频繁,一般出现在频繁使用大的临时变量导致新生代空间被装满的速度极快,而每次新生代装满时就会触发 GC,频繁 GC 同样会导致页面卡顿,想要避免的话就不要搞太多的临时变量,因为临时变量不用了就会被回收;

其他部分

转载自:https://juejin.cn/post/7179629924251074618
评论
请登录