哈喽大家好,我是Fine,今天为大家分享一篇关于JS垃圾回收机制的一篇文章。
在面试中,垃圾回收机制也是常考题之一。比如我们在被问到闭包时,就很有可能引出垃圾回收机制的问题:
当然在实际工作中,JS垃圾回收机制也会有很多应用场景:
本篇文章将与大家分介绍一下JavaScript垃圾回收的重要性和定义,并深入探讨内存管理的概念、JS垃圾回收机制的分类,以及如何避免内存泄漏以及性能优化。
垃圾回收是JavaScript中内存管理的重要组成部分。开发人员不需要手动分配和释放内存。垃圾回收机制可以自动处理内存的分配和释放,减轻了开发人员的负担,并且降低了内存泄漏的风险,它的主要目的是自动地检测和释放不再使用的内存,以便程序能够更高效地利用系统资源。
它通过标记不再需要的对象,并回收它们所占用的内存空间,以便其他对象可以使用。
栈内存用于存储程序的函数调用,变量声明以及一些占用小的变量值,如布尔,部分整数等,它们的生命周期受到函数的调用和退出以及变量的作用域的控制。当函数被调用或者变量创建时,相关的变量和函数调用会被压入栈内存,如果函数退出或者变量作用域销毁,相关的变量和函数就会从栈内存中弹出。
堆内存的作用是存储变量值,如字符串,对象,数组及函数,它们的生命周期受到JavaScript垃圾回收机制的控制,当不再需要这些变量时,垃圾回收机制会将它们销毁。
简言之,堆用于存储动态分配的对象,而栈用于存储基本类型的值和对堆中对象的引用。
也就是说,在堆内存中才存在垃圾回收器这个概念,内存的分配和释放是由JavaScript引擎自动处理的,开发人员无需显式地分配或释放内存。JavaScript引擎使用垃圾回收机制来管理内存,确保不再使用的对象被自动回收,以便为新的对象腾出空间。
进入今天的正题,垃圾回收机制有三类,其中标记清除和引用计数是比较常见的机制,分代回收则是前二者的结合
标记清除法是JS最常见的垃圾回收机制之一。它的工作流程包括标记阶段和清除阶段。
我们写个类来模拟一下标记清除的操作
// 标记清除, 垃圾回收机制
class MarkGC {
marked = new Set(); // 模拟标记操作
run(obj) {
this.marked.clear(); // 这一步应该是放在最后的,但是看不出效果,所以改成运行前重置
this.mark(obj);
this.sweep(obj); // 这一步实际上没有效果,为了方便理解
return this;
}
// 判断对象或属性是否已经标记
checkMark = (obj) => typeof obj === "object" && !this.marked.has(obj);
mark(obj) {
const { marked } = this;
if (this.checkMark(obj)) {
marked.add(obj);
Reflect.ownKeys(obj).forEach((key) => this.mark(obj[key]));
}
}
sweep(obj) {
Reflect.ownKeys(obj).forEach((key) => {
const it = obj[key];
if (this.checkMark(it)) {
delete obj[key];
this.sweep(it);
}
});
}
}
// 全局对象
const globalVar = {
obj1: { name: "Object 1" },
obj2: { name: "Object 2" },
obj3: { name: "Object 3" }
}
const gc = new MarkGC()
gc.run(globalVar)// 执行垃圾回收
console.log(globalVar, gc.marked);
// 删除操作
delete globalVar.obj3
delete globalVar.obj2
// 对象删除后运行垃圾回收
gc.run(globalVar)
console.log(globalVar, gc.marked);
来理解一下上述代码,标记清除法主要分为mark操作和sweep操作,运行mark函数会将全局对象中的属性存入标记列表中,然后运行sweep函数对,没标记的对象清除
引用计数基于每个对象维护一个引用计数器,用于跟踪对象被引用的次数。当对象的引用计数变为零时,即没有任何引用指向它时,该对象被认为是不再被使用,可以进行回收。下面是该方法的基本原理
我们同样使用一段代码来简单模拟一下引用计数的操作
// 引用计数器
class RefCount {
constructor() {
this.count = 0;
}
increment() {
this.count++;
}
decrement() {
this.count--;
}
}
// 对象类
class MyObject {
constructor() {
this.refCount = new RefCount();
this.refCount.increment(); // 对象被创建时,引用计数加1
}
addReference() {
this.refCount.increment(); // 引用增加时,引用计数加1
}
releaseReference() {
this.refCount.decrement(); // 引用减少时,引用计数减1
if (this.refCount.count === 0) {
this.cleanup(); // 引用计数为0时,进行清理操作
}
}
cleanup() {
// 执行清理操作,释放资源
console.log("清理完成");
}
}
// 创建对象并建立引用关系
const obj1 = new MyObject();
// 建立引用关系
obj1.addReference();
console.log(obj1.refCount);
// 解除引用关系
obj1.releaseReference();
obj1.releaseReference();
console.log(obj1.refCount);
RefCount类是一个简单的计数器,使用MyObject类创建新的类,使用计数器的addReference函数增加引用数量,使用releaseReference解除引用关系,此时数量会减一,当引用数量减到0时会执行cleanup函数对资源进行释放,达到垃圾回收效果
分代回收是一种结合了标记清除和引用计数的垃圾回收机制,它会根据对象的生命周期将内存分为不同的代。
分代回收存在一个假设:大多数对象的生命周期都比较短暂,而只有少数对象具有较长的生命周期。基于这个假设,分代回收将对象的生命周期划分为两类:新生代(Young Generation)堆和老生代(Old Generation)堆。新生代堆用于存储大量的短期存活对象,而老生代堆则用于存储长期存活对象
关于两种分代回收的原理如下
老生代实际上就是上面说到的标记清除算法,这套算法适用于存活时间较长的对象
新生代堆被分为两个相等大小的区域:From空间和To空间
下面我使用JS实现一下新生代回收的过程
// 新生代回收机制
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中对调两个堆空间最终完成整个周期
内存泄漏是指在程序中分配的内存无法被正常释放和回收的情况,导致内存的持续占用和增长。
它与垃圾回收机制有密切关系。垃圾回收机制的目的是自动识别和回收不再使用的内存,以避免内存泄漏和资源浪费。然而,如果存在内存泄漏,即使对象已经不再使用,垃圾回收机制也无法正确识别这些对象为垃圾并释放它们的内存。这样,内存泄漏导致的内存占用会随着时间的推移逐渐增加,直到达到系统的内存限制。
常见的内存泄漏场景有下面几类
当对象仍然存在引用,即使不再需要时,垃圾回收机制也无法回收这些对象。例如,未正确解除事件监听器或定时器,导致被监听的对象一直被引用,无法释放内存。
场景:使用element.addEventListener却没有使用取消函数:removeEventListener;setInterval或setTimeout没有关闭
解决:使用removeEventListener,clearTimeout等函数重置
当两个或多个对象相互引用,并且这些对象之间没有与其他对象的引用关系时,即使这些对象不再被使用,垃圾回收机制也无法回收它们。这种情况下,对象之间形成了一个封闭的循环,导致内存泄漏。
场景:
const obj = {}
const obj1 = {}
obj.child = obj1
obj1.child = obj
解决:合理设计对象之间的引用关系,避免对象类型变量循环使用,使用弱引用或断开循环引用的方法来解决
全局变量在整个应用程序生命周期中都存在,如果没有正确管理和释放全局变量,会导致这些变量一直存在于内存中,无法被垃圾回收机制回收。
场景:全局创建变量,在程序或页面的生命周期并未对该变量重置或者清空,则会一直处于激活状态,不会被垃圾回收机制处理
解决:限制变量的作用域,避免过多的全局变量,TS中可以使用命名空间和模块的形式,也就是JS的函数或对象
例如打开的文件句柄、网络连接或数据库连接等资源,如果在使用完毕后没有正确释放,会导致内存泄漏。
场景:在网络请求时超时时间过长,请求一直等待可能会造成内存泄漏
解决:使用完操作后尽量手动断开或者设置超时,比如请求的abort函数和timeout属性,这一类现象类似于线程的死锁,无法得知何时取消,造成性能问题。
JavaScript垃圾回收机制是内存管理的关键,它能够自动检测和释放不再使用的内存,提高程序的性能和可靠性。了解垃圾回收的分类、内存泄漏的原因和避免方法,以及性能优化的最佳实践,有助于开发高效的JavaScript应用程序。
原文地址: https://juejin.cn/post/7264503343995912246
原文作者: 阿宇的编程之旅
本文来自掘金的分享
觉得本文有用的小伙伴,可以帮忙点个“在看”,让更多的朋友看到咱们的文章。