JS的GC到底怎么工作的?💼

JS的GC到底怎么工作的?💼

引子: 啥是GC,为什么需要GC🗑️

GC看起来高端,其实就是 垃圾回收机制(Garbage Collection) 的英文简称,主要负责清理执行过程中遗留的未被使用的变量,避免程序执行过程中耗尽内存而崩溃。

注意⚠️

高级编程语言中,GC似乎成了一个必需品。但是需要注意的是,像C/CPP这种偏底层的语言中,GC是不存在的。

为什么呢???🥲

其实这么做也是有原因的

  1. 由于GC运行比较耗时,在高级语言中性能可能不是问题,但在C/C++中,这可是不被允许的
  2. C/C++的设计哲学就是把控制权交给程序员,加上GC显然违背了设计理念

这种把所有控制权都交给程序员的做法可能很酷,但是会给日常开发带来很多麻烦,对于咱们这种三脚猫🐱来说是很痛苦的

反观咱们JS,主打就是一个

  • 皮实☝️,
  • 性能什么的也不用在乎了(反正本来就是一片废墟了)
  • 开发怎么舒服怎么来(虽然奇奇怪怪的bug让人很😖)

已经在谷底了,怎么走都是向上🔝


因此,有个GC很合理吧🤓。那么,今天就来讨论讨论

  • JSGC到底是怎么工作的?
  • 那些操作会绕过GC,造成内存泄漏?
  • 如何查看是否内存泄漏?
  • 如何预防内存泄漏

拓展知识: 编译器、作用域、引擎的爱恨情仇🤡

大家都知道,JS的执行分为 编译执行两个阶段。那么这两个阶段都干了什么事呢?这两个阶段跟作用域又有什么关系呢?

编译

编译阶段看名字也知道,是编译器在干活,主要干两件事:

  • 把变量放到作用域里边
  • 给之后的引擎编译执行代码

那么,它又是如何做到识别变量的呢?这可是字节的面试题,仔细听

来看例子:

1
let a = 4;

这个例子中,编译器先会把上边的句子分成 let, a, =, 4几个部分,这一步叫词法分析

之后,它会根据词法分析的结果,构建抽象语法树AST,构建出来的树首先有一个Variable Declearation的父节点,下来的子节点是Identifier,就是a,与他为兄弟节点的是AssignmentExpression,代表赋值操作,它也有一个子节点,是NumericLiteral,代表数字。

遇到let a,编译器先向作用域询问是否存在a这个变量,如果存在,就抛出错误

思考:如果把 let改成var呢?

如果不存在,由上边AST得知是一个基本类型变量NUmbericLiteral,在栈内存中开辟一个变量a,在作用域里新建一个a

如果是一个引用对象,即AST中AssignmentExpression子节点为Reference,那么就会把内容存入堆内存,栈中创建新变量保留堆地址

在这里插入图片描述

之后,编译器才会处理a=2语句,它会把这个语句编译为引擎能执行的句法,把压力交给引擎

引擎

引擎在执行上一步生成的代码时,会查找变量的声明,就比如上边的例子,引擎执行时会询问作用域是否存在a变量,查找也分为两种:LHSRHS,分别代表地址查询和值查询,这里的a就是一个地址查询,因为要把值赋给它嘛。很好理解吧!好!那我们进入正题咯

Part1: JS的GC到底怎么工作的💼

🗑️垃圾回收机制的原理很简单,就跟把🐘塞到冰箱一样,主要分为三步

  1. 找出不再使用的变量
  2. 释放其占用的内存
  3. 固定的时间间隔运行

可以看到,只要第一步做对了,那第二、第三步就是洒洒水🙂‍↕️,那么引擎是如何找到不再使用的变量的呢?

再来给大家科普一下ECMA吧🥰

ECMAJS的规范,按道理来说JS其实应该叫做ECMAScript,或者说是ECMAScript的一种。JS的所有特性都是由ECMA决定的

![image-20240428171503709](/Users/lawkaiqing/Library/Application Support/typora-user-images/image-20240428171503709.png)

那么,在ECMA中,有没有GC的定义呢?

很遗憾,没有,在ECMA中并没有GC的相关定义,所以GC似乎是靠各浏览器厂商自由发挥的,查了一下,大概分为两种方法。

1. 计数清除

计数清除主要是咱们大名鼎鼎的IE在使用,原理很简单,就是当这个变量被引用时,计数加一,解除引用时,计数减一,当计数为零的时候,这个变量就会被GC回收

1
2
3
4
5
6
7
8
function test(){
let a = new Object(); // a = 1
let b = new Object(); // b = 1

let c = a; // a++ -> 2
let c = b; // a-- -> 1

}

这里就要用到咱们上边提到的预备知识啦

  • 你看这个let a = new Object();new Object在堆新建变量,然后栈中a引用这个object,count++;

在下方,let c = a的时候,构建的AST语法树是怎么样的呢?为什么这个时候引用会是2呢?

那来看下一个例子:

1
2
3
4
5
6
function Person(){
let a={};
let b={};
a.prop = b;
b.prop = a;
}

这里a和b的引用次数是多少呢?

不难看出,这里a和b的引用次数都是1,这代表着两个变量都不会被GC回收,这就是计数清除掉一大缺点:循环引用

那这下就糟糕了,一旦有循环引用出现,就会引发内存泄漏,太危险了‼️

而且,这种做法计数器更改的会很频繁,每一次赋值操作可能都会更改计数器,因此也被抛弃在时代的长河中。

2. 标记清除

这是目前浏览器大多基于标记清除法。我们可以分为两个阶段:

  • 标记:从根节点遍历为每个可以访问到的对象都打上一个标记,表示该对象可达。
  • 清除:在没有可用分块时,对堆内存遍历,若没有被标记为可达对象就将其回收。

这下子就很暴力了,直接遍历这个AST,没找到的变量就不打标记🏷️

而且完美解决了计数清除的两大问题🙋‍♀️:

  • 循环引用 -> 打标记哪来的循环引用
  • 计数器更改频繁 -> 要没空间了才遍历整棵树,改动次数大大减少

但是,这样不是完美的,主要有两大缺点:

  • 内存碎片化,删除没标记的变量的内存是随机的,很零散
  • 内存分配速度慢,由于内存随机,所以速度慢

针对这些问题,就衍生出了标记-整理

在这里插入图片描述

标记阶段一致,不过在分配的过程中,把被引用的对象移到一端,凑出完整的内存空间

3. NodeJs V8 中的垃圾回收机制

在 Node 中,通过 JS 使用内存时就会发现只能使用部分内存(64 位系统下约为 1.4 GB, 32 位系统下约为 0.7 GB),这导致 Node 无法直接操作大内存对象。

这是因为,以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要 50 毫秒以上,做一次非增量式的垃圾回收要 1 秒以上,而垃圾回收过程会引起 JS 线程暂停执行这么多时间。因此,在当时的考虑下,直接限制堆内存是一个好的选择。(目前已经可以并行)

3.那么,在这个情况下,V8会怎么做?

新老生代视图

chrome把内存分为两个区,新生代和老生代,老生代就是常驻嘉宾🤨,新生代就是存活时间较短的变量,对于这两个区,垃圾回收的频次是不一样的。来看看吧

1. 新生代

新生代可以看到,把内存分为 使用区 和 空闲区,新加入的对象都会被存放到使用区,当使用区快被写满时,就执行一次垃圾回收操作。由此还引出了 Scavenge 算法

Scavenge 流程如下:

  • 标记活动对象和非活动对象
  • 复制 from space 的活动对象到 to space 并对其进行排序
  • 释放 from space 中的非活动对象的内存
  • 将 from space 和 to space 角色互换

image-20200925123816388

那么,新生代对象如何才能变成老生代呢?

  • 第一种情况:经过多次复制后依然存活的对象会被认为是生命周期较强的对象,会被移到老生代管理。
  • 第二种情况:如果复制一个对象到空闲区时,空闲区空间占用超过25%,那么这个对象将被移到老生代区域。原因是,当完成回收后,空闲区转变成使用区,需继续进行内存分配,若占比过大,将会影响后续内存的分配。

2. 老生代

老生代内容不需要怎么清理,采用的方法就是上边说的标记-整理

3. 增量标记算法

前面的三种算法,都需要将正在执行的 JavaScript 应用逻辑暂停下来,待垃圾回收完毕后再恢复。这种行为叫作“全停顿”(stop-the-world)。

在 V8 新生代的分代回收中,只收集新生代,而新生代通常配置较小,且存活对象较少,所以全停顿的影响不大,而老生代就相反了。

为了降低全部老生代全堆垃圾回收带来的停顿时间,V8将标记过程分为一个个的子标记过程,同时让垃圾回收标记和JS应用逻辑交替进行,直到标记阶段完成。

img

经过增量标记改进后,垃圾回收的最大停顿时间可以减少到原来的 1/6 左右。

Part2. 怎么欺骗GC🥰

要做到欺骗GC,那就要破坏掉GC的工作条件咯,可以看到,只要变量可达,就不会被删除

那么怎么做到变量可达,又没什么用呢?

1. 那当然是闭包啦!

来看例子:

以下代码就会引发内存泄漏哦

1
2
3
4
5
6
7
8
9
10
const fun1 = () => {
const arr = new Array
arr.length = 1008658
return function() {
console.log(arr);
}
}
setTimeout(() => {
const b = fun1()
}, 6000)

2. 那当然是全局变量啦

声明过多的全局变量,会导致变量变为常驻内存,要直到进程结束才能释放内存。

⚠️注意:

不仅仅是声明的变量会导致常驻内存,我们的计时器,是不是也挂载在globalThis上边呢?

因此,来看看例子吧:

1
2
3
4
5
6
7
8
i = 0;
const handleClick = () => {
while (i < 1000) {
setTimeout(() => {
i++;
}, 0);
}
};

运行看看,会是怎么样的?不过要慎用,小心页面崩溃!!!

同样的,还有挂载在globalThis上的事件监听

Part3. 如何查看是否内存泄漏?👻

Chrome已经把工具给咱们做好啦,就在开发者面板上。

别忘了把旁边的内存打开!!!

![image-20240506144233098](/Users/lawkaiqing/Library/Application Support/typora-user-images/image-20240506144233098.png)

就拿上边的

1
2
3
4
5
6
7
8
9
10
const fun1 = () => {
const arr = new Array
arr.length = 1008658
return function() {
console.log(arr);
}
}
setTimeout(() => {
const b = fun1()
}, 6000)

举例吧!

![image-20240506144405667](/Users/lawkaiqing/Library/Application Support/typora-user-images/image-20240506144405667.png)

可以看到中间框内,内存只升不降,那就是造成了内存泄漏

或者如果是点击事件,一般认为,五次连续点击后,仍没被回收,那就是内存泄漏,换句话说,就是图中呈现连续向上的五个阶梯,那就是内存泄漏

Part4. 如何预防内存泄漏🥲

解除引用

确保占用最少的内存可以让页面获得更好的性能。而优化内存占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为 null 来释放其引用——这个做法叫做解除引用(dereferencing)

1
2
3
4
5
6
7
8
function createPerson(name){
var localPerson = new Object();
localPerson.name = name;
return localPerson;
}
var globalPerson = createPerson("Nicholas");
// 手动解除 globalPerson 的引用
globalPerson = null;

解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

提供手动清空变量的方法

1
2
3
4
var leakArray = [];
exports.clear = function () {
leakArray = [];
}

在业务不需要用到的内部函数,可以重构在函数外,实现解除闭包

免创建过多生命周期较长的对象,或将对象分解成多个子对象

避免过多使用闭包

注意清除定时器和事件监听器

Nodejs 中使用 stream 或 buffer 来操作大文件,不会受 Nodejs 内存限制

小结

  • 垃圾回收机制是把双刃剑,它可以让开发者不用担心内存分配,但是同时,会造成脚本执行速度变慢。

  • 以前的浏览器中主要通过标记清除方法来回收垃圾,NodeJs 和Chrome V8中主要通过分代回收、Scavenge、标记清除、增量标记等算法来回收垃圾。

在日常开发中,有一些不引人注意的书写方式可能会导致内存泄漏,需要多注意自己的代码规范!!!🥳


JS的GC到底怎么工作的?💼
http://baidu.com/2024/05/01/gc/
作者
KB
发布于
2024年5月1日
许可协议