垃圾回收与内存泄漏
涵盖栈与堆、引用计数/标记清除、V8 分代式 GC、增量标记、四种内存泄漏场景与排查。
一、内存结构:栈与堆
1. 栈(Stack)
// 栈是 JS 主线程执行代码的地方,调用栈用于函数调用
function foo() {
const a = 1 // 基本类型 a 存在栈上
const b = 'hello' // 基本类型 b 存在栈上
return bar(a, b) // 传参:参数也是栈上的拷贝
}
function bar(x, y) {
const obj = { x, y } // 引用类型 obj 存在堆上,但 obj 的引用(指针)存在栈上
return obj
}
foo()
// 调用栈:foo() → bar(a, b) → ...
// foo 执行完毕,栈帧被销毁
// bar 返回 obj(在堆上)的引用
// obj 在堆上的内容,等待 GC2. 堆(Heap)
堆是存放引用类型数据(对象、数组、函数)的地方。栈上只存引用(指针),数据本体在堆上:
const a = { name: '张三' }
const b = a
a.name = '李四'
console.log(b.name) // '李四',a 和 b 指向同一对象
// 内存图:
// 栈:a → 0x001 | 0x002 ← b
// 堆:0x001 { name: '李四', ... }3. 关键区别
| 维度 | 栈 | 堆 |
|---|---|---|
| 存储内容 | 基本类型值 + 引用(指针) | 引用类型(对象/数组/函数) |
| 大小 | 较小(受限于 OS 线程栈大小) | 较大(受限于进程内存) |
| 分配速度 | 极快(指针移动) | 较慢(需要查找空闲内存) |
| 回收方式 | 随栈帧弹出立即销毁 | GC 周期性回收 |
| 数据结构 | 后进先出(LIFO) | 动态分配 |
二、垃圾回收(GC)的两种基本算法
1. 引用计数(Reference Counting)
// 早期算法(IE 老版本使用过),现在主流 JS 引擎基本不用
// 原理:每个对象维护引用计数,当计数为 0 时被回收
let obj = { name: '张三' }
// obj 引用对象 → 计数 = 1
let obj2 = obj
// obj2 也引用 → 计数 = 2
obj = null
// 解除引用 → 计数 = 1
obj2 = null
// 解除引用 → 计数 = 0 → 对象被回收致命缺陷:循环引用
function circular() {
const a = {}
const b = {}
a.b = b
b.a = a
// a 和 b 互相引用,计数永远 ≥ 1,无法被回收
}
circular()
// 在严格模式下,函数返回后应该被回收,但由于循环引用,泄漏了2. 标记清除(Mark-and-Sweep)
现代 JS 引擎使用。原理:
- 从根对象(window / global)出发
- 递归遍历所有可达对象,标记为"存活"
- 清除所有未被标记的对象
// 例如以下代码
function foo() {
const obj = { name: '张三' } // obj 在函数栈内可达
return obj
}
const x = foo() // x 是 window 的属性,所以 x → obj 仍可达
// 即使 foo() 执行完,obj 也不会被回收
const y = foo() // y 持有另一个对象
// 如果 y 在后续代码中被 null / 重新赋值,obj 就变成不可达,下次 GC 被回收优势:
- 能正确处理循环引用(循环引用的对象如果从根不可达,仍会被回收)
- 简单可靠,是主流算法
3. 两种算法对比
| 维度 | 引用计数 | 标记清除 |
|---|---|---|
| 循环引用 | ❌ 泄漏 | ✅ 正确处理 |
| 实时性 | ✅ 立即回收 | ❌ 周期性执行 |
| STW(stop-the-world) | 几乎无 | 需要暂停主线程 |
| 使用情况 | 几乎不用 | 主流(V8、JVM 等) |
三、V8 引擎的垃圾回收策略
V8 采用分代式(Generational)GC + 增量标记 + 并发标记的组合策略。
1. 分代式假设(Generational Hypothesis)
大多数对象的生命周期很短,少数对象的生命周期很长。V8 据此把堆分为:
| 代 | 区域 | 特点 | GC 算法 |
|---|---|---|---|
| 新生代 | From / To(各约 16 MB) | 新创建的对象、短命对象 | Scavenge(复制算法) |
| 老生代 | Old Space(几十 ~ 几百 MB) | 存活过 GC 的对象、长命对象 | Mark-Sweep + Mark-Compact |
2. 新生代:Scavenge 算法
// 新对象先分配到 From 区(也称 Eden)
// 当 From 区满时,触发 Minor GC(也称 Scavenge)
// Scavenge 过程:
// 1. 检查 From 中所有对象
// 2. 存活的对象复制到 To 区
// 3. From 和 To 角色对换
// 4. 释放整个 From 区域
// 一轮 Scavenge 后还存活的对象,就晋升(Promote)到老生代为什么用复制算法:新生代对象大多"朝生暮死",所以复制存活对象的成本很低;释放整个区域比逐个标记清除快得多。
3. 老生代:Mark-Sweep + Mark-Compact
老生代空间大,对象存活率高,不再适合 Scavenge 那种"全量复制",所以 V8 用三色标记 + 写屏障:
// 三色标记法(Tri-color Marking):
// 白色:未被访问(最终会被回收)
// 灰色:自身被访问,但子节点未访问
// 黑色:自身和子节点都已访问
// 1. 初始:所有对象白色
// 2. 从根开始遍历,把直接可达置为灰色
// 3. 从灰色对象取一个置为黑色,遍历其子节点置为灰色
// 4. 重复 3,直到没有灰色对象
// 5. 剩余白色对象就是不可达的,回收掉写屏障(Write Barrier):用户代码可能在 GC 进行时修改对象引用,把本应黑色的对象重新指向一个白色对象。V8 在写入时记录这种"跨代引用",避免漏标。
4. 增量标记(Incremental Marking)
完整 Mark-Sweep 需要暂停主线程(STW),但 V8 不能让主线程停太久。V8 把标记工作拆成多次:
// 原本 1 秒的 STW 拆成 5 次 200ms
// 主线程和 GC 线程交替执行
// 用户代码依然流畅关键点:增量标记需要写屏障来保证正确性。
5. 并发标记 / 并发清理(Concurrent Marking / Sweeping)
GC 的大部分工作(标记、清理)可以在后台线程执行,不阻塞主线程。当前主流的 V8 源码中,标记和清理已经是并发为主,主线程只在起始 / 终止 / 写屏障处短暂暂停。
6. 触发时机
| 触发条件 | 行为 |
|---|---|
| From 区满 | Minor GC(Scavenge) |
| 老生代接近容量上限 | Major GC(Mark-Sweep) |
| 内存页碎片化严重 | Major GC(Mark-Compact) |
7. V8 GC 示例图
V8 堆内存(示意图)
┌─────────────────────────────────────────┐
│ Old Space(老生代) │
│ 长期存活的对象、闭包、长生命周期的模块 │
│ │
│ ┌──────────────────────────────┐ │
│ │ Pointer Space(老指针区) │ │
│ │ - 包含指向 Old Space 的指针 │ │
│ │ - GC 需要扫描 │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Data Space(老数据区) │ │
│ │ - 只包含数据 │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────┘
┌────────────────────────┐
│ New Space(新生代) │
│ ┌──────┐ ┌──────┐ │
│ │ From │ │ To │ │ ← Scavenge 在这两个区间复制
│ └──────┘ └──────┘ │
│ 存活 N 次晋升到老生代 │
└────────────────────────┘四、内存泄漏(Memory Leak)
定义:程序中已经不需要的对象,由于某种原因无法被 GC 回收,导致内存占用持续增长,最终可能导致页面卡顿或崩溃。
1. 常见内存泄漏场景
场景 1:意外的全局变量
// ❌ 没有用 var/let/const → 隐式全局变量
function fn() {
name = '张三' // 不写关键字,相当于 window.name = '张三'
}
fn()
// name 挂载到 window,永远不被回收// ✅ 始终用 let/const
function fn() {
const name = '张三' // 局部变量,函数结束就被回收
}场景 2:被遗忘的定时器
// ❌ 定时器持有 DOM 引用
const map = new Map()
const intervalId = setInterval(() => {
const node = document.querySelector('.box')
if (node) {
map.set(node, { count: map.get(node)?.count + 1 || 1 })
// map 持有 DOM 引用,DOM 即使被移除,也回收不了
}
}, 1000)
// 在某个时机组件销毁时,记得清理
// ✅
clearInterval(intervalId)
map.clear()场景 3:闭包引用了 DOM
// ❌ 闭包无意中持有 DOM
function bindClick() {
const button = document.getElementById('btn')
const hugeData = new Array(1000000).fill('*') // 1MB 数据
button.onclick = function() {
console.log(hugeData.length) // 闭包引用 hugeData
}
// 即使 button 被移除,hugeData 也释放不掉
}
bindClick()
// ✅ 解绑事件 + 不在闭包里持有大对象
function bindClick() {
const button = document.getElementById('btn')
let length = 0 // 只持有需要的值
button.onclick = function() {
console.log(length)
}
// 清理时解绑
return () => {
button.onclick = null
length = 0
}
}场景 4:游离的 DOM 引用
// ❌
const elements = {
btn: document.getElementById('btn')
}
document.body.removeChild(document.getElementById('btn'))
// DOM 节点从 DOM 树移除,但 elements.btn 仍持有引用 → 泄漏
// ✅
document.body.removeChild(elements.btn)
delete elements.btn // 解除引用场景 5:未清理的事件监听
// ❌
window.addEventListener('resize', () => {
// ...
})
// 页面卸载/组件销毁时
// ✅
const handler = () => { /* ... */ }
window.addEventListener('resize', handler)
// 组件销毁:
window.removeEventListener('resize', handler)场景 6:Map / Set / WeakMap 误用
// ❌ Map / Set 对 key 是强引用
const map = new Map()
const dom = document.getElementById('box')
map.set(dom, 'some meta')
dom.remove()
// map 仍持有 dom → dom 不被回收
// ✅ WeakMap / WeakSet 对 key 是弱引用
const weakMap = new WeakMap()
weakMap.set(dom, 'some meta')
dom.remove()
// 下次 GC 自动回收2. 内存泄漏排查工具
Chrome DevTools Memory 面板
// 1. Performance Monitor:观察 JS heap size 实时变化
// 2. Memory 面板 → Heap snapshot:
// - 多次 snapshot,对比 Delta
// - 看 Retained Size 大的对象
// 3. Memory 面板 → Allocation instrumentation on timeline:
// - 录制一段时间,看哪些函数频繁分配内存
// 4. Performance 面板:录制交互,看 GC 频率(频繁 GC 是泄漏信号)代码层排查
// 检查 window 上的属性,避免意外的全局变量
window.hasOwnProperty('name') // 检查是否存在
// 监测堆大小(浏览器)
if (performance.memory) {
console.log({
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
})
}3. 内存泄漏排查实战
// 1. 打开 Chrome DevTools → Memory 面板
// 2. 点击 "Take heap snapshot"(快照1)
// 3. 执行可疑操作(如点击某按钮 3 次)
// 4. 再 Take snapshot(快照2)
// 5. 切换到 "Comparison" 视图
// 6. 关注 Size Delta 和 #Delta,正值且持续增长的就是泄漏对象
// 7. 点击进入查看 Retainers 路径,定位是哪个引用持有的
// Allocation instrumentation on timeline 用法:
// 1. Memory → Allocation instrumentation on timeline → Start
// 2. 执行操作
// 3. Stop
// 4. 在蓝色柱状图(new allocations)上找频繁出现的五、WeakMap / WeakSet 的应用
1. 私有变量
// 利用 WeakMap 实现对象的私有属性
const privateData = new WeakMap()
class User {
constructor(name, password) {
this.name = name // 公开
privateData.set(this, { password }) // 私有
}
getPassword() {
return privateData.get(this).password
}
}
const user = new User('king', '123')
console.log(user.name) // 'king'
console.log(user.password) // undefined(外部无法访问)
// user 被 GC 时,privateData 里的 entry 也自动清除2. 关联元数据但不阻止回收
// 一个 DOM 节点关联一些临时数据
const meta = new WeakMap()
meta.set(domNode, { visits: 0, lastUpdate: Date.now() })
// 当 domNode 被移除时,对应 entry 也被回收六、面试高频问答
Q1: 介绍一下 JS 的垃圾回收机制
答:JS 引擎(V8)使用分代式 GC + 标记清除(Mark-Sweep)+ 增量标记 + 并发标记的组合策略。具体而言:
- 分代:堆分新生代(New Space)和老生代(Old Space)。新生代用 Scavenge 算法(复制式),老生代用 Mark-Sweep + Mark-Compact。
- 标记清除:从根对象出发遍历所有可达对象并标记,未标记的对象就是垃圾。
- 增量标记:把标记工作拆成多次小段执行,避免长时间 STW。
- 并发标记:GC 在后台线程执行,主线程只在写屏障处短暂阻塞。
Q2: V8 为什么把堆分代?
答:基于分代假设(大多数对象生命周期短)。新生代中的对象"朝生暮死",适合用 Scavenge(复制存活对象到另一块区域,释放原区域);老生代中对象存活率高,用 Mark-Sweep 更合适。这种分代策略在多数应用下都能高效地管理内存。
Q3: 标记清除如何处理循环引用?
答:标记清除是基于可达性(是否从根对象出发可达),而不是引用计数。即使两个对象互相引用,它们如果从根不可达(没有任何外部引用链能到达它们),就会在 GC 时被清除。这正是它相对于引用计数的最大优势。
Q4: 常见的内存泄漏场景有哪些?
答:常见的有五种:
- 意外的全局变量(漏写 var/let/const)
- 被遗忘的定时器(setInterval 未清理,且闭包持有大对象)
- 闭包引用大对象(闭包无意中持有 DOM 或大数据)
- 游离的 DOM 引用(节点从 DOM 移除但 JS 对象仍持有)
- 未清理的事件监听(addEventListener 后未 removeEventListener)
排查工具:Chrome DevTools → Memory → Heap snapshot / Allocation timeline。