JS的GC到底怎么工作的?💼
JS的GC到底怎么工作的?💼
引子: 啥是GC,为什么需要GC🗑️
GC
看起来高端,其实就是 垃圾回收机制(Garbage Collection)
的英文简称,主要负责清理执行过程中遗留的未被使用的变量,避免程序执行过程中耗尽内存而崩溃。
注意⚠️
高级编程语言中,
GC
似乎成了一个必需品。但是需要注意的是,像C
/CPP
这种偏底层的语言中,GC
是不存在的。为什么呢???🥲
其实这么做也是有原因的
- 由于
GC
运行比较耗时,在高级语言中性能可能不是问题,但在C/C++中,这可是不被允许的- C/C++的设计哲学就是把控制权交给程序员,加上GC显然违背了设计理念
这种把所有控制权都交给程序员的做法可能很酷,但是会给日常开发带来很多麻烦,对于咱们这种三脚猫🐱来说是很痛苦的
反观咱们JS,主打就是一个
- 皮实☝️,
- 性能什么的也不用在乎了(反正本来就是一片废墟了)
- 开发怎么舒服怎么来(虽然奇奇怪怪的bug让人很😖)
已经在谷底了,怎么走都是向上🔝
因此,有个GC
很合理吧🤓。那么,今天就来讨论讨论
JS
的GC
到底是怎么工作的?- 那些操作会绕过
GC
,造成内存泄漏? - 如何查看是否内存泄漏?
- 如何预防内存泄漏
拓展知识: 编译器、作用域、引擎的爱恨情仇🤡
大家都知道,JS的执行分为 编译
和执行
两个阶段。那么这两个阶段都干了什么事呢?这两个阶段跟作用域又有什么关系呢?
编译
编译阶段看名字也知道,是编译器在干活,主要干两件事:
- 把变量放到作用域里边
- 给之后的引擎编译执行代码
那么,它又是如何做到识别变量的呢?这可是字节的面试题,仔细听
来看例子:
1 |
|
这个例子中,编译器先会把上边的句子分成 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
变量,查找也分为两种:LHS
和RHS
,分别代表地址查询和值查询,这里的a
就是一个地址查询,因为要把值赋给它嘛。很好理解吧!好!那我们进入正题咯
Part1: JS的GC到底怎么工作的💼
🗑️垃圾回收机制的原理很简单,就跟把🐘塞到冰箱一样,主要分为三步
- 找出不再使用的变量
- 释放其占用的内存
- 固定的时间间隔运行
可以看到,只要第一步做对了,那第二、第三步就是洒洒水🙂↕️,那么引擎是如何找到不再使用的变量的呢?
再来给大家科普一下ECMA
吧🥰
ECMA
是JS
的规范,按道理来说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 |
|
这里就要用到咱们上边提到的预备知识啦
- 你看这个
let a = new Object();
,new Object
在堆新建变量,然后栈中a
引用这个object,count++;
在下方,
let c = a
的时候,构建的AST语法树是怎么样的呢?为什么这个时候引用会是2呢?
那来看下一个例子:
1 |
|
这里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 角色互换
那么,新生代对象如何才能变成老生代呢?
- 第一种情况:经过多次复制后依然存活的对象会被认为是生命周期较强的对象,会被移到老生代管理。
- 第二种情况:如果复制一个对象到空闲区时,空闲区空间占用超过25%,那么这个对象将被移到老生代区域。原因是,当完成回收后,空闲区转变成使用区,需继续进行内存分配,若占比过大,将会影响后续内存的分配。
2. 老生代
老生代内容不需要怎么清理,采用的方法就是上边说的标记-整理。
3. 增量标记算法
前面的三种算法,都需要将正在执行的 JavaScript 应用逻辑暂停下来,待垃圾回收完毕后再恢复。这种行为叫作“全停顿”(stop-the-world)。
在 V8 新生代的分代回收中,只收集新生代,而新生代通常配置较小,且存活对象较少,所以全停顿的影响不大,而老生代就相反了。
为了降低全部老生代全堆垃圾回收带来的停顿时间,V8将标记过程分为一个个的子标记过程,同时让垃圾回收标记和JS应用逻辑交替进行,直到标记阶段完成。
经过增量标记改进后,垃圾回收的最大停顿时间可以减少到原来的 1/6 左右。
Part2. 怎么欺骗GC🥰
要做到欺骗GC,那就要破坏掉GC的工作条件咯,可以看到,只要变量可达,就不会被删除
那么怎么做到变量可达,又没什么用呢?
1. 那当然是闭包啦!
来看例子:
以下代码就会引发内存泄漏哦
1 |
|
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 |
|
举例吧!
![image-20240506144405667](/Users/lawkaiqing/Library/Application Support/typora-user-images/image-20240506144405667.png)
可以看到中间框内,内存只升不降,那就是造成了内存泄漏;
或者如果是点击事件,一般认为,五次连续点击后,仍没被回收,那就是内存泄漏,换句话说,就是图中呈现连续向上的五个阶梯,那就是内存泄漏
Part4. 如何预防内存泄漏🥲
解除引用
确保占用最少的内存可以让页面获得更好的性能。而优化内存占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为 null 来释放其引用——这个做法叫做解除引用(dereferencing)
1 |
|
解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。
提供手动清空变量的方法
1 |
|
在业务不需要用到的内部函数,可以重构在函数外,实现解除闭包
免创建过多生命周期较长的对象,或将对象分解成多个子对象
避免过多使用闭包
注意清除定时器和事件监听器
Nodejs 中使用 stream 或 buffer 来操作大文件,不会受 Nodejs 内存限制
小结
垃圾回收机制是把双刃剑,它可以让开发者不用担心内存分配,但是同时,会造成脚本执行速度变慢。
以前的浏览器中主要通过标记清除方法来回收垃圾,NodeJs 和Chrome V8中主要通过分代回收、Scavenge、标记清除、增量标记等算法来回收垃圾。
在日常开发中,有一些不引人注意的书写方式可能会导致内存泄漏,需要多注意自己的代码规范!!!🥳