文章

V8垃圾回收机制深度解析

V8垃圾回收机制深度解析

一句话概括

V8 的垃圾回收(GC)机制通过”分代回收”策略将堆内存划分为新生代(New Space)和老生代(Old Space),新生代使用 Scavenge 算法快速回收短命对象,老生代使用标记-清除-压缩(Mark-Sweep-Compact)算法处理长命对象,配合并发标记、增量标记等优化策略将 GC 停顿时间控制在毫秒级。

背景与意义

为什么需要垃圾回收

C 和 C++ 中,开发者必须手动调用 malloc/free 管理内存。一个常见的 bug:

1
2
3
4
char* buffer = malloc(1024);
// ... 使用 buffer ...
// 忘写了 free(buffer) → 内存泄漏!
// 或者写了但指针已经被覆盖 → double free → crash!

JavaScript 选择了垃圾回收——让开发者专注于业务逻辑,由运行时自动追踪和回收不再使用的内存。这极大降低了开发负担,但也带来了一些问题:

GC 停顿(Stop-The-World):当垃圾回收器工作时,所有 JavaScript 代码的执行都被暂停。对于应用启动或大范围内存分配时,这个停顿可能达到 100-300ms——足够让用户感知到”卡顿”。

2012 年,一款流行的单页应用在初始加载时需要分配超过 50MB 的 JavaScript 对象。在当时的 V8 版本中,一次完全的老生代 GC 耗时超过 700ms——页面整整 0.7 秒无法响应鼠标点击。这推动了 V8 团队开发增量标记和并发 GC。

到了 2026 年,V8 的 Orinoco 项目已将所有 GC 阶段(标记、清除、压缩)都实现了并发执行。单个 100ms 的中断被替换为多个 1-3ms 的微停顿。用户几乎感觉不到 GC 的存在。

JavaScript 内存的生命周期

1
2
3
4
5
6
分配 → 使用 → 释放(GC 自动回收)
  |        |        |
  |        |        +— 引用计数/可达性分析 → 不再可达时回收
  |        +— 强引用/弱引用影响回收时机
  +— V8 在栈上分配所有值,遇到堆对象时分配
      在堆中

概念与定义

V8 堆内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
V8 堆内存 (受 V8 --max-old-space-size 控制,默认约 1.4GB x64)
  |
  +-- 新生代 (New Space) — 1~16MB (可分两块)
  |    半空间 From  |  半空间 To
  |    (活动对象)     (空闲区域)
  |
  +-- 老生代 (Old Space) — 主占用的堆内存
  |    指针区域 (Map, 属性, 元素)
  |    数据区域 (字符串, 盒装数字, 双精度数组)
  |
  +-- 大对象空间 (Large Object Space) — >1MB 的对象
  |    不被移动,永不压缩
  |
  +-- 代码空间 (Code Space) — JIT 编译的代码
  |    内存页不可执行 (W^X 保护)
  |
  +-- 细胞空间 (Cell/PropertyCell/Map Space)
      元数据: Map、描述符、函数上下文

分代假说

V8 的 GC 策略基于两个经过实践验证的观察(Generational Hypothesis):

  1. 大多数对象”出生就死”:约 90-98% 的对象在分配后很快就不再使用了
  2. 存活得越久,越不容易死:经过几次 GC 还存活的对象,往往生命周期较长

基于这两个假说,V8 对新生代和老生代采用了截然不同的回收策略。

可达性(Reachability)

1
2
3
4
5
6
7
8
9
10
// V8 用"根"(Roots)作为可达性的起点
const roots = [
  globalThis,           // 全局对象
  document,             // DOM 引用
  currentFunction,      // 当前执行函数的局部变量
  // ... 还有其他隐式根
];

// 从根出发,通过引用遍历,所有可达的对象都是"活动对象"
// 不可达的 = "垃圾"

最小示例

用 Node.js 监控 V8 GC 事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// gc-observer.mjs — 监控 V8 的垃圾回收事件
import v8 from 'node:v8';

// 启用 GC 跟踪(需要在启动时加 --expose-gc 标志)
// node --expose-gc --experimental-modules gc-observer.mjs

// 方法 1: 获得堆统计
function logHeapStats(label) {
  const stats = v8.getHeapStatistics();
  
  console.log(`=== ${label} ===`);
  console.log(`  堆大小限制:  ${(stats.heap_size_limit / 1024 / 1024).toFixed(0)}MB`);
  console.log(`  已用堆大小:  ${(stats.used_heap_size / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  总计堆大小:  ${(stats.total_heap_size / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  物理内存:    ${(stats.total_physical_size / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  堆外内存:    ${(stats.malloced_memory / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  峰值内存:    ${(stats.peak_malloced_memory / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  可用堆大小:  ${(stats.total_available_size / 1024 / 1024).toFixed(1)}MB`);
  console.log();
}

// 方法 2: 启用 GC 性能事件
// 需要 Node.js >= 18
if (typeof gc !== 'undefined') {
  // 创建大量短期对象,观察新生代 GC
  function createShortLivedObjects(count = 100000) {
    const array = [];
    for (let i = 0; i < count; i++) {
      // 这些对象在函数返回后立即变成垃圾
      array.push({
        id: i,
        data: new Array(10).fill(Math.random()),
        timestamp: Date.now()
      });
    }
    return array; // 返回引用让它们继续存活
  }

  // 创建长期存活对象,观察晋升到老生代
  const cache = new Map();
  function createLongLivedObjects(count = 10000) {
    for (let i = 0; i < count; i++) {
      cache.set(i, {
        id: i,
        name: `object-${i}`,
        largeData: new Array(500).fill(i * Math.random())
      });
    }
  }

  logHeapStats('初始状态');

  // 触发短期对象的 GC 压力
  let temp = [];
  for (let r = 0; r < 5; r++) {
    temp.push(createShortLivedObjects(50000));
    gc(); // 强制 GC
    console.log(`第 ${r+1} 次创建短期对象后`);
  }
  
  logHeapStats('短期对象创建后');

  // 清除短期对象引用
  temp = null;
  gc();
  
  logHeapStats('短期对象清除后');

  // 创建长期对象
  createLongLivedObjects(5000);
  gc();
  
  logHeapStats('长期对象创建后');

  // 方法 3: 使用 performance.mark 测量 GC 耗时(实验性)
  if (performance.measureUserAgentSpecificMemory) {
    performance.measureUserAgentSpecificMemory().then(result => {
      console.log('应用内存使用:', 
        (result.bytes / 1024 / 1024).toFixed(1) + 'MB');
    });
  }
  
  // 打印 GC 事件(V8 内部跟踪)
  console.log('\nGC 日志: (需要 --trace-gc 标志)');
  // node --expose-gc --trace-gc gc-observer.mjs
} else {
  console.log('请使用 --expose-gc 标志重新运行');
  console.log('node --expose-gc --trace-gc gc-observer.mjs');
}

使用 Chrome DevTools 观察内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<!-- memory-demo.html — 在 Chrome 中观察 GC -->
<!DOCTYPE html>
<html>
<head>
  <style>
    button { padding: 10px; margin: 5px; cursor: pointer; }
    #log { font-family: monospace; font-size: 13px; height: 400px; overflow: auto; border: 1px solid #ccc; padding: 10px; }
  </style>
</head>
<body>
<button onclick="createLeak()">💣 创建内存泄漏</button>
<button onclick="createClean()">✅ 创建(可回收)</button>
<button onclick="forceGC()">🗑️ 强制 GC</button>
<button onclick="snapshot()">📸 内存快照</button>
<button onclick="clearLog()">清空日志</button>
<div id="log"></div>

<script>
const log = document.getElementById('log');
const leakData = []; // 全局引用 → 阻止 GC
const cleanData = new WeakMap(); // 弱引用 → 不阻止 GC

function logMsg(msg) {
  const div = document.createElement('div');
  div.textContent = `[${new Date().toISOString().slice(11,19)}] ${msg}`;
  log.appendChild(div);
}

// 泄漏版: 对象被全局数组引用,GC 无法回收
function createLeak() {
  logMsg('💣 创建 1000 个泄漏对象');
  for (let i = 0; i < 1000; i++) {
    leakData.push({
      id: i,
      name: `泄漏对象-${i}`,
      // 每个对象携带一个 1KB 的字符串
      payload: 'X'.repeat(1024),
      nested: {
        created: Date.now(),
        arr: new Array(100).fill(Math.random())
      }
    });
  }
  updateHeapInfo();
}

// 清洁版: 对象只被 DOM 元素引用,元素被移除后可 GC
function createClean() {
  logMsg('✅ 创建 1000 个可回收对象');
  for (let i = 0; i < 1000; i++) {
    const el = document.createElement('div');
    el.id = `clean-${i}-${Date.now()}`;
    el.style.display = 'none';
    
    // 使用 WeakMap 关联数据
    cleanData.set(el, {
      timestamp: Date.now(),
      largeArray: new Array(200).fill(i)
    });
    
    document.body.appendChild(el);
  }
  logMsg('  创建了 1000 个隐藏 div + WeakMap 数据');
  updateHeapInfo();
}

function forceGC() {
  logMsg('🗑️ 尝试触发 GC...');
  // 先移除隐藏的 div(让 WeakMap 的数据变为不可达)
  document.querySelectorAll('div[id^="clean-"]').forEach(el => el.remove());
  logMsg('  已移除隐藏 div,数据可被 GC 回收');
  
  // 强制 GC
  if (window.gc) {
    window.gc();
    logMsg('  GC 完成');
  } else {
    logMsg('  ⚠️ 在 Chrome 中使用 --enable-precise-memory-info 标志');
    logMsg('  或手动点击 DevTools → Performance → 🗑️');
  }
  updateHeapInfo();
}

function snapshot() {
  logMsg('📸 在 DevTools 中手动采集快照查看差异');
  logMsg('  Memory → Heap Snapshot → 对比两次快照');
}

function updateHeapInfo() {
  if (performance.memory) {
    const info = performance.memory;
    logMsg(`    已用 JS: ${(info.usedJSHeapSize / 1048576).toFixed(1)}MB`);
    logMsg(`    总计 JS: ${(info.totalJSHeapSize / 1048576).toFixed(1)}MB`);
    logMsg(`    堆限制:  ${(info.jsHeapSizeLimit / 1048576).toFixed(1)}MB`);
  }
}

function clearLog() { log.innerHTML = ''; }

logMsg('页面加载完成');
logMsg('打开 DevTools → Memory → 采集快照后测试');
</script>
</body>
</html>

核心知识点拆解

1. 新生代 GC:Scavenge 算法(Cheney 算法)

新生代使用 “半空间回收”(Semi-space Copying)策略,源于 C. J. Cheney 1970 年的论文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
初始状态:
  From Space (活动对象)    |  To Space (空闲)
  [ A ] [ B ] [ C ] [ ]   |  [ ] [ ] [ ] [ ]
       ↑分配合成           

触发 GC — 复制存活对象到 To Space:
  From Space               |  To Space (复制中)
  [ A ] [ B ] [ . ] [ ]   |  [ A ] [ B ] [ C ] 
       ↑ C 不可达,不复制   |       

GC 完成 — 交换角色:
  From Space (空闲)        |  To Space (活动对象)
  [ ] [ ] [ ] [ ]          |  [ A ] [ B ] [ C ]
                           |       ↑ 分配合成

这个过程也称为”Cheney 回收”或”停止-复制”回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 简化自 V8: src/heap/new-space.cc
class NewSpace {
 public:
  void Scavenge() {
    // 1. 扫描根引用的对象
    ScavengeVisitor visitor(this);
    root_visitor_.VisitRoots(&visitor);
    
    // 2. 遍历引用图中的所有可达对象
    while (!scavenge_queue_.IsEmpty()) {
      HeapObject obj = scavenge_queue_.Dequeue();
      
      // 3. 对于每个对象:
      //    - 从 From 空间复制到 To 空间
      //    - 更新引用(指向新位置)
      //    - 扫描它的子引用
      Object* from = obj->address();
      Object* to = AllocateInToSpace(obj->Size());
      
      // 复制数据
      MemCopy(to, from, obj->Size());
      
      // 在 From 空间中留下"转发指针"
      // 防止同一对象被多次复制
      *reinterpret_cast<Object**>(from) = to;
      
      // 入队子对象
      for (ObjectRef ref : obj->GetAllRefs()) {
        if (ref->IsInFromSpace()) {
          scavenge_queue_.Enqueue(ref);
        }
      }
    }
    
    // 4. 交换 From/To 空间
    SwapSpaces();
    
    // 5. 检查哪些对象存活超过 2 次 GC → 晋升到老生代
    PromoteSurvivors();
  }

 private:
  SemiSpace* from_space_;
  SemiSpace* to_space_;
  Queue<HeapObject> scavenge_queue_;
  int survived_count_ = 0;
  
  // 存活判定:对象是否在 From 空间中且没有转发指针
  bool IsAlive(HeapObject obj) {
    return obj->IsInToSpace();
  }
  
  // 晋升阈值:经过 2 次 Scavenge 仍然存活 → 老生代
  static constexpr int kPromotionThreshold = 2;
};

新生代 GC 的特点

  • 优点:只复制存活对象,不需要扫描整个堆
  • 优点:内存分配只需要移动指针(bump allocation)
  • 缺点:需要两倍空间(From + To)
  • 典型耗时:< 1ms(多数对象已死亡,只需复制少量存活对象)

2. 老生代回收:标记-清除-压缩

老生代的对象通常要么存活很久,要么相互引用复杂,不适合用复制算法(复制巨大内存块成本高)。

V8 使用 三色标记法 进行标记:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 三色标记
// 白色: 未访问到(可能为垃圾)
// 灰色: 已访问到但子对象未处理(中间状态)
// 黑色: 已访问且子对象已处理(存活)

初始状态:
[W] [W] [W] [W] [W] [W]  ← 所有对象初始为白色

开始标记 (从根出发):
1. 根对象 → 灰色
2. 处理灰色对象:
   - 灰色 → 黑色
   - 其子对象 → 灰色 (如果尚未访问)
3. 重复直到没有灰色

结果:
[B] [B] [W] [B] [W] [W]  ← 白色 = 垃圾
      ↑ 根   ↑            ← 存活 = 黑色

清除阶段:
删除所有白色对象

压缩阶段:
将黑色对象紧挨排列,消除内存碎片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 简化自 V8: src/heap/mark-compact.cc
class MarkCompactCollector {
 public:
  void CollectGarbage() {
    if (FLAG_incremental_marking) {
      // 增量标记 — 分步完成,每步 5-10ms
      IncrementalMarking();
    } else if (FLAG_concurrent_marking) {
      // 并发标记 — 在辅助线程上运行
      ConcurrentMarking();
    }
    
    // 标记完成后:
    // 1. 处理弱引用(WeakMap, WeakRef)
    ProcessWeakReferences();
    
    // 2. 清除 (Sweep)
    Sweep();
    
    // 3. 如果碎片率 > 25%,执行压缩 (Compact)
    if (fragmentation_ > 0.25) {
      EvacuatePages();
    }
  }

 private:
  // 三色标记的核心
  void MarkObject(HeapObject obj, MarkBit* mark_bit) {
    if (mark_bit->IsWhite()) {
      // 白色→灰色
      mark_bit->SetGrey();
      marking_worklist_.Push(obj);
    }
  }
  
  // 处理标记工作列表
  void ProcessMarkingWorklist() {
    while (!marking_worklist_.IsEmpty()) {
      HeapObject obj = marking_worklist_.Pop();
      
      // 灰色→黑色(处理所有子引用)
      MarkBit* bit = GetMarkBit(obj);
      bit->SetBlack();
      
      // 扫描所有子对象
      for (ObjectRef ref : obj->GetAllRefs()) {
        MarkObject(ref, GetMarkBit(ref));
      }
    }
  }
  
  // 压缩 — 内存整理
  void EvacuatePages() {
    // 将存活对象移动到新的页面
    // 减少内存碎片
    for (auto page : old_space_pages_) {
      if (page->ShouldEvacuate()) {
        for (auto object : page->GetLiveObjects()) {
          Address new_addr = AllocateInNewPage(object->Size());
          MemMove(new_addr, object->address(), object->Size());
          // 更新所有指向旧地址的引用
          UpdatePointers(object->address(), new_addr);
        }
        page->Release();
      }
    }
  }
};

3. 并发标记(Orinoco 项目)

V8 的 Orinoco 项目(始于 2016 年)的目标是将所有 GC 阶段移到后台线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
V8 GC 发展历程:

v4.x (Chrome 45-) — 全 Stop-The-World:
  [---GC 暂停 (100ms)---][JS 执行][---GC 暂停---]

v5.x (Chrome 53+) — 增量标记:
  [JS][4ms GC][JS][3ms GC][JS][4ms GC][JS]

v6.x (Chrome 64+) — 并发标记:
  [JS .........................]
    [后台标记线程 ────────]
         [并发清除]
         [并发压缩]

v8.x (Chrome 80+) — 并发+并行:
  [JS .........................]
    [并发标记 ──────────]
    [并发清除 ────]
    [并行压缩 ───]
    [并行整理]

并发标记的工作原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 简化自 V8 的并发标记实现
class ConcurrentMarking {
 public:
  void Start() {
    // 1. 在主线程上初始化"根"集合
    //    快照所有根引用(复制到后台线程安全使用)
    SnapshotRoots();
    
    // 2. 将标记任务分派到 Worker 线程
    const int num_workers = GetNumberOfWorkerThreads();
    for (int i = 0; i < num_workers; i++) {
      worker_threads_.emplace_back([this]() {
        // Worker 线程运行的标记循环
        ProcessMarkingWorklistParallel();
      });
    }
    
    // 3. 主线程继续执行 JS
    //    但有"写屏障"来记录 JS 执行期间的对象引用变化
  }
  
  // 写屏障 — 在 JS 修改对象引用时,通知 GC
  void WriteBarrier(HeapObject* object, ObjectRef new_reference) {
    if (IsGreyOrBlack(object) && IsWhite(new_reference)) {
      // 如果黑色对象引用了白色对象 → 灰色重新处理
      MarkBit::SetGrey(new_reference);
      concurrent_marking_worklist_.Push(new_reference);
    }
  }
  
  void ProcessMarkingWorklistParallel() {
    while (!concurrent_marking_worklist_.IsEmpty()) {
      HeapObject obj = concurrent_marking_worklist_.Pop();
      
      MarkBit* bit = GetMarkBit(obj);
      bit->SetBlack();
      
      for (ObjectRef ref : obj->GetAllRefs()) {
        if (GetMarkBit(ref)->IsWhite()) {
          MarkBit::SetGrey(ref);
          concurrent_marking_worklist_.Push(ref);
        }
      }
    }
  }
  
  // 标记结束 — 主线程短暂暂停,完成收尾
  void Finalize() {
    // 保存的根引用在工作线程中可能不是最新
    // 重新处理根引用 + 写屏障记录的变更
    ProcessRootsFromMainThread();
    ProcessWriteBarrierBuffer();
    
    // 此时所有可达对象都是黑色,白色就是垃圾
  }
};

4. 对象晋升策略

从新生代晋升到老生代的条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// V8 中对象晋升的三种情况:

// 情况 1: 存活超过 2 次 Scavenge
let survivedScavenge = { data: '存活了第一轮' };
// 下一次 Scavenge → 复制到 To 空间,存活次数+1
// 再下一次 Scavenge → 存活次数=2,晋升到老生代

// 情况 2: To 空间已使用超过 50%
// 新分配时发现 To 空间快满了 →
// 直接将对象晋升,不执行复制

// 情况 3: 大对象 (> 新生代空间的一半)
let largeObj = new Array(2000000).fill('x'); 
// 超过新生代容量,直接在老生代分配

// 晋升的影响:
const obj = { /* ... */ };

// 在新生代中:
// 分配: 指针递增 (1-2 纳秒)
// 回收: 复制存活对象(通常很少)

// 晋升到老生代后:
// 分配: 需要空闲链表查找 (稍慢)
// 回收: 全堆标记-清除 (更贵)
// 访问: 减少了 GC 频率,但每次 GC 更贵

实战案例

案例:生产环境内存泄漏排查

一个真实的 Node.js 服务端应用内存泄漏分析和修复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// memory-leak-demo.mjs — 模拟内存泄漏场景和排查
import http from 'node:http';

// ========== 场景 1: 闭包引用泄漏 ==========
function createLeakyHandler() {
  // 大对象被闭包捕获
  const largeData = new Array(100000).fill('泄漏数据');
  
  return function handler(req, res) {
    // handler 持有 largeData 的引用
    // 即使 largeData 在外部不再使用
    res.end(JSON.stringify({ ok: true }));
    // largeData 永远不会被 GC 回收!
  };
}

// 每次请求创建一个新的 handler,每个 handler 携带 800KB 的泄漏
const leakyHandlers = [];

http.createServer((req, res) => {
  if (req.url === '/leak') {
    // 每次请求产生一个泄漏
    const handler = createLeakyHandler();
    leakyHandlers.push(handler);  // 还被数组引用 → 死引用
    handler(req, res);
    return;
  }
  
  // 正常路径 — 没问题
  res.end('OK');
}).listen(3000);

// 修复方案:
function createCleanHandler() {
  // 1. 将 largeData 作为模块级别的常量(静态)
  // 2. 只创建一个 handler 并复用
  // 3. 或者使用 WeakRef(但不够可靠)
  const data = new Array(100000).fill('数据');
  
  return (req, res) => {
    // 使用 data
    res.end(data[0]);
  };
}

// ========== 场景 2: 定时器引用泄漏 ==========
class AnalyticsTracker {
  constructor() {
    this.queue = [];
    
    // ⚠️ 泄漏: 定时器捕获了 this 引用
    // 如果 AnalyticsTracker 实例被废弃
    // 定时器阻止整个实例被回收
    this.timer = setInterval(() => {
      this.flush();
    }, 10000);
  }
  
  start() { /* ... */ }
  flush() { /* ... 发送数据 */ }
  stop() {
    clearInterval(this.timer);  // ✅ 这行是关键
  }
}

// 使用泄漏检测
function detectLeak() {
  let interval = null;
  
  interval = setInterval(() => {
    // 创建一个大型闭包
    const hugeData = new Array(1000).fill('data');
    
    // 每次检查前先清除上一次的引用
    // 但如果不清除 → 内存持续增长
  }, 1000);
  
  // 修复: 清除定时器引用
  // clearInterval(interval);
  // hugeData 在每次回调执行完毕后即可回收(只要不在闭包链路上)
}

// ========== 场景 3: 事件监听器泄漏 ==========
class DataFetcher {
  constructor() {
    // 向全局对象注册监听器
    // 如果不主动 removeListener,实例永远不会被回收
    process.on('data', this.handleData);  // ⚠️ this.handleData 没 bind
    // 实际上是 process.on('data', this.handleData.bind(this));
    // 即使 DataFetcher 实例被置为 null,回调中的 this 仍然引用它
  }
  
  handleData(data) {
    this.lastData = data;
  }
  
  destroy() {
    process.removeListener('data', this.handleData);  // 必须主动清理
    // ⚠️ 注意: 如果这里用的是 this.handleData.bind(this)
    //        需要保存 bind 后的引用才能 removeListener
  }
}

生产环境 GC 监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// gc-monitor.mjs — 生产环境 GC 监控
import v8 from 'node:v8';
import { performance, PerformanceObserver } from 'node:perf_hooks';

class GCMonitor {
  constructor(options = {}) {
    this.warnThresholdMs = options.warnThresholdMs || 100;
    this.criticalThresholdMs = options.criticalThresholdMs || 500;
    this.sampleWindowMs = options.sampleWindowMs || 60000;
    
    this.gcEvents = [];
    this.lastAlert = 0;
    
    this.setupMonitoring();
  }

  setupMonitoring() {
    // Node.js >= 18: 使用 PerformanceObserver 监控 GC
    if (typeof PerformanceObserver !== 'undefined' && 
        PerformanceObserver.supportedEntryTypes?.includes?.('gc')) {
      
      const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (entry.entryType === 'gc') {
            this.onGCEvent(entry);
          }
        }
      });
      
      observer.observe({ entryTypes: ['gc'] });
    } else {
      // 备用: 使用 v8.getHeapStatistics 采样
      this.fallbackSampling();
    }
  }

  onGCEvent(entry) {
    const gcType = entry.kind;  // 1=Scavenge, 2=MarkSweep, 3=Incremental, 4=Full
    const durationMs = entry.duration;
    const now = Date.now();
    
    this.gcEvents.push({
      type: ['', 'Scavenge', 'MarkSweep', 'IncrementalMarking', 'Full'][gcType] || 'Unknown',
      duration: durationMs,
      timestamp: now,
      totalHeapBefore: (v8.getHeapStatistics().used_heap_size / 1024 / 1024).toFixed(1)
    });
    
    // 告警
    if (durationMs > this.criticalThresholdMs && 
        now - this.lastAlert > 30000) {
      this.lastAlert = now;
      
      console.warn(`[GC ALERT] 长GC事件: ${durationMs.toFixed(1)}ms`);
      console.warn(`  类型: ${this.gcEvents[this.gcEvents.length-1].type}`);
      console.warn(`  堆使用: ${(v8.getHeapStatistics().used_heap_size / 1024 / 1024).toFixed(1)}MB`);
    }
    
    // 周期性报告
    if (this.gcEvents.length % 10 === 0) {
      this.reportStats();
    }
  }

  reportStats() {
    const window = this.gcEvents.slice(-50); // 最近 50 个事件
    
    const totalTime = window.reduce((sum, e) => sum + e.duration, 0);
    const avgTime = totalTime / window.length;
    const maxTime = Math.max(...window.map(e => e.duration));
    const scavenges = window.filter(e => e.type === 'Scavenge').length;
    const fullGCs = window.filter(e => e.type === 'Full' || e.type === 'MarkSweep').length;
    
    console.log(`[GC Stats] 最近 ${window.length} 次 GC:`);
    console.log(`  平均 ${avgTime.toFixed(1)}ms, 最大 ${maxTime.toFixed(1)}ms`);
    console.log(`  Scavenge: ${scavenges}, FullGC: ${fullGCs}`);
    console.log(`  堆: ${(v8.getHeapStatistics().used_heap_size/1024/1024).toFixed(1)}/${(v8.getHeapStatistics().heap_size_limit/1024/1024).toFixed(0)}MB`);
  }

  fallbackSampling() {
    setInterval(() => {
      const stats = v8.getHeapStatistics();
      const usageRatio = stats.used_heap_size / stats.heap_size_limit;
      
      if (usageRatio > 0.85) {
        console.warn(`[GC Monitor] 堆使用率: ${(usageRatio * 100).toFixed(0)}% — 接近极限`);
      }
      
      // 检测内存泄漏趋势
      this.trackHeapTrend(stats.used_heap_size);
    }, this.sampleWindowMs);
  }

  trackHeapTrend(currentSize) {
    if (!this.lastHeapSize) {
      this.lastHeapSize = currentSize;
      return;
    }
    
    const growthRate = (currentSize - this.lastHeapSize) / this.sampleWindowMs; // 字节/ms
    
    if (growthRate > 1024) { // > 1MB/s
      console.warn(`[GC Monitor] 堆增长异常: ${(growthRate * 1000 / 1024 / 1024).toFixed(2)}MB/s`);
    }
    
    this.lastHeapSize = currentSize;
  }
}

// 使用
const monitor = new GCMonitor({
  warnThresholdMs: 100,
  criticalThresholdMs: 300
});

console.log('GC 监控已启动 (进程 ID:', process.pid, ')');

底层原理

V8 中的对象结构和指针压缩

V8 中的每个 JS 对象由 Map(隐藏类)和实际属性值组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
V8 对象 (64 位系统,启用指针压缩):
+---------------------------+
| Map 指针 (压缩为 32 位)    |  ← 指向 Map(对象的"形状"描述)
+---------------------------+
| 属性/元素指针 (32 位压缩)  |
+---------------------------+
| 命名属性 (内联/in-object)  |
+---------------------------+
| 元素 (索引属性)            |
+---------------------------+

指针压缩: 
- 所有指针存储为 32 位"偏移量"
- 基地址 = 堆基址(所有对象在同一个 4GB 区域内)
- 实际地址 = 基址 + 32 位偏移
- 节省 50% 的指针内存 (~15-20% 总内存节省)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 简化自 V8: src/heap/heap.h
// 指针压缩的实现

// 不使用压缩时:
// 指针 = 64 位 → 8 字节
// 对象的两个指针字段 = 16 字节

// 使用压缩时:
// 指针 = Base + TaggedIndex(32 位) → 4 字节
// 对象的两个指针字段 = 8 字节

// 基地址在 V8 隔离区初始化时确定
// 所有堆分配都在基址开始的 4GB 范围内

class TaggedPointer {
  uint32_t compressed_;
  
  Address Decode(Address base) {
    return base + static_cast<Address>(compressed_);
  }
  
  static TaggedPointer Encode(Address addr, Address base) {
    DCHECK(addr >= base && addr < base + kHeapLimit);
    return TaggedPointer{static_cast<uint32_t>(addr - base)};
  }
};

写屏障与增量标记的协作

并发标记期间,JS 主线程仍然在修改对象引用。写屏障(Write Barrier)保证了标记的完整性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 简化自 V8: src/heap/marking-barrier.cc
class MarkingBarrier {
 public:
  // 写屏障 — 每次 JS 代码修改对象引用时调用
  // 由编译器在每次对象赋值操作时自动插入
  static void Write(HeapObject host, ObjectSlot slot, Object value) {
    // 如果并发标记未运行,什么都不做
    if (!IsMarking()) return;
    
    // 关键规则: 如果 host 是黑色,且 value 是白色
    // 则 value 可能被"漏掉"(因为 host 的子对象已扫描过)
    // 必须将 value 标记为灰色
    
    MarkBit::MarkBitValue host_mark = GetMarkBit(host);
    if (!host_mark.IsBlack()) return;  // 灰色 → 不需要屏障
    
    MarkBit::MarkBitValue val_mark = GetMarkBit(value);
    if (val_mark.IsWhite()) {
      // 将白色→灰色,重新入队标记列表
      val_mark.SetGrey();
      concurrent_marking_worker_.Push(value);
    }
  }
};

// 写屏障的代价:
// 赋值操作的额外 1-2 条 CPU 指令(JIT 编译内联)
// 平均性能影响 < 5%

GC 与 CPU 缓存的影响

通过整理内存碎片(Compaction),V8 提高了 CPU 缓存的命中率。碎片化率从 30% 降低到 5% 以下,意味着:

  • 访问同一对象的不同属性时,更可能命中 L1/L2 缓存
  • 遍历对象图(如标记阶段)时,缓存友好性提升
  • 但实际上压缩本身需要移动内存,触发大量缓存失效

V8 的平衡策略:仅在碎片率 > 25% 时执行压缩,常规 GC 只做标记-清除,不做压缩。

高频面试题解析

面试题 1:V8 为什么把堆分为新生代和老生代?两个代用不同的回收算法有什么好处?

答案要点

这个设计的核心依据是”分代假说”(Generational Hypothesis):

为什么分代

  • 90%+ 的对象在分配后很快死亡(如函数内的临时变量)
  • 如果对整个堆使用同样的 GC 算法,每次 GC 都要扫描整个堆,成本过高
  • 分代后将 GC 集中在”最可能产生垃圾”的区域(新生代)

算法选择的原因

特性新生代 (Scavenge)老生代 (Mark-Sweep-Compact)
空间小(1-16MB)大(几百 MB~GB)
主要成本复制存活对象标记扫描所有对象
存活率<10%(死亡多)>80%(存活多)
算法原因Copy 算法在低存活率时非常高效(只需复制少量对象)Copy 算法不适用于高存活率(复制大量对象成本高),改用标记-清除

为什么不能反过来:老生代用 Copy 算法的话,一个 500MB 的老生代需要额外 500MB 的 To 空间,且每次 GC 要复制 80% 的对象(400MB),时间成本极高。

Scavenge 的”分配即高效”原理:新生代内存是连续区域 + bump pointer(碰撞指针)分配。分配一个对象只需要移动指针 2-4 个字节的时间(约 1-2ns),比老生代的空闲链表搜索(约 20-100ns)快 10-50 倍。

面试题 2:什么是三色标记法?增量标记和并发标记如何避免”漏标”和”多标”?

答案要点

三色标记法是标记阶段的抽象表示:

  • 白色:未访问到(可能是垃圾,最终会被回收)
  • 灰色:已访问自身,但子对象未处理完(中间态,待继续处理)
  • 黑色:自身和子对象都已处理完(存活对象)

增量标记:将标记过程分成多个小步(每步约 5-10ms),穿插在 JS 执行之间。

并发标记:标记在后台线程上执行,主线程继续执行 JS。

核心问题:标记期间 JS 修改对象引用 → 可能出现”漏标”。

漏标场景(黑色→白色):

1
2
3
4
5
初始: A(黑色)  B(白色)
1. JS: A.x = B         // A 是黑色已扫描,B 是白色
2. JS: C.x = null       // C 不再引用 B
3. GC 标记完成
4. B 是白色 → 被回收   // ❌ 错误!A 引用了 B!

V8 的解决方案(写屏障):

1
2
3
4
5
6
7
// 每次赋值操作时,编译器插入写屏障
void WriteBarrier(Object* host, Object* value) {
  if (marking_in_progress_ && IsBlack(host) && IsWhite(value)) {
    SetGrey(value);  // 把 white→grey,保证 value 被标记
    marking_worklist_.push(value);
  }
}

多标不是大问题:把垃圾标记为存活(”浮游垃圾”)不会导致程序错误,只会延迟回收。下一轮 GC 会正确清理。

并发标记的特殊挑战:主线程和后台线程同时操作标记位,需要使用原子操作(std::atomic)或锁来保证一致性。

面试题 3:JavaScript 中如何主动触发GC?什么情况下应该(或不应该)手动干预?

答案要点

如何手动触发 GC

  • 浏览器:window.gc() 需要 --enable-precise-memory-info
  • Node.js:global.gc() 需要 --expose-gc
  • 这是开发者工具功能,不能在生产中使用

手动干预的正确姿势

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ✅ 应该做的:
// 1. 解除引用让 GC 可以回收
let temp = { data: new Array(10000) };
// ... 使用完 ...
temp = null;  // 显式解除引用(只是提示,不强制 GC)

// 2. 使用 WeakMap/WeakSet/WeakRef
const cache = new WeakMap();  // 键是弱引用,不影响 GC
const obj = { data: 'value' };
cache.set(obj, 'cached');
obj = null;  // cache 中的条目也会被 GC 回收

// 3. 及时清理定时器/监听器
const timer = setInterval(() => {}, 1000);
// 使用完 → 必须 clearInterval(timer)
clearInterval(timer);

// 4. 使用 FinalizationRegistry 监听回收
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`对象 ${heldValue} 已被回收`);
});
registry.register(target, 'target-1');

// ❌ 不需要做的:
// 1. 在代码中调用 global.gc() — 依赖运行时的机制
// 2. delete obj.prop — 不会触发 GC,反而可能阻止 V8 的优化
// 3. 过度担心字符串小对象的回收 — V8 的新生代 GC 非常快

// ❌ 误以为 delete 能帮助内存管理:
const obj = {
  a: 1, b: 2, c: 3  // V8 内联属性
};
delete obj.a;  // ⚠️ 不会回收内存!V8 把属性标记为空洞
// V8 把隐藏类(地图)从 C0 变为 C1
// 属性实际上还在内存中,只是不访问
// 更好的做法: obj.a = undefined (保留地图结构)

什么时候内存问题需要 GC 干预?

症状原因解决方案
内存持续增长,手动跑 GC 后立即下降代码未正确释放引用查找泄漏(闭包、全局变量、事件监听器)
GC 频繁触发(每几百毫秒一次)内存分配过快或对象过多对象池化、减少分配
GC 停顿 > 200ms老生代太大或碎片化限制缓存大小、使用弱引用
V8 OOM 错误接近堆上限检查泄漏,确认业务是否需要更多内存

加分项:提到 --max-old-space-size 参数对 Node.js GC 行为的影响——增大堆上限意味着每次 Full GC 要扫描更大的堆,停顿时间可能线性增长。在特定情况下,缩小堆上限反而能提高性能。

总结与扩展

V8 的垃圾回收机制是 JavaScript 运行时性能的核心。理解它意味着知道:

  1. 为什么分配对象很快 — bump pointer 分配 + 新生代多数对象死亡无需回收
  2. 为什么 GC 有时会卡 — 老生代标记-清除的时间与存活对象数量成正比
  3. 如何避免 GC 成为瓶颈 — 减少临时对象、对象池化、合理使用 WeakMap/WeakRef

核心优化原则

策略原理
避免在热路径中创建对象减少新生代 GC 频率
对象池化大规模对象避免反复分配和回收大对象
使用 WeakMap 做缓存缓存不阻止 GC 回收键对象
及时清理全局引用防止对象”漏网”成为老生代垃圾
限制缓存大小防止老生代过度膨胀

进一步的方向

  • Orinoco 的并行压缩(Parallel Compaction):多个线程并行压缩老生代页
  • Oilpan:V8 中 C++ 堆对象的 GC 系统(Blink 使用)
  • 区域 GC(Zones):新兴的”一次性整体释放”策略,适合某些特定模式
  • JavaScript 引擎对比:V8 vs SpiderMonkey vs JavaScriptCore 的不同 GC 策略
本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量