你真的了解JS垃圾回收机制吗?
本文最后更新于:1 个月前
目录
前言
垃圾回收是JavaScript中内存管理的重要组成部分。开发人员不需要手动分配和释放内存。垃圾回收机制可以自动处理内存的分配和释放,减轻了开发人员的负担,并且降低了内存泄漏的风险,它的主要目的是自动地检测和释放不再使用的内存,以便程序能够更高效地利用系统资源。
它通过标记不再需要的对象,并回收它们所占用的内存空间,以便其他对象可以使用。
本篇文章将与大家分享,介绍一下JavaScript垃圾回收的重要性和定义,并深入探讨内存管理的概念、JS垃圾回收机制的分类,以及如何避免内存泄漏以及性能优化。
堆栈内存管理
在之前的文章中,我针对堆与栈的概念做了初步的介绍,引用文章中的一句话:
栈内存用于存储程序的函数调用,变量声明以及一些占用小的变量值,如布尔,部分整数等,它们的生命周期受到函数的调用和退出以及变量的作用域的控制。当函数被调用或者变量创建时,相关的变量和函数调用会被压入栈内存,如果函数退出或者变量作用域销毁,相关的变量和函数就会从栈内存中弹出。
堆内存的作用是存储变量值,如字符串,对象,数组及函数,它们的生命周期受到JavaScript垃圾回收机制的控制,当不再需要这些变量时,垃圾回收机制会将它们销毁。
简言之,堆用于存储动态分配的对象,而栈用于存储基本类型的值和对堆中对象的引用。
也就是说,在堆内存中才存在垃圾回收器这个概念,内存的分配和释放是由JavaScript引擎自动处理的,开发人员无需显式地分配或释放内存。JavaScript引擎使用垃圾回收机制来管理内存,确保不再使用的对象被自动回收,以便为新的对象腾出空间。
JS垃圾回收机制
进入今天的正题,垃圾回收机制有三类,其中标记清除和引用计数是比较常见的机制,分代回收则是前二者的结合
标记清除(Mark and Sweep)
标记清除法是JS最常见的垃圾回收机制之一。它的工作流程包括标记阶段和清除阶段。
标记阶段
- 从根对象开始,例如全局对象(window)或函数的作用域链
- 遍历对象的属性和引用,将可访问的对象标记为被引用的对象
- 递归遍历活动对象的属性和引用,标记其他可访问的对象
清除阶段
- 遍历堆中的所有对象。
- 对于未被标记为活动的对象,将其标记为垃圾对象。
- 释放垃圾对象所占用的内存空间。
- 将已经被清除的对象从内存中删除。
我们写个类来模拟一下标记清除的操作
// 标记清除, 垃圾回收机制
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函数对,没标记的对象清除
标记清除的特点
优点
- 内存回收全面:标记清除算法能够回收不再被引用的所有对象,包括循环引用的对象。通过标记阶段和清除阶段的组合,能够有效地释放内存空间
- 灵活性:标记清除算法与编程语言的具体实现无关,适用于多种编程语言和环境。它可以在运行时动态地进行垃圾回收,根据对象的实际引用情况进行操作
- 可预测性:标记清除算法的执行时间是可控的。垃圾回收操作可以在合适的时机进行,避免了出现大量的内存分配和释放操作,从而提高了程序的响应性能
缺点
- 暂停时间:标记清除算法需要在垃圾回收时停止程序的执行,进行标记和清除操作。这可能导致程序的暂停时间较长,影响了程序的实时性和响应性能
- 空间效率:标记清除算法在执行清除操作时,需要对整个堆进行遍历,查找并清除未标记的对象。这可能导致在垃圾回收期间出现较大的内存占用,从而降低了内存的利用效率
- 碎片化问题:标记清除算法在清除对象后会产生内存碎片,即一些小而不连续的内存空间。这可能会导致后续的内存分配操作出现困难,增加内存分配的时间和复杂度
引用计数(Reference Counting)
引用计数基于每个对象维护一个引用计数器,用于跟踪对象被引用的次数。当对象的引用计数变为零时,即没有任何引用指向它时,该对象被认为是不再被使用,可以进行回收。下面是该方法的基本原理
引用计数器的维护
- 每个对象都有一个引用计数器,初始值为 0。
- 当对象被引用时,引用计数器增加。
- 当对象的引用被取消或销毁时,引用计数器减少。
引用计数的跟踪
- 当一个对象被其他对象引用时,引用计数增加。
- 当一个对象引用的其他对象被取消或销毁时,引用计数减少。
垃圾回收的触发
- 在程序执行过程中,当垃圾回收器被触发时,它会遍历堆中的所有对象。
- 对于每个对象,检查其引用计数器的值。
- 如果引用计数器为零,说明该对象不再被引用,可以被回收。
回收对象
- 当一个对象被回收时,其占用的内存空间会被释放。
- 同时,该对象引用的其他对象的引用计数也会相应减少。
- 如果其他对象的引用计数也变为零,这些对象也会被回收,整个过程递归进行。
我们同样使用一段代码来简单模拟一下引用计数的操作
// 引用计数器
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函数对资源进行释放,达到垃圾回收效果
引用计数的特点
优点
- 实时性:引用计数算法能够实时地检测到对象的不再被引用状态,并立即回收这些对象。一旦对象的引用计数变为零,即可立即进行回收,释放对象所占用的内存空间
- 简单高效:引用计数算法的实现相对简单,每个对象都维护一个引用计数器,通过增加和减少计数器的值来追踪对象的引用关系,这使得引用计数算法在实现上比较高效
- 处理循环引用:引用计数算法通常能够处理循环引用的情况,即当两个或多个对象互相引用时,只要它们的引用计数都变为零,垃圾回收器就能够回收这些对象
缺点
- 循环引用问题:引用计数算法无法处理循环引用的情况。当存在循环引用时,即使这些对象不再被程序使用,它们的引用计数也不会变为零,从而导致内存泄漏
- 额外开销:引用计数算法需要维护每个对象的引用计数器,这会带来额外的内存开销。每次对象的引用发生变化时,都需要更新计数器的值,这会增加运行时的开销
- 更新的性能开销:当对象的引用发生频繁变化时,如大量的增加和减少引用,引用计数的频繁更新可能会影响程序的性能
分代回收(Generational Collection)
分代回收是一种结合了标记清除和引用计数的垃圾回收机制,它会根据对象的生命周期将内存分为不同的代。
分代回收存在一个假设:大多数对象的生命周期都比较短暂,而只有少数对象具有较长的生命周期。基于这个假设,分代回收将对象的生命周期划分为两类:新生代(Young Generation)堆和老生代(Old Generation)堆。新生代堆用于存储大量的短期存活对象,而老生代堆则用于存储长期存活对象
关于两种分代回收的原理如下
老生代回收
老生代实际上就是上面说到的标记清除算法,这套算法适用于存活时间较长的对象
新生代回收
新生代堆被分为两个相等大小的区域:From空间和To空间
- 新对象分配到From空间
- 当From空间满时,触发垃圾回收
- 从根对象开始,标记所有存活的对象
- 将存活的对象复制到To空间中
- 清除已经死亡的对象
- 将To空间作为新的From空间,并将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应用程序。
以上就是文章的全部内容了,感谢你看到了这里,希望你从中获益,如果觉得文章不错的话,还希望三连支持一下博主,非常感谢!
相关代码
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!