记录Javascript垃圾回收和内存泄漏

541 阅读13分钟

垃圾回收

是么是垃圾回收?

垃圾回收是一个术语,用于描述查找和删除那些不再被其它对象引用的对象的处理过程。换句话说,垃圾回收是删除任何其它对象未使用的对象的过程。垃圾回收通常缩写为'GC',式javascript中使用的内存管理系统的基本组成部分。

为什么要进行垃圾回收?

在js中,V8只能使用系统的一部分内存,64位系统下最多能分配1.4G内存,32位系统中0.7G,这样的内存其实并不大,nodejs遇到一个2G的文件,可能就无法进行写入内存进行各种操作了.

  • 为什么内存分配有上限?
  1. js的单线程机制 意味着一旦进入垃圾回收,就无法进行其它任务了,造成卡顿。
  2. 垃圾回收的限制 垃圾回收是一份十分耗时的操作,V8官方是这样形容的
1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式(ps:后面会解释)的垃圾回收甚至要 1s 以上。

鉴于这些原因,V8做了一个粗暴的选择,限制内存,1.4 0.7由此而来。当然这个内存是可以调整的,

// 这是调整老生代这部分的内存,单位是MB。后面会详细介绍新生代和老生代内存
node --max-old-space-size=2048 xxx.js

或者

// 这是调整新生代这部分的内存,单位是 KB。
node --max-new-space-size=2048 xxx.js
怎么进行垃圾回收的?

v8把堆内存分为两部分———新生代内存和老生代内存,新生代内存就是临时分配的内存,存活时间短,老生代内存是常驻内存,存活时间长。 盗个图——

image.png

(2.4w字,建议收藏)😇原生JS灵魂之问(下), 冲刺🚀进阶最后一公里(附个人成长经验分享)

新生代内存默认为32MB和16MB,分别对用64和32位操作系统,够小了,临时存储用的,也能理解。

  • 新生代的垃圾回收怎么做的呢? 首先将新生代内存一分为二,

image.png

From为使用的内存,To为闲置的内存,首先检查From的内存中的对象是否存活,如果存活,就复制到To内存中,否则直接回收,检查完毕之后,To中为使用的内存,From中的内存被回收,为空闲状态,角色反转,检查To的内存中的对象是否存活,如果存活复制到From中,否则直接回收···,如此循环回收。

会有一个问题,检查到非存活对象,直接回收不就好了吗,为什么还要来回翻转,这是为了解决这样的问题,

image.png

深色的代表存活对象,白色的代表未使用的内存,很明显的看出,未使用的内存是不连续的,不利于进行内存的分配,这种零散的空间也叫做内存碎片,刚刚说的新生代垃圾回收算法也叫Scavenge算法。

Scavenge算法主要就是解决内存碎片的问题,在一系列操作之后,to空间就变成了这个样子:

image.png

这样就方便了内存的分配,不过缺点也很明显,新生代内存只能使用一半,但是它存放的是周期短的对象,这种对象一般很少,因此时间性能非常优秀。

  • 老生代的内存回收 新生代内存中变量如果经过多次回收依然存在,就会放到老生代内存当中,这种现象叫做晋升。 发生晋升的原因:
  1. 已经经历过一次Scavenge回收
  2. To(闲置)空间的内存占比超过25%; 老生代内存当中不能再使用Scavenge算法了,老生代积累的内存一般都是很大的,复制大量内存,浪费一半内存空间。

那么老生代怎样进行垃圾回收?

  1. 标记清除,目前在Javascript引擎这种算法是最常用的,到目前为止的大多数浏览器的Javascript引擎都采用标记清除算法,只是不同的浏览器厂商对此算法做了不同的优化。

整个标记清除算法大致过程就像下面这样:

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中的所有对象都是垃圾,全标记为0
  • 然后从各个根对象开始遍历,把不是垃圾的节点变为1
  • 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  • 最后,把所有内存中的对象标记修改为0,等待下一轮垃圾回收

根对象,在浏览器中包括又不止于全局window对象,文档DOM树等;

  • 优点 标记清除的优点只有一个,那就是实现比较简单,标记无非是打与不打两种,这使得一位二进制位(0和1)就可以为其标记,十分简单。
  • 缺点 老生代内存同样存在内存碎片的问题,老生代怎么解决呢?

问题产生的原因,是因为垃圾清除之后剩余对象内存位置不变引发的问题。

标记整理(Mark-Compact)算法就可以有效的解决,他的标记阶段和标记清除算法没什么不同,只是标记结束后,标记整理算法将活着的对象向内存的一端移动,最后清理掉边界的内存。

image.png

增量标记

标记清除算法是一种全停顿的垃圾回收方式,对于老生代大量内存来说,耗时过久,为了减少全停顿时间,在2011年,V8对老生代的标记进行了优化,全停顿变为增量标记。

什么是增量? 增量就是将一次性进行标记的任务,分为很多的小步,每个小步执行完之后,执行一会儿逻辑,这样交替就完成了一轮标记。 image.png

三色标记法(暂停与回复)

执行一会儿增量后,如果采用非黑即白的标记策略,暂停回复后该从哪开始执行,为了解决这个问题V8团队采用了一种特殊的方式:三色标记法

image.png

  • 白色是指未被标记的对象
  • 灰色是指自身被标记,成员变量未被标记
  • 黑色是指自身和成员变量皆被标记 最开始都是白色,从一组根对象开始,先将根对象放入到标记列表中,并改变颜色为灰色,当对象的成员变量被标记后,自身变为黑色,无法到达的节点还是白色。

暂停过后恢复执行,直接找到灰色标记并接着往下执行标记就可以了。

三色标记法不需要每次都扫描整个内存空间,减少了全停顿时间。

写屏障

如果增量标记过程中,引用关系被修改怎么办?

image.png

B不再引用C,而引用了D,因为C现在是黑色标记,是不会被清理,不过我们不用考虑这个,引用关系没有之后很快会被清理。D是白色,D将在清理阶段被回收,还有引用关系就被回收这肯定是不对的。

为了解决这个问题,V8增量回收采用写屏障的方法,即出现黑色引用白色,强制把白色改为灰色,从而保证下一次可以正确的标记,这个机制也叫做强三色不变性

惰性清理

如果可用内存可以使我们流畅的运行代码,其实是没必要立即进行垃圾清理的,可以稍微延迟,也无需一次清理完所有内存,可以按需逐一清理直到所有的非活动对象内存被清理完毕,接着再执行增量标记。

增量标记和惰性清理的优缺

增量标记和惰性清理是主线程一次性停顿的时间大大减少,是程序运行更加流畅,但是总停顿时间并没有减少反而略有增加,三色标记法和写屏障无疑增加了运行成本,降低应用程序的吞吐量。

并发回收

image.png

它指的是在垃圾回收过程中,主线程不停顿,辅助线程在后台完成GC过程。这是并发回收的优点,同时这也是实现的难点,在主线程运行的过程中,堆内存的引用地址改变,进而引起一系列的改变,比如说标记,它需要额外实现一些读写锁机制来实现。

  1. 引用计数法 这其实是最早的一种垃圾回收算法,他把对象是否不再需要简化定义为对象有没有其它对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,因为它的问题很多,还是需要了解下

它的策略是跟踪记录每个变量值被使用的次数:

  • 当声明了一个变量并将一个引用类型赋值给该变量的时候这个值的引用次数就为1
  • 同一个值又被赋给另一个变量,引用数加一
  • 该变量的值被其它值覆盖了,则引用次数减一
  • 当这个值的引用次数为0的时候,就说明这个值不再被需要了,垃圾回收器会在运行的时候回收掉引用次数为0的值占用的内存。
let A = new Object();  // 对象的引用数为1
let B = A;             // 对象的引用数为2
let A = null;          // 对象的引用数减 1  对象的引用数为 1
let B = null;          // 对象的引用数减 1  对象的引用数为 0
···                    // 被回收

这种引用很容易遇到一个问题循环引用,例如

function gcTest(){
    let A = new Object();
    let B = new Object();
    A.b = B;
    B.a = A;
}

gcTest这个函数调用之后,A和B是要被清除的,但是按照引计数,引用数都是2,是无法完成清理的。按照标记清除的思路来看下,当函数结束后,gcTest这个函数不再被使用,里边的对象也会成为非活动对象,被清除掉。这也是后来放弃引用计数,使用标记清除的原因之一。

  • 优点 对比标记清除算法可以看出来,当引用为0的时候,就可以立即回收。

标记清除算法需要每隔一段时间清理一次,这时候必须要暂停去执行,还要遍历根对象,而引用计数只需要在引用的时候计数就可以了。

  • 缺点 首先需要一个计数器,这个计数器需要占用很大的位置,因为我们并不清楚引用数量的上限,还有就是循环应用无法回收的问题,这也是最严重的。

垃圾回收就说到这里了,基本上理解了垃圾的回收的方法以及为什么要进行垃圾回收,我想这才是最重要的。

内存泄漏

什么是内存泄漏?

当不再被使用的对象内存,没有被及时回收,这就是内存泄漏。

常见的内存泄漏
1. 不正当的闭包
function func1(params) {
    let arr1 = new  Array(100).fill('张三');
    return function func2() {

    }
};
let resFunc1 = func1();

function func2(params) {
    let arr2 = new  Array(100).fill('张三');
    return function func2() {
        let name = arr2;
    }
};
let resFunc2 = func2();
resFunc2();
resFunc2 = null;

闭包resFunc1没有造成内存泄漏,resFunc2造成了内存泄漏,resFunc2中存在对arr2的引用,没有被回收,造成了内存泄漏,当resFunc2函数调用完成之后,resFunc2 = null,切断引用手动垃圾回收就可以了。

2. 隐式全局变量

对于全局变量,垃圾回收机制很难判断什么时候去回收,通常不会回收它们,所以我们要避免额外全局变量的使用。

function func3(params) {
    test = new  Array(100).fill('张三');
    this.test2 = new  Array(100).fill('张三');
};
func3();

无意中全局增加了 test和test2两个全局变量,首先避免这种写法,使用

"use strict"

或者就是及时清理全局变量中不再使用的和额外添加的变量。

3. 游离DOM引用

觉得这个不常用,还是解释下,游离DOM,我理解的就是父节点被删除,但是自身还存在引用的节点,无法GC,需要手动把父节点和父节点的子节点全部置为null,才能GC。

4. 遗忘的定时器
setInterval(() => {
    this.$axios().then();
}, 3000);  

比如说每隔三秒更新一次数据,没有及时清除的情况下,定时器引用的变量之类会一直存在,不会被回收掉,setTimeout 和 cancelAnimationFrame同样存在这个问题,需要及时用 clearInterval 或者 clearTimeout 或者 cancelAnimationFrame 及时清除掉定时器

5. 遗忘的事件监听器
<template>
    <div></div>
</template>
<script>
export default {
    created(){
        window.addEventListener('resize',this.func)
    },
    beforeDestroy(){
        window.removeEventListener("resize", this.func)
    },
    methods:{
        func(){}
    }
}
</script>

当事件监听器在组建内挂载事件处理函数,组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果里边引用了大量的数据,比如说请求返回的数据,可能会引起页面内存占用过高,造成卡顿,甚至崩溃。

6. 遗忘的监听者模式
<template>
    <div></div>
</template>

<script>
export default {
    created() {
        eventBus.on("listener", this.func)
    },
    beforeDestroy(){
        eventBus.off("listener", this.func)
    },
    methods: {
        func() {
        // do something
        }
    }
}
</script>

和遗忘的事件监听器相同,在beforeDestroy勾子函数清除掉即可。

7. 遗忘的Map Sep对象

当使用Map或者Sep存储对象时,都是对Object的强引用,需要主动清除引用。

WeakMap对于键是弱引用,不会干扰GC,WeakSet也是如此。

//  obj 对 {a :1} 是强引用
let obj = {a :1};

//  map 对 obj 是强引用
let map = new Map();
map.set(obj,1)

//  set 对 obj 是强引用
let set = new Set();
set.add(obj);

obj = null;
console.log(map.size); // 1
console.log(set.size); // 1

改为 WeakSet WeakMap

    let obj = {a :1};

    //  map 对 obj 是强引用
    let map = new WeakMap();
    map.set(obj,1)

    //  set 对 obj 是强引用
    let set = new WeakSet();
    set.add(obj);

    obj = null;
    console.log(map.size); // undefined
    console.log(set.size); // undefined

如上所示,当使用WeakSet WeakMap后,map.size 和 set.size 打印为undefined,已经被清理掉了。

8. 未清理的console

觉得这点比较容易理解吧,console.log()也是一个函数,

image.png

函数传参都是值传递,对值的引用,会导致内存清除不掉,这应该会造成内存泄漏的。

总结:

介绍了垃圾回收和内存泄漏,以后写代码的时候,也可以从容的避免这些坑,这个也算优化吧。

参考文章: