手把手带你理解垃圾回收和内存泄漏排查问题

80 阅读12分钟

垃圾回收的概念

是为了以防内存泄漏,内存泄漏的含义就是当已经不需要某块内存时这块内存还存在着,垃圾回收就是间歇的不定期的寻找到不再使用的变量,并释放掉它们所指向的内存。

function LargeObj()  {
    this.LargeArr = new Array(100000)
}
function addGrow() {
    var x = []
    var o = new LargeObj()
    x.push(o)
    return x
}
var b = addGrow()

// 在不再需要 x 数组时,可以手动将其置为null 
// b = null;

以上代码便存在内存泄漏,每当调用addGrow()函数时,都会创建一个新的数组x并向其中推入一个LargeObj实例。然而,每次调用addGrow()函数后,虽然x数组中的对象会被返回给变量b,但x数组本身不会被释放。如果没有其他地方引用x数组,它将成为不可达对象,由于它是一个全局变量,它将一直存在于内存中,从而造成内存泄漏。。但同时也说明了一个问题,内存泄漏可能也并不一定是一个问题,因为我们在实际开发中确实有时候需要抽取一个公共方法并把结果返回出来。要具体情况具体分析。

垃圾回收的方式

1、标记清除:

是当变量进入环境时,将这个变量标记为“进入环境”。当变量离开环境时, 则将其标记为“离开环境”。标记“离开环境”的就回收内存。

function test(){
    var a = 10 ;   // 被标记 ,进入环境 
    var b = 20 ;   // 被标记 ,进入环境
}
test();            // 执行完毕 之后 a、b又被标离开环境,被回收。

2、引用计数

引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减1。当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾回收器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。

function test(){
    var a = {} ;    // a的引用次数为0 
    var b = a ;     // a的引用次数加1,为1 
    var c =a;       // a的引用次数再加1,为2
    var b ={};      // a的引用次数减1,为1
}

前端排查工具的认识和使用

在排查内存泄漏问题中,我们可以借助Chrome DevTools里的Performance面板和Memory面板可以用来定位内存问题。

1、Performance面板

当我们怀疑页面发生了内存泄漏的时候,可以先用Performance录制一段时间内页面的性能变化。你只需要切换到Performance面板,点击Record,然后在页面上正常操作一段时间,点击手动垃圾回收按钮,最后停止录制即可。

image.png

如果录制结束后,看到内存的下限在不断升高的话,你就要注意了,这里蓝色的线的下限被明显提高,所以我们可以推测出js脚本可能已经造成了内存泄漏。当你怀疑发生了内存泄漏的时候,你就可以用Memory面板来进一步定位泄漏的源头了。

2、Memory面板

通常,我们可以从Memory的主界面开始,点击左上角的圆点就可以记录下当前的堆内存快照(heap snapshot)了。

image.png

1、先简单地在当前页面录制一个快照

image.png

  • Object:普通对象或者 DOM 节点:

  • Distance:到根节点的距离。

  • Shallow size: 这是对象自身占用内存的大小。通常只有数组和字符串的shallow size比较大。

  • Retain size: 这是将对象本身连同其无法从 GC 跟到达的相关对象一起删除后释放的内存大小。 因此,如果Shallow Size = Retained Size,说明基本没怎么泄漏。而如果Retained Size > Shallow Size,就需要多加注意了。

2、在页面上正常操作一段时间后,进行第二个快照的录制

image.png 此时可以发现第二个快照的内存变大了,我们一般会进行前后快照的比较。

3、前后快照比较

image.png

切换到comparison, comparison视图可以让你对比两份内存快照之间的差异。默认是跟上一份快照做对比,当然你也可以选择任意两份内存做对比。

这个视图下每一列的数据有点不同:

  • Constructor: 对象的构造器。
  • New: 该对象构造器下有多少新对象被创建
  • Deleted: 该对象构造器下有多少新对象被销毁
  • Delta: New - Delete的差值 (越大说明泄漏越多)
  • Alloc.Size:两份快照之间新分配的内存
  • Freed Size: 两份快照之间释放掉的内存
  • Size Delta:Alloc Size - Freed Size 的差值(越大说明泄漏越多)

Constructor下都对应的哪些内容?

  • Detached HTMLDivElement:代码里对指定类型Dom节点的引用。
  • DOMTimer: 对应定时器相关的内存引用。
  • EventListener:对应事件监听的内存引用。
  • (array, string, number, Function): 包含着一系列对象,这些对象的属性上有对应类型变量的引用。
  • (compiled code): Javascript引擎(如V8)为了加快运行速度,会对代码进行一次编译。(compiled code)顾名思义就是指与编译后的代码相关联的内存。
  • (closure): 函数闭包持有的内存引用。

这个视图绝对是排查内存泄漏的利器。当你能定位到是哪些操作可能造成内存泄漏后,比较操作前后的内存快照,很容易就能发现发生内存泄漏的对象。其中前四个是笔者开发中目前关注得比较多的。

3、使用 Memory 面板注意事项

尽量减少干扰项的影响力:

1、分辨正常的内存变化的干扰。

2、注意开发环境的打包器热加载逻辑等的影响。

3、 生成环境的代码是混淆过的,一些构造器名字很奇怪,如果可以的话,本地打包一份没经过混淆过的代码做 debug。或者也可以 hover 看看对象结构猜测对应构造器,但效率不高。

4、不要有浏览器插件,它们也占用和影响内存,可以用无痕浏览器

5、请更新到最新版本的谷歌浏览器。(被坑惨了)

常见内存泄漏排查和解决

常见的内存泄漏

  • 事件监听引起的内存泄漏
  • 定时器引起的内存泄漏
  • 全局变量引起的内存泄漏
  • DOM节点分离引起的内存泄漏
  • 闭包引起的内存泄漏
  • console.log() 引起的内存泄漏

笔者把常见的内存泄漏排查分为两种方式:一种是在不同页面录制前后快照的排查,一般是用于定时器和事件监听引起的内存泄漏,因为这种一般都是需要就在页面切换前就清除的。另一种是在相同页面录制前后快照的排查,当前页面的内存排查一般是用于排查除定时器和事件监听以外的内存泄漏,因为由于vue是单页面,每次路由切换页面都会销毁(有做页面缓存除外),当前页面的泄漏也会随着被浏览器回收了。(备注:以下案列都是笔者为了方便演示有内存泄漏的不同情况,而特地构造的两个页面,实际的开发过程中定位页面内存泄漏可能会比较复杂一点,需要具体情况具体分析,笔者当时在定位页面内存泄漏问题时,也是花了一点时间。

1、不同页面的录制前后快照的排查

1.1、事件监听引起的内存泄漏

document.addEventListener('keyup', handleKeyup)
document.addEventListener('keydown', handleKeyDown)

function handleKeyup(event) {
    if (event.keyCode === 27) {
        console.log('====ESC')
    }
}
function handleKeyDown(event) {
    if (event.keyCode === 13) {
        console.log('====enter')
    }
}

打开浏览器的无痕模式,切换到Performance面板,点击录制后,在有监听事件泄漏的页面和没有监听事件泄漏的页面来回切换。然后点击手动垃圾回收,再停止录制。

image.png

可以发现黄色的线(事件监听)的线被明显提高,所以可以怀疑事件监听的事件发生了内存泄漏,而且笔者一共切换了五次,所以有五次提升。接下来我们就可以用Memory面板进行排查。

再次打开浏览器的无痕模式,切换到Memory面板,在没有发生内存泄漏的页面录制一次快照。

image.png

然后来回切换发生泄漏的页面,录制第二次快照,并进行比较。

image.png

1.2、定时器引起的内存泄漏

import { ref } from 'vue'

let timer = ref(null)
function addTime() {
    timer.value = setInterval(() => {
    }, 1000)
}

addTime()

排查方式同上面,只不过过滤排查的值换成了timer了。还有Performance面板笔者也无法很明显地看出哪里有泄漏问题,只能在快照上看了。

Performance面板: image.png

Memory面板:

image.png

2、相同页面录制前后快照的排查

这里要特别说明一下,以下是笔者在明确有内存泄漏的前提下进行的操作演示,在实际开发排查中可能会更复杂。所以以下的内存泄漏也可能得在不同页面拍摄快照才能发现,因为有可能页面缓存导致页面不会被重新渲染,从而导致泄漏,也可能是某个按钮点击,也可能是某次请求。

2.1、全局变量引起的内存泄漏

<template>
    <div>
        <!-- 全局变量导致的内存泄漏 -->
        <el-button @click="addGrow">全局变量导致的内存泄漏</el-button>
    </div>
</template>

<script setup>
import { ref, onBeforeUnmount, nextTick } from 'vue'
function LargeObj()  {
    this.LargeArr = new Array(100000)
}

// 由于x是一个全局变量,每次调用 addGrow() 函数时,都会向 x.value 数组中添加一个新的 LargeObj 实例。
// 会导致内存泄漏,因为每个LargeObj实例都会占用一定的内存空间,而且由于x是全局变量,它的生命周期会持续到页面关闭或显式释放它(组件切换后就被释放了)。
const x = ref([])

function addGrow() {
    var o = new LargeObj()
    x.value.push(o)
}

</script>

题外话: x针对相对于当前页面组件是全局变量。相对于其他页面组件就是局部变量了,当组件切换时,x就会被自动回收,如果换成window.x去接收则不会。

打开浏览器的无痕模式,切换到Performance面板,点击录制后,在当前页面进行点击操作。然后点击手动垃圾回收,再停止录制。

image.png

可以发现蓝色的线下限被提高了,所以可以怀疑发生了js脚本的内存泄漏。

再次打开浏览器的无痕模式,切换到Memory面板,在当前页面不做操作录制一次快照。然后点击按钮5次后,进行第二次快照录制,再进行比较。可重点关注一下这几个变量:array, string, number, Function。

image.png

2.2、闭包导致的内存泄漏

<template>
    <div>
        <!-- 闭包导致的内存泄漏 -->
        <el-button @click="closure">闭包导致的内存泄漏</el-button>
    </div>
</template>

<script setup>
import { ref, onBeforeUnmount, nextTick } from 'vue'


let funcs = ref([])
function outer() {
    var someText = new Array(1000000)

    return function inner() {
        return someText
    }
}

// 当调用closure()函数时,会将outer()函数的返回值(即内部函数inner())推入funcs数组中。
// 由于inner()函数引用了外部函数outer()中的someText变量,形成了闭包。
function closure() {
    funcs.value.push(outer())
}

</script>

同样的方法排查闭包内存泄漏的问题

image.png

可重点关注一下这几个变量:array, string, number, Function。 image.png

2.3、分离节点导致的内存泄漏

<template>
    <div>
        <!-- 分离节点导致的内存泄漏 -->
        <button @click="toggleComponent">分离节点导致的内存泄漏</button>
        <div v-if="showComponent" ref="containerRef">
            {{ message }}
        </div>
    </div>
</template>

<script setup>
import { ref, onBeforeUnmount, nextTick } from 'vue'


const containerRef = ref(null)
const showComponent = ref(true)
const dom = ref('')
const message = ref('Hello World')
// 保留了节点的引用,在有echart中可能会这么写,只不过是在mounted
nextTick(() => {
    dom.value = containerRef.value
})
// 节点导致的内存泄漏
const toggleComponent = () => {
    showComponent.value = !showComponent.value
}

</script>

同样的方法排查分离节点导致内存泄漏的问题

image.png

image.png

2.4、console.log()

记得如果要本地调试,把console.log()去掉减少干扰。不然你有时明明都找对地方并解决了都没发现。 生产环境脚手架一般都会把console.log()给去掉。

tip:代码中尽量不要出现匿名函数,建议使用命名函数,不然调试完不会出现函数名,只会出现括号,不方便定位。

3、实际项目开发中的内存泄漏解决方案

3.1、监听器的问题

常犯的错误,这样第二个参数相当于不同的引用。

document.addEventListener('keyup', function handleKeyup(event) {
    if (event.keyCode === 27) {
        console.log('====ESC')
    }
})
document.addEventListener('keydown', function handleKeyDown(event) {
    if (event.keyCode === 13) {
        console.log('====enter')
    }
})

onBeforeUnmount(() => {
    document.removeEventListener('keyup', function handleKeyup(event) {
        if (event.keyCode === 27) {
            console.log('====ESC')
        }
    })
    document.removeEventListener('keydown', function handleKeyDown(event) {
        if (event.keyCode === 13) {
            console.log('====enter')
        }
    })
})

正确的解决方案:

document.addEventListener('keyup', handleKeyup)
document.addEventListener('keydown', handleKeyDown)

function handleKeyup(event) {
    if (event.keyCode === 27) {
        console.log('====ESC')
    }
}
function handleKeyDown(event) {
    if (event.keyCode === 13) {
        console.log('====enter')
    }
}

onBeforeUnmount(() => {
    document.removeEventListener('keyup', handleKeyup)
    document.removeEventListener('keydown', handleKeyDown)
})

3.2、定时器的问题

import { ref, onBeforeUnmount } from 'vue'

let timer = ref(null)
function addTime() {
    timer.value = setInterval(() => {
    }, 1000)
}

addTime()

onBeforeUnmount(() => {
    clearInterval(timer.value)
    timer.value = null
})

3.3、全局变量,闭包,节点分离的问题

在vue中由于是单页面组件,所以组件切换就会被销毁。如果不放心,可以在销毁之前将对应的引用置空

import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
    ......
})

参考文章:

浏览器中的垃圾回收与内存泄漏

手把手教你如何排查Javascript内存泄漏

结语:

才疏学浅,以上仅代表个人的见解,如有错误,欢迎指出,共同学习。后面如果有新的发现还会继续更新。