目录
前言[1]
堆栈内存管理[2]
JS 垃圾回收机制[3]
标记清除(Mark and Sweep)[4]
标记阶段[5]
清除阶段[6]
标记清除的特点[7]
优点[8]
缺点[9]
引用计数(Reference Counting)[10]
引用计数器的维护[11]
引用计数的跟踪[12]
垃圾回收的触发[13]
回收对象[14]
引用计数的特点[15]
优点[16]
缺点[17]
分代回收(Generational Collection)[18]
老生代回收[19]
新生代回收[20]
分代回收的特点[21]
优点[22]
缺点[23]
内存泄漏[24]
内存泄漏的场景[25]
无用的对象引用[26]
循环引用[27]
全局变量的滥用[28]
未释放的资源[29]
总结[30]
相关代码[31]
前言
垃圾回收是 JavaScript 中内存管理的重要组成部分。开发人员不需要手动分配和释放内存。垃圾回收机制可以自动处理内存的分配和释放,减轻了开发人员的负担,并且降低了内存泄漏的风险,它的主要目的是自动地检测和释放不再使用的内存,以便程序能够更高效地利用系统资源。
它通过标记不再需要的对象,并回收它们所占用的内存空间,以便其他对象可以使用。
本篇文章将与大家分享,介绍一下 JavaScript 垃圾回收的重要性和定义,并深入探讨内存管理的概念、JS 垃圾回收机制的分类,以及如何避免内存泄漏以及性能优化。
堆栈内存管理
在之前的文章[32]中,我针对堆与栈的概念做了初步的介绍,引用文章中的一句话:
栈内存用于存储程序的函数调用,变量声明以及一些占用小的变量值,如布尔,部分整数等,它们的生命周期受到函数的调用和退出以及变量的作用域的控制。当函数被调用或者变量创建时,相关的变量和函数调用会被压入栈内存,如果函数退出或者变量作用域销毁,相关的变量和函数就会从栈内存中弹出。
堆内存的作用是存储变量值,如字符串,对象,数组及函数,它们的生命周期受到 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 应用程序。
以上就是文章的全部内容了,感谢你看到了这里,希望你从中获益,如果觉得文章不错的话,还希望三连支持一下博主,非常感谢!
相关代码
myCode: 基于 js 的一些小案例或者项目 – Gitee.com[33]
参考资料
[2]堆栈内存管理: #%E5%A0%86%E6%A0%88%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86
[3]JS垃圾回收机制: #JS%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E6%9C%BA%E5%88%B6
[4]标记清除(Mark and Sweep): #%E6%A0%87%E8%AE%B0%E6%B8%85%E9%99%A4%EF%BC%88Mark%20and%20Sweep%EF%BC%89
[5]标记阶段: #%E6%A0%87%E8%AE%B0%E9%98%B6%E6%AE%B5
[6]清除阶段: #%E6%B8%85%E9%99%A4%E9%98%B6%E6%AE%B5
[7]标记清除的特点: #%E6%A0%87%E8%AE%B0%E6%B8%85%E9%99%A4%E7%9A%84%E7%89%B9%E7%82%B9
[8]优点: #%E4%BC%98%E7%82%B9
[9]缺点: #%E7%BC%BA%E7%82%B9
[10]引用计数(Reference Counting): #%E5%BC%95%E7%94%A8%E8%AE%A1%E6%95%B0%EF%BC%88Reference%20Counting%EF%BC%89
[11]引用计数器的维护: #%E5%BC%95%E7%94%A8%E8%AE%A1%E6%95%B0%E5%99%A8%E7%9A%84%E7%BB%B4%E6%8A%A4
[12]引用计数的跟踪: #%E5%BC%95%E7%94%A8%E8%AE%A1%E6%95%B0%E7%9A%84%E8%B7%9F%E8%B8%AA
[13]垃圾回收的触发: #%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%9A%84%E8%A7%A6%E5%8F%91
[14]回收对象: #%E5%9B%9E%E6%94%B6%E5%AF%B9%E8%B1%A1
[15]引用计数的特点: #%E5%BC%95%E7%94%A8%E8%AE%A1%E6%95%B0%E7%9A%84%E7%89%B9%E7%82%B9
[16]优点: #%E4%BC%98%E7%82%B9
[17]缺点: #%E7%BC%BA%E7%82%B9
[18]分代回收(Generational Collection): #%E5%88%86%E4%BB%A3%E5%9B%9E%E6%94%B6%EF%BC%88Generational%20Collection%EF%BC%89
[19]老生代回收: #%E8%80%81%E7%94%9F%E4%BB%A3%E5%9B%9E%E6%94%B6
[20]新生代回收: #%E6%96%B0%E7%94%9F%E4%BB%A3%E5%9B%9E%E6%94%B6
[21]分代回收的特点: #%E5%88%86%E4%BB%A3%E5%9B%9E%E6%94%B6%E7%9A%84%E7%89%B9%E7%82%B9
[22]优点: #%E4%BC%98%E7%82%B9
[23]缺点: #%E7%BC%BA%E7%82%B9
[24]内存泄漏: #%E5%86%85%E5%AD%98%E6%B3%84%E6%BC%8F
[25]内存泄漏的场景: #%E5%86%85%E5%AD%98%E6%B3%84%E6%BC%8F%E7%9A%84%E5%9C%BA%E6%99%AF
[26]无用的对象引用: #%E6%97%A0%E7%94%A8%E7%9A%84%E5%AF%B9%E8%B1%A1%E5%BC%95%E7%94%A8
[27]循环引用: #%E5%BE%AA%E7%8E%AF%E5%BC%95%E7%94%A8
[28]全局变量的滥用: #%E5%85%A8%E5%B1%80%E5%8F%98%E9%87%8F%E7%9A%84%E6%BB%A5%E7%94%A8
[29]未释放的资源: #%E6%9C%AA%E9%87%8A%E6%94%BE%E7%9A%84%E8%B5%84%E6%BA%90
[30]总结: #%E6%80%BB%E7%BB%93
[31]相关代码: #%E7%9B%B8%E5%85%B3%E4%BB%A3%E7%A0%81
[32]之前的文章: https://hunter1024.blog.csdn.net/article/details/128819344
[33]myCode: 基于js的一些小案例或者项目 – Gitee.com: https://gitee.com/DieHunter/myCode/tree/master/%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E6%9C%BA%E5%88%B6
原创文章,作者:guozi,如若转载,请注明出处:https://www.sudun.com/ask/88323.html