Skip to content

垃圾回收与内存泄漏

作者:青见春山
发表于:2026-07-29
字数统计:7500 字
预计阅读26分钟

涵盖栈与堆、引用计数/标记清除、V8 分代式 GC、增量标记、四种内存泄漏场景与排查。

一、内存结构:栈与堆

1. 栈(Stack)

JavaScript
// 栈是 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 在堆上的内容,等待 GC

2. 堆(Heap)

堆是存放引用类型数据(对象、数组、函数)的地方。栈上只存引用(指针),数据本体在堆上:

JavaScript
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)

JavaScript
// 早期算法(IE 老版本使用过),现在主流 JS 引擎基本不用
// 原理:每个对象维护引用计数,当计数为 0 时被回收

let obj = { name: '张三' }
// obj 引用对象 → 计数 = 1

let obj2 = obj
// obj2 也引用 → 计数 = 2

obj = null
// 解除引用 → 计数 = 1

obj2 = null
// 解除引用 → 计数 = 0 → 对象被回收

致命缺陷:循环引用

JavaScript
function circular() {
  const a = {}
  const b = {}
  a.b = b
  b.a = a
  // a 和 b 互相引用,计数永远 ≥ 1,无法被回收
}

circular()
// 在严格模式下,函数返回后应该被回收,但由于循环引用,泄漏了

2. 标记清除(Mark-and-Sweep)

现代 JS 引擎使用。原理:

  1. 根对象(window / global)出发
  2. 递归遍历所有可达对象,标记为"存活"
  3. 清除所有未被标记的对象
JavaScript
// 例如以下代码
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 算法

JavaScript
// 新对象先分配到 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 用三色标记 + 写屏障:

JavaScript
// 三色标记法(Tri-color Marking):
// 白色:未被访问(最终会被回收)
// 灰色:自身被访问,但子节点未访问
// 黑色:自身和子节点都已访问

// 1. 初始:所有对象白色
// 2. 从根开始遍历,把直接可达置为灰色
// 3. 从灰色对象取一个置为黑色,遍历其子节点置为灰色
// 4. 重复 3,直到没有灰色对象
// 5. 剩余白色对象就是不可达的,回收掉

写屏障(Write Barrier):用户代码可能在 GC 进行时修改对象引用,把本应黑色的对象重新指向一个白色对象。V8 在写入时记录这种"跨代引用",避免漏标。

4. 增量标记(Incremental Marking)

完整 Mark-Sweep 需要暂停主线程(STW),但 V8 不能让主线程停太久。V8 把标记工作拆成多次:

JavaScript
// 原本 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 示例图

Plain
V8 堆内存(示意图)

┌─────────────────────────────────────────┐
│            Old Space(老生代)           │
│  长期存活的对象、闭包、长生命周期的模块    │
│                                         │
│  ┌──────────────────────────────┐       │
│  │  Pointer Space(老指针区)     │       │
│  │  - 包含指向 Old Space 的指针   │       │
│  │  - GC 需要扫描                │       │
│  └──────────────────────────────┘       │
│  ┌──────────────────────────────┐       │
│  │  Data Space(老数据区)        │       │
│  │  - 只包含数据                 │       │
│  └──────────────────────────────┘       │
└─────────────────────────────────────────┘

┌────────────────────────┐
│  New Space(新生代)   │
│  ┌──────┐  ┌──────┐   │
│  │ From │  │ To   │   │  ← Scavenge 在这两个区间复制
│  └──────┘  └──────┘   │
│  存活 N 次晋升到老生代        │
└────────────────────────┘

四、内存泄漏(Memory Leak)

定义:程序中已经不需要的对象,由于某种原因无法被 GC 回收,导致内存占用持续增长,最终可能导致页面卡顿或崩溃。

1. 常见内存泄漏场景

场景 1:意外的全局变量

JavaScript
// ❌ 没有用 var/let/const → 隐式全局变量
function fn() {
  name = '张三'  // 不写关键字,相当于 window.name = '张三'
}

fn()
// name 挂载到 window,永远不被回收
JavaScript
// ✅ 始终用 let/const
function fn() {
  const name = '张三'  // 局部变量,函数结束就被回收
}

场景 2:被遗忘的定时器

JavaScript
// ❌ 定时器持有 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

JavaScript
// ❌ 闭包无意中持有 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 引用

JavaScript
// ❌
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:未清理的事件监听

JavaScript
// ❌
window.addEventListener('resize', () => {
  // ...
})

// 页面卸载/组件销毁时
// ✅
const handler = () => { /* ... */ }
window.addEventListener('resize', handler)
// 组件销毁:
window.removeEventListener('resize', handler)

场景 6:Map / Set / WeakMap 误用

JavaScript
// ❌ 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 面板

JavaScript
// 1. Performance Monitor:观察 JS heap size 实时变化
// 2. Memory 面板 → Heap snapshot:
//    - 多次 snapshot,对比 Delta
//    - 看 Retained Size 大的对象
// 3. Memory 面板 → Allocation instrumentation on timeline:
//    - 录制一段时间,看哪些函数频繁分配内存
// 4. Performance 面板:录制交互,看 GC 频率(频繁 GC 是泄漏信号)

代码层排查

JavaScript
// 检查 window 上的属性,避免意外的全局变量
window.hasOwnProperty('name')  // 检查是否存在

// 监测堆大小(浏览器)
if (performance.memory) {
  console.log({
    usedJSHeapSize: performance.memory.usedJSHeapSize,
    totalJSHeapSize: performance.memory.totalJSHeapSize,
    jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
  })
}

3. 内存泄漏排查实战

JavaScript
// 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. 私有变量

JavaScript
// 利用 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. 关联元数据但不阻止回收

JavaScript
// 一个 DOM 节点关联一些临时数据
const meta = new WeakMap()

meta.set(domNode, { visits: 0, lastUpdate: Date.now() })
// 当 domNode 被移除时,对应 entry 也被回收

六、面试高频问答

Q1: 介绍一下 JS 的垃圾回收机制

:JS 引擎(V8)使用分代式 GC + 标记清除(Mark-Sweep)+ 增量标记 + 并发标记的组合策略。具体而言:

  1. 分代:堆分新生代(New Space)和老生代(Old Space)。新生代用 Scavenge 算法(复制式),老生代用 Mark-Sweep + Mark-Compact。
  2. 标记清除:从根对象出发遍历所有可达对象并标记,未标记的对象就是垃圾。
  3. 增量标记:把标记工作拆成多次小段执行,避免长时间 STW。
  4. 并发标记:GC 在后台线程执行,主线程只在写屏障处短暂阻塞。

Q2: V8 为什么把堆分代?

:基于分代假设(大多数对象生命周期短)。新生代中的对象"朝生暮死",适合用 Scavenge(复制存活对象到另一块区域,释放原区域);老生代中对象存活率高,用 Mark-Sweep 更合适。这种分代策略在多数应用下都能高效地管理内存。

Q3: 标记清除如何处理循环引用?

:标记清除是基于可达性(是否从根对象出发可达),而不是引用计数。即使两个对象互相引用,它们如果从根不可达(没有任何外部引用链能到达它们),就会在 GC 时被清除。这正是它相对于引用计数的最大优势。

Q4: 常见的内存泄漏场景有哪些?

:常见的有五种:

  1. 意外的全局变量(漏写 var/let/const)
  2. 被遗忘的定时器(setInterval 未清理,且闭包持有大对象)
  3. 闭包引用大对象(闭包无意中持有 DOM 或大数据)
  4. 游离的 DOM 引用(节点从 DOM 移除但 JS 对象仍持有)
  5. 未清理的事件监听(addEventListener 后未 removeEventListener)

排查工具:Chrome DevTools → Memory → Heap snapshot / Allocation timeline。

七、关联文档