我们为什么需要了解
作为前端,和GC 强相关就是内存泄漏场景、调试工具、基础优化策略,这就是需要我们理解可达性、分代回收概念,以及熟练使用 DevTools 分析内存。
为什么需要 GC
GC 就是垃圾回收的缩写。下面统一使用 GC 来指代垃圾回收。
众所周知,应用程序在运行过程中需要占用一定的内存空间,且在运行过后就必须将不再用到的内存释放掉,否则就会出现下图中内存的占用持续升高的情况,一方面会影响程序的运行速度,另一方面严重的话则会导致整个程序的崩溃。
我们可以通过Dev Tool的Performance来直观的观察到内存的改变,在页面加载完毕之后,正常的应该如下,线条在没有控制界面之后会趋于直线。
如果真的存在内存的问题,我们可以通过点击
中间的圆圈键进行持续性的观察,得到下面的这张图,js heed持续性的上升。

GC是如何解决这些问题的
我们要了解 GC,就要了解 GC 作用的对象:内存。内存的管理和编程语言无关,基本都生命周期都是一样的:
- 分配程序需要的内存
- 使用分配到的内存
- 把不需要的内存给回收了
GC主要作用于第三点,识别不需要的内存,如何回收这些不需要的内存。
全停顿
在理解 GC 之前,还有一个重要的概念,"全停顿"。
GC 运行的时候,会阻塞 JS 主线程的运行。虽然不会影响到 Web Worker的运行,它运行在独立的子线程中,但是到运行结束,把数据返回到主线程的时候依旧会受到影响。
Web Worker有自己的 GC
全停顿是为了解决数据不一致的问题,以经典的饭店作为场景来描述。你还没有吃完饭,只是临时起来去上一个厕所,这个时候服务员看到座位上没有人了,就以为桌面上的饭已经没有人吃了,所以就收拾了,等你上完测试,面对空空如也的桌面,只能干瞪眼。 这个是因为服务员和你对于同一个场景的理解是不一致的,导致服务员做的事情不符合我们的预期。为了解决这个问题,就需要同一时间只有做一件事情。GC 运行的时候,JS 不要运行。
除了 GC 的时候回全停顿,在 DOM 渲染的时候(script 标签的加载),同步的 API(alert、confirm),DOM 的重排和重绘等,都会出发全停顿。
解决的目标(什么对象是垃圾)
GC的实现思路每个高级语言基本都是一样的:确定哪个变量不会再使用,然后释放它占用的内存。
什么样的变量是不会再被使用的,有如下情形:
- 对象不在被引用
- 对象不能从根上访问到
解决的方案(GC 算法)
GC 的算法是为了判断什么对象是垃圾的问题。常见的有如下四种:
- 引用计数
- 标记清楚
- 标记整理
- 分代回收
引用计数
属于 GC 算法一个过去的算法,现在不再使用。它的实现思路是,在声明变量的时候给它赋一个引用值,这个值的引用数为 1。如果这变量又赋值给了另外一个变量,那么它的引用数就加一,当引用数为 0 的时候,就可以安全的回收其内存。
但是在循环遍历的场景中就无能为力了,如下代码中:
js
function fn(a, b) {
a.pre = b.next
b = a
}
a和b相互引入,它们的引用数都是2。计数算法就无法判断对象的是否应该进行清除回收。而且当对象大了之后,需要给这个计数开辟的内存空间也是一个不小的花销。
标记清除(mark-and-sweep)
实现思路:分标记和清除两个阶段完成。
- 遍历所有对象找标记活动对象
- 遍历所有对象清除没有标记对象
- 回收相应的空间
快速回收内存。最大的优点可以回收循环引用的对象,是 V8 引擎使用得最多的算法。具体的标记方法对于学习语法规范的而言不重要,有很多方法,并且随着 v8 版本的更新一直都有在优化和更新。我们可以通过"活动"这个字眼可以看出来它的含义。比引用计数更加的大,但是它也不是没有缺点,如下图:

红色区域是一个根对象,就是一个全局变量,会被标记;而蓝色区域就是没有被标记的对象,会被回收机制回收。这时就会出现一个问题,表面上蓝色区域被回收了三个空间,但是这三个空间是不连续的,当我们有一个需要三个空间的对象,那么我们刚刚被回收的空间是不能被分配的,这就是"空间碎片化"。
标记整理
为了解决内存碎片,就引入了"标记整理"这一算法。就是在回收垃圾之前添加一个标记整理的操作。如下图:

基本实现思路为,活动的对象放在内存的一边,非活动的对象字段就在另外一遍,这样直接清除另外一边的非活动对象就可以有效解决内存碎片的问题。如下图:
但是这样又会带来新的问题:移动对象,不会立即回收对象,降低了回收的效率。
垃圾回收策略
增量标记(incremental Marking)
为了减少全停顿的时间,V8
对标记进行了优化,将一次停顿进行的标记过程,分成了很多小步。每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成标记。
分代回收
上面属于回收的算法,而分代回收属于回收的策略。主要思路是。将内存分为新生代和老生代,分别采用不同的算法,最大限度的优化垃圾回收的过程。
(1)新生代:对象的存活时间较短。新生对象或只经过一次垃圾回收的对象。
(2)老生代:对象存活时间较长。经历过一次或多次垃圾回收的对象。
两者空间的主要差别分配到到的内存是不一致的。
类型 | 64位 | 32位 |
---|---|---|
老生代 | 1400MB | 700MB |
新生代 | 64MB | 16MB |
可以明显看他们的差距是非常大的,这也直接导致了两者空间使用到的 GC 算法的侧重点不同。
回收新生代对象
主要使用标记整理算法和Scavenge算法,Scavenge算法主要是将新生代中开辟为From和To两个空间,内存对半分。如下图:
新生代中的内存回收发生在 From 空间中,过程如下:
- GC 一遍之后,检查在From空间,清空不活动的内存
- 活动的对象判断是否符合晋升的机制,符合就复制到老生代空间中,其余复制到 To空间中
- From被清空之后,To空间作为From空间,原本的From就作为To。即 From和 To空间的翻转。
上面提到的From晋升到老生代空间的条件有如下两个:
- 已经经过一轮 GC 了
- To中的内存空间使用量超过 25%
设计的原因分别如下:
- 新生代假设"大多数对象生命周期短暂",因此经历两次回收仍存活的对象更可能长期存在,适合转移到老生代进行更高效的管理
- Scavenge 回收完成后,To 空间会变为下一次分配的 From 空间。若 To 空间占用过高(如超过 25%),剩余空间不足以容纳后续新对象分配,会导致频繁触发垃圾回收或内存分配失败。通过直接晋升对象,可避免这一问题
总结整个过程如下图:
性能和 GC 的关系
垃圾回收机制是我们应用层上需要关注的语法规范,但是ES6 的 WeakMap 和 WeakSet这类的语法规范也定义了内存管理的逻辑约束,没有修改 GC 的实现逻辑。
GC 的过程属于一个近似且不完美的方案,因为某块内存是否还有用,属于"不可判定的"问题,意味着靠算法是不能完全解决的。
这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。
这就是为什么WEB 项目中在有了 GC 情况下,依旧会出现了内存泄露的问题。
内存泄露
当内存持续的增长,对象和变量没有被及时的垃圾回收就会造成内存泄露。
我们一般会说造成内存泄露的有如下六种情况:
1. 全局变量滥用
问题:
js
function leak() {
globalVar = "未声明的全局变量"; // 隐式全局变量
this.anotherGlobal = "this 指向全局对象"; // 在非严格模式下,this 指向 window
}
Vue3 默认或推荐启用严格模式,这个问题大复减弱。但是依旧存在一些全局的应用,比如全局使用的缓存。
js
windows.cache = new Map()
全局的变量可以统一使用WeakMap
进行处理。
2. 闭包未及时清理
现代项目中,模块化的开发,这个问题基本不存在。但是仍然存在使用定时器实现的闭包依旧存在可能的问题:
js
export function useTimer() {
const data = ref('yoran')
setInterval(() => console.log(data.value). 10000)
return { data }
}
3. 未清理的事件监听器与第三方库资源
-
DOM 事件监听器
即使使用 Vue 模板语法(如
@click
),若手动绑定事件 (如通过addEventListener
)且未在onUnmounted
中解绑,仍会泄漏 -
第三方库实例未销毁
如 ECharts、WebSocket 或地图 SDK,需在组件卸载时手动调用
dispose()
或关闭连接
4. 未清除的定时器与异步任务
不用多说
5. 残留的 DOM 的引用
手动操作 DOM 并缓存引用时,即使元素被移出 DOM 树,JavaScript 对象的强引用仍会阻止其回收:
javascript
const elements = [];
elements.push(document.getElementById('my-element')) // 强引用导致无法回收
静态分配
为了压榨浏览器,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
GC 虽然是自动周期性的执行,但是开发者也是有一些操作可以控制这周期的到来。
浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。 ,如下面的代码片段:
js
const obj = []
for (let i = 0; i < 10000; i++) {
const item = { count: i }
obj.push(item)
}
在循环中会频繁的使用创建和注销item对象,当 JS 运行时存在频繁的创建和注销的对象的时候,JS 引擎就会自动的调用 GC ,频参照[[全停顿]]的改变,频繁的调用 GC 会阻塞 JS 的运行,对性能造成明显的影响。
为了解决这个问题,我们可以先创建一个确定长度的对象,然后修改这个对象数组上面的元素。
js
const obj = Array.from({ length: 10000 }, () => ({
count: null
}))
for (let i = 0; i < 10000; i++) {
obj[i].count = i
}
这样就可以避免频繁的创建销毁变量,直接减少 GC 的调用,达到性能优化的效果。
还有一个常见的场景:
js
const fn = (a, b) => {
const obj = {}
obj.x = a.x + b.x
obj.y = a.y + b.y
return obj
}
这个代码片段和《红宝书》4.3.4例子相似,如果这个函数是被频繁的调用的话,也会影响 JS 对象的的更替速度。我们可以修改如下:
js
const fn = (a, b, obj) => {
obj.x = a.x + b.y
obj.y = a.y + b.y
return obj
}
const a = { x: 1, y: 2 }
const b = { x: 3, y: 4 }
const c = fn(a, b, {})
隐藏类和删除操作
代码非常注重的性能的话,才需要考虑隐藏类和删除操作。一般来说,如果两个变量都使用同一个构造函数来new的话,它们就拥有同一个隐藏类,因为他们共用同一个原型。如下代码片段:
js
function Persion(height = 0) {
this.height = height
}
const a = new Person()
const b = new Person()
a和b有同一个隐藏类。但是当a.name = 'yoran'
添加一个Persion不存在的name的时候,浏览器就会给a和b创建两个不同的隐藏类。即使他们都是有同一个构造函数。
同理的,如果对生成的对象进行删除对象元素的操作也是如此。
js
function Persion(height = 0) {
this.height = height
}
const a = new Person()
const b = new Person()
delect a.height
为了极致的性能的话,最佳实践如下:
js
function Persion(height = 0, name) {
this.height = height
this.name = name
}
const a = new Person('yoran')
const b = new Person()
a.height = null
- 构造函数的上下文变量一次性声明所有可能需要的属性,避免"先创建再补充"
- 对于需要的属性,定义将它置于
null
总结
- 内存管理的生命周期部分程序语言,都是分配内存 -> 使用内存 -> 释放内存。
- GC 执行的时候,会阻塞 JS 主线程的运行;此时不会影响 Web Worker 的运行, 它有自己独立的子线程。
- 现代 GC 的主要使用到两种算法:标记清除算法和标记整理清除算法。执行的算法策略有两种:增量标记算法和分代回收算法。
- 新生代空间和老生代空间由于分配到的内存的不同,直接影响算法的使用策略。
- GC 性能优化的关键在于减少 GC 的频繁调用。