咱们直接开门见山,如何回收,不说为什么有垃圾回收,垃圾怎么造成的,什么情况算是垃圾(可达性),这些大家可以自行搜索
常见的两种,也是面试官知道的,标记清除法 ,引用计数法
ok, 我们来看标记清除法
标记清除是JavaScript引擎中进行垃圾回收中使用到最多的算法,在目前主流的浏览器厂商中几乎都是可以看到标记清除算法,只不过不同浏览器厂商优化不同,而且不同的浏览器上运行的性能也有差异。
而此算法主要核心分为两部分标记和清除。
标记清除重点
代码执行阶段,程序中所有的变量会被添加上一个二进制字符(二进制运算快)并初始值置为0( <math xmlns="http://www.w3.org/1998/Math/MathML"> 0 默认全为垃圾 \color{red}{0默认全为垃圾} </math>0默认全为垃圾),然后遍历所有的对象,被使用的变量标记置为1,在程序运行结束时回收掉所有标记为零的变量,回收结束之后将现存变量标记统一置为0,等待下一轮回收开启。
优点:标记清除算法思路清晰,实现比较简单。
缺点:由于系统分配的内存时间不同,回收的先后顺序也是不同的,这时就会导致剩余空闲空间并不是连续的,出现了内存碎片现象。
(先说下分配,声明变量或者函数时,需要给到他们一定的空间,内存需要分配)
内存碎片化之后,新的分配空间被分配时需要先计算一下满足符合要求的空间,增加了计算负担。同时如果后续系统需要分配的新变量使用空间很大,虽然系统总剩余内存是满足需求,但是并没有连续的满足需求的空间进行分配,这时可能会出现分配失败。
所以虽然标记清除算法比较简单但是缺点也是很明显
由于内存碎片的诞生导致的分配时间较长和空间浪费,所以只要解决掉内存碎片这个致命问题,这两个问题就会迎刃而解。
这时 标记整理 Mark-Compact算法出现了,他的清除逻辑和标记清除算法基本相似不过进行了优化,会在清除结束之后将活着的空间进行整理向一端移动,同时清理掉内存的边界。 这样就处理了,碎片化不连续以及总和空间足够但是都是碎片化问题
引用计数重点
引用计数就是跟踪记录每个变量值被使用的次数,
如果一个引用类型的值给一个声明的变量赋值,则将这个引用类型的值的引用次数为1,如果同一个值被又被赋值给另一个变量,则引用计数再加1,如果之前被赋值的变量值变更成了其他引用类型,则原本的引用类型引用计数减1,如果这个引用类型的引用计数为0时表示,此时为不可达状态,浏览器垃圾回收器就将此类型占用的空间进行回收掉 <math xmlns="http://www.w3.org/1998/Math/MathML"> (此处是实时的,当计数变为 0 既会被立即回收) \color{#4dd0e1}{(此处是实时的,当计数变为0既会被立即回收)} </math>(此处是实时的,当计数变为0既会被立即回收)
优点
1、实时回收,引用计数当归零就立即进行回收操作。
2、不会暂停执行栈,标记清除算法定时进行垃圾回收时会先暂停程序运行,来进行垃圾回收,而引用计数是实时回收不会暂停程序的运行
缺点
1、空间浪费,由于需要进行计数,所以需要开辟空间来存储计数器,同时由于引用无上限故占用空间也是无上限。
2、无法解决循环引用无法回收(致命问题),循环引用既两个引用类型AB,A有一个地址指向了B,B也有一个对象指向了A,导致两者引用技术为2,正常情况下当test函数运行结束进行垃圾回收,但是AB两者的基数都是不是0则回收失败,无法清除,这种情况大量发生时会造成大量的内存空间被浪费,故引用计数算法现在已经很少使用逐渐被标记清除算法替代
js
function test() {
let A = new Object()
let B = new Object()
B.a = A
A.b = B
}
这是上面两个垃圾回收方法,后来,浏览器v8更新了回收方案,因为垃圾太多,占用空间大,会让消费者逐渐感觉到产品问题,所以,后台google团队接着优化了回收机制
之前GC的清除算法无论是标记清除还是标记整理,在进行回收时都需要检查内存中的所有对象,但是如果存在一些,体积大,存活时间长,创建早的内存来进行检查,相当于是做了无用功,而新创建,体积小和存活时间短的对象需要更加频繁的检查所以基于这个问题,V8提出了新生代和老生代的优化策略。将内存空间划分为新生代和老生代两个部分,不同部分执行不同的回收策略。
为了解决标记-清除的"全堆暂停"和"内存碎片"问题,现代引擎采用了更复杂的新生代和老生代:
分代式优化
新生代
简单来说就是新产生的对象,通常只支持 1~8M 的容量,存活时间较短。
新生代中的内存被拆分为两部分,使用区 和空闲区 ,浏览器进行内存申请时分配使用区 空间,当使用区空间快被写满时则进行一次垃圾回收,新生代的垃圾回收器会对使用区的活动对象进行标记,标记完成之后将使用区活跃的对象复制到空闲区 ,并进行排序,随后进入垃圾清理阶段,对使用区进行清理,清理操作完成之后,使用区和空闲区进行角色互换,之前的空闲区变成新的使用区,之前的使用区变成新的空闲区,循环往复。
如果一个对象被多次复制还未被清理掉,故此对象会被认定为生命周期较长的对象,会被从新生代移动到老生代中,采用老生代的垃圾回收机制管理。
注意点:新生代复制一个对象到空闲区,此时如果空闲区的使用空间超过25%之后这个对象会被立即复制到老生代,而25%的红线要求是为了保证进行空闲区和使用区翻转时对于新的对象分配空间操作不会被影响。
老生代
相比于新生代,老生代放的就是一些生命周期比较长,经过多次新生代垃圾回收还存在的对象,
同样的相比于新生代不仅垃圾回收频率较低,存储空间也是比新生代大的多。而老生代的回收算法就比较简单就是标记清除算法,不过在v8中为了处理标记清除算法产生的内存碎片问题,使用了标记整理算法进行空间优化大大提高了回收效率。
并行回收
众所周知JavaScript是一门单线程语言,所以在进行GC回收时会阻塞js脚本的运行导致系统停顿,等GC回收结束后恢复运行,这被称为全停顿。
但是这样的话会存在极大的风险,如果GC回收时间较长,就会导致系统停顿时间较长这是不可被接受的。所以V8引擎加入了并行回收的优化机制,在开启GC回收线程之后,会同时开启多个辅助线程进行处理,提高回收处理时间,虽然增加了一部分线程之间协调的时间,但是总时间比一个线程用时来讲大大的缩短。避免系统卡顿时间过长。 (增加多个线程同时回收,减少线程执行时间)
增量标记
由于全停顿标记策略在处理老生代垃圾 回收时即使是有并行处理优化但是消耗时间也会消耗大量的时间 ,所以在2011年时V8团队又提出了*增量标记策略来进行优化。
增量标记是将一次GC标记过程进行拆分,一次执行一小部分,执行完毕后继续执行脚本,执行一段脚本之后又继续执行刚刚拆分的GC标记任务,循环往复直至这次GC标记完成。 (类似react fiber原理,拆成很多小任务来执行)
三色标记法(恢复与暂停)
在三色标记法之前,GC标记只是将活动的变量标记为黑色,不活动的变量标记为白色,当GC标记过程结束之后,系统会回收掉所有的白色标记变量,但是这种非黑即白的方法虽然清除起来非常方便,但是存在一个问题是执行一段时间之后无法知道执行到了哪里,不能进行暂停。
所以V8又引入了一个灰色进行暂停和恢复操作

如图,在GC标记开始时所有对象都是白色的,然后从根对象开始进行标记,先将这组对象标记为灰色,然后进行记录,如果此时进行中断,后续恢复时既从灰色标记时开始即可,当回收器从标记工作表中弹出对象并访问他们的引用对象时,会将灰色置为黑色,同时将下一个引用对象置为灰色,继续往下进行标记工作。直至无可标记为灰色对象为止,此时表示GC标记过程结束,将所有未标记的变量进行回收工作。所以三色标记法可以渐进执行而不用每次执行都要全盘进行扫描整个内存空间,可以配合增量标记回收减少全停顿时间,提升体验。
处理异常 (写屏障)
在一次完成GC标记暂停中,如果执行任务程序时内存中存在的变量引用关系被改变了,这样会导致此次GC存在问题。所以V8团队提出了写屏障作为保护。
如图,现有A、B、C三个对象依次被引用,且在GC过程中已经被标记了,但是在暂停GC任务,插入执行程序任务之后,引用关系被改变了,新增了一个新变量D,但是此时程序中也未存在灰色标记的变量,下一步进行清除机制时,新变量D按清除机制来讲是要被清除掉,但是这是极其不合理的,一个新的变量还存在引用就被回收掉,这会导致程序云行报错。此时写屏障机制 就派上用场了,一旦有黑色的对象引用白色的对象,就会强制将被引用的白色变量标记为灰色,保证下一次的增量GC正确运行,这个机制称为强三色不变性(白色变量D被黑色变量B引用之后会被强制置灰保证程序运行正确性)。
惰性清理
在增量GC标记之后下一步就是来真正回收内存空间,通过惰性清理来进行清除释放内存。惰性清理机制运行原理是在进行回收时如果内存足够就可以将这个回收清理时间稍微延迟一下,让JavaScript脚本先执行,清理时也不会一下全部清理掉所有的垃圾,会根据按需进行清理直至所有垃圾都回收完毕,然后继续等待下个GC标记阶段执行结束。
并发回收
虽然增量标记和惰性清理的出现使主线程停顿时间大大减少了,但是总体的停顿时间其实并未减少,如果真正细算起来甚至还增加了,应用程序的吞吐量也被降低,不过用户和浏览器的交互体验大大提升牺牲也是值得的。但是后续V8团队为了使回收更加高效, 又使用了并发回收机制,他是在主线程在执行程序任务时,主动开启辅助线程进行GC回收。而主线程又可以自由执行而不会挂起(标记操作全部由辅助进程操作)。

总结: 浏览器垃圾回收的核心是可达性分析 ,主流算法是标记-清除 及其优化变种(标记-整理)。现代引擎通过分代回收 (新生代用 Scavenge,老生代用 Mark-Sweep/Compact)、增量回收 、并行回收 和并发回收等策略极大提高了效率并减少了停顿时间。但开发者仍需警惕常见的内存泄漏模式(全局变量、遗忘定时器/回调、脱离 DOM 引用、不当闭包),并善用开发者工具(如 Chrome DevTools Memory 面板)进行内存分析和问题定位。理解 GC 机制有助于编写更高效、内存更友好的 JavaScript 代码。
并行回收 和 并发回收
从技术角度看,并行回收是多线程同时工作,但需要主线程暂停配合;而并发回收是后台线程悄悄干活,主线程完全不受干扰。这就像搬家时------前者是全家一起打包但必须停止上班工作(并行),后者是请搬家公司在你上班时就把东西运走了(并发)。
核心区别在于:GC 线程与 JavaScript 主线程的工作关系。
-
并行回收 (Parallel Collection)
-
核心思想: 利用多核 CPU 的优势,在多个辅助线程上 同时 执行 GC 任务。
-
主线程状态: JavaScript 主线程 必须暂停执行(Stop-The-World)。 GC 工作期间,你的 JavaScript 代码是完全停止运行的。
-
工作方式:
- 当 GC 需要执行(例如,Minor GC 或 Major GC 开始)时,JavaScript 主线程首先被暂停。
- GC 控制器将 GC 任务(通常是标记阶段这个最耗时的部分)分割成多个子任务。
- 这些子任务被分发到多个辅助线程(Worker Threads) 上并行执行。
- 所有辅助线程完成各自的子任务后,GC 控制器汇总结果。
- 可能接着执行清除/整理/复制等阶段(这些阶段可能并行也可能在主线程进行)。
- 最后,主线程恢复执行 JavaScript。
-
目标: 减少单次 GC 事件的 总耗时。 通过并行处理,把原来一个线程需要做 100ms 的工作,分给 4 个线程做,可能只需要 25ms。虽然主线程还是暂停了,但暂停的时间显著缩短了。
-
类比: 想象一条繁忙的高速公路(主线程)需要维修一段路(GC)。并行回收就是:完全封闭这段高速公路(暂停主线程) ,但同时派出多支维修队(辅助线程) 在封闭的路段上同时开工维修不同的部分。维修总时间大大缩短,但道路是完全封闭的。
-
优点: 显著缩短 GC 暂停时间,实现相对简单(因为主线程暂停,内存对象图是静止的,没有并发修改问题)。
-
缺点: 主线程仍然会经历暂停(虽然时间短了),对于需要极低延迟的应用(如游戏、VR)可能还不够理想。
-
应用: V8 的老生代标记阶段通常采用并行标记。很多 GC 的某些阶段都会利用并行来加速。
-
-
并发回收 (Concurrent Collection)
-
核心思想: 允许 GC 工作(主要是在标记阶段)在 后台线程 上执行,而 JavaScript 主线程继续执行 应用代码。两者 同时进行。
-
主线程状态: JavaScript 主线程 不需要暂停,或者只需要非常短暂的暂停(通常只在开始和结束时)。 GC 的大部分工作是在后台悄悄进行的。
-
工作方式:
-
GC 启动(通常需要一个很短的初始暂停来设置)。
-
标记阶段的主要工作 被移到一个或多个后台线程上执行。
-
与此同时,JavaScript 主线程继续执行用户的代码。
-
这是最核心也是最难的部分!因为主线程在运行时会不断地创建新对象、修改现有对象的引用关系(删除引用、添加引用) 。这会导致后台 GC 线程正在标记的对象图状态时刻在变化!
-
为了解决这个"对象图动态变化"的问题,需要引入复杂的机制:
- 写屏障 (Write Barrier): 这是一个关键的编译/运行时机制。当 JavaScript 代码修改对象属性 (写入引用)时,写屏障代码会被触发。它的作用是记录下被修改的引用关系(例如,哪个对象哪个属性被覆盖了,或者新引用了哪个对象)。这些记录的信息会被提供给后台 GC 线程,让它知道哪些地方发生了变化,需要重新检查或更新标记状态。这是实现并发标记的核心保障。
- 三色标记法 (Tri-color Marking): 用白(未访问)、灰(已访问但引用的对象未完全检查)、黑(已访问且引用的对象也已检查)三种颜色来精确追踪标记进度和状态变更。结合写屏障,后台 GC 能正确处理主线程修改带来的影响。
-
最终,在清除阶段或整理阶段,通常还是需要一个短暂的最终暂停来完成最后的清理工作和交换角色等。
-
-