前言
在现在的面试中,无论是大中小厂,垃圾回收机制已经是老生常谈的问题了,那么垃圾回收到底是什么?本篇文章继续针对这一问题展开探讨,如有不足的地方欢迎大家批评指正。
一、基础概念
(1)js内存的生命周期
1、 内存分配:当声明变量、函数、对象时,系统会自动分配内存给它们
2、 内存使用:即读写内存,也就是使用变量、函数
3、 内存回收:使用完毕,由垃圾回收器自动回收不再使用的内存
(2)什么是垃圾回收?
在说垃圾回收之前,我们首先需要了解的是,什么是垃圾?为什么要进行垃圾回收?
垃圾: JS中的函数,变量,对象等都需要占用一定的内存,当这些东西不再被使用的时候,就变成了垃圾
- 已经调用完毕的函数作用域及其内部的值
- 值为 null 值
- 无法被访问到的值
上面已经说了,JS中的所有的变量都会占用内存,当这些变量变成垃圾的时候,如果不进行回收,内存就会被一直占用,随着程序的运行,垃圾也会越来越多,总有一刻,内存会被占满,程序也就无法运行了
垃圾回收: JavaScript中内存管理的重要组成部分。开发人员不需要手动分配和释放内存。垃圾回收机制可以自动处理内存的分配和释放,减轻了开发人员的负担,并且降低了内存泄漏的风险,它的主要目的是自动地检测和释放不再使用的内存,以便程序能够更高效地利用系统资源
(3)垃圾回收机制(Garbage Collection)简称GC
js的垃圾回收机制是一种内存管理技术,对开发者来说,js的垃圾回收机制是自动的、无形的。
我们创建的原始值、对象、函数......这一切都会占用内存,而在js引擎中有一个垃圾回收器在后台执行。
注意:全局变量一般不会回收(关闭页面时回收);一般情况下局部变量的值,不用了,会被自动回收掉。
二、已过时的垃圾收集算法 - 引用计数法
(1)引用计数法原理
主要看当前创建的对象在堆内存中有没有被其他对象引用,如果没有被其他对象引用,那么该对象将被垃圾回收机制回收。
ini
let obj1 = { name: 'test'}; // 创建一个对象,被obj1所引用,计数为1
let obj2 = obj1; // obj2变量是第二个对该对象的引用,计数为2
obj1 = null; // 该对象的原始引用obj1已经没有了,计数为1
obj2 = null; // 此时对象所有引用都没有了,计数为0,垃圾回收机制回收该对象
(2)致命缺点:循环引用
两个对象内的属性相互引用对象,两个对象的引用计数永远大于0,无法回收导致内存泄漏。
ini
function func(){
let obj1 = {};
let obj2 = {};
obj1.a = obj2;
obj2.a = obj1;
return 1;
}
在2012年以后所有现代浏览器都取消这种算法了,取而代之的是标记清除法。
三、标记清除法(mark-and-sweep)
2012年起,所有现代浏览器都使用了标记清除法
(1)标记清除法原理
它会定期执行以下"垃圾回收"步骤:
例如,对象有如下的结构:
第一步,标记所有的根:
下面列出固有的可达值的基本集合, 这些值明显不能被释放。
比方说: 当前执行的函数,它的局部变量和参数。
当前嵌套调用链上的其他函数、它们的局部变量和参数。
全局变量。
(还有一些内部的)
这些值被称作 根 (roots)
第二步,跟随根的引用标记它们所引用的对象:
第三步,......如果还有引用的话,继续标记:
最后,通过这个过程没有被标记的对象被认为是不可达的,并且会被删除。
关于垃圾回收,js引擎做了许多优化,使垃圾回收运行速度更快,也使得现代浏览器的性能越来越高,并且不会对代码执行引入任何延迟。
四、分代回收(Generational Collection)
分代回收是一种结合了标记清除和引用计数的垃圾回收机制,它会根据对象的生命周期将内存分为不同的代。 关于两种分代回收的原理如下
老生代回收
老生代实际上就是上面说到的标记清除算法,这套算法适用于存活时间较长的对象 新生代回收
新生代堆被分为两个相等大小的区域:From空间和To空间
- 新对象分配到From空间
- 当From空间满时,触发垃圾回收
- 从根对象开始,标记所有存活的对象
- 将存活的对象复制到To空间中
- 清除已经死亡的对象
- 将To空间作为新的From空间,并将From空间作为新的To空间,完成垃圾回收
下面我使用JS实现一下新生代回收的过程
javascript
// 新生代回收机制
class GenerationalCollection {
// 定义堆的From空间和To空间
fromSpace = new Set();
toSpace = new Set();
garbageCollect(obj) {
this.mark(obj); // 标记阶段
this.sweep(); // 清除阶段
// 切换From和To的空间
const { to, from } = this.exchangeSet(this.fromSpace, this.toSpace);
this.fromSpace = from;
this.toSpace = to;
return this;
}
isObj = (obj) => typeof obj === "object";
exchangeSet(from, to) {
from.forEach((it) => {
to.add(it);
from.delete(it);
});
return { from, to };
}
allocate(obj) {
this.fromSpace.add(obj);
}
mark(obj) {
if (!this.isObj(obj) || obj?.marked) return;
obj.marked = true;
this.isObj(obj) &&
Reflect.ownKeys(obj).forEach((key) => this.mark(obj[key]));
}
sweep() {
const { fromSpace, toSpace } = this;
fromSpace.forEach((it) => {
if (it.marked) {
// 将标记对象放到To空间
toSpace.add(it);
}
// 从From空间中移除该对象
fromSpace.delete(it);
});
}
}
// 全局对象
const globalVar = {
obj1: { name: "Object 1" },
obj2: { name: "Object 2" },
obj3: { name: "Object 3" }
}
const GC = new GenerationalCollection()
// 创建对象并分配到From空间
GC.allocate(globalVar.obj1)
GC.allocate(globalVar.obj2)
console.log(GC.fromSpace, GC.toSpace);
// 执行垃圾回收
GC.garbageCollect(globalVar)
console.log(GC.fromSpace, GC.toSpace);
简单描述一下上面的代码,allocate函数将对象放到From堆空间中,mark函数对对象及属性添加标记,在sweep清除函数中如果对象既被标记又在From空间中那么就将其复制到To空间中,最后在垃圾回收机制函数garbageCollect中对调两个堆空间最终完成整个周期
五、垃圾收集机制的一些优化建议
- 分代收集(Generational collection)------ 对象被分成两组:"新的"和"旧的"。在典型的代码中,许多对象的生命周期都很短:它们出现、完成它们的工作并很快死去,因此在这种情况下跟踪新对象并将其从内存中清除是有意义的。那些长期存活的对象会变得"老旧",并且被检查的频次也会降低。
- 增量收集(Incremental collection)------ 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。因此,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的。这需要它们之间有额外的标记来追踪变化,但是这样会带来许多微小的延迟而不是一个大的延迟。
- 闲时收集(Idle-time collection)------ 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。