Appearance
新生代垃圾回收:Scavenge算法
上一章我们了解了V8的分代回收策略。新生代专门用于存放"朝生夕死"的对象,这些对象的特点是数量多、生命周期短。那么,如何快速高效地回收这些对象呢?
V8的答案是Scavenge算法——一种以空间换时间的回收策略。
Scavenge核心思想
Scavenge基于Cheney算法,其核心思想是将新生代空间一分为二:
┌─────────────────────────────────────────────────────┐
│ New Space │
├─────────────────────────┬───────────────────────────┤
│ From Space │ To Space │
│ (活跃半区) │ (空闲半区) │
│ │ │
│ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ A │ │ B │ │ C │ │ (完全空闲) │
│ └───┘ └───┘ └───┘ │ │
│ │ │
└─────────────────────────┴───────────────────────────┘- From Space:当前正在使用的空间,新对象在此分配
- To Space:空闲空间,用于GC时存放存活对象
Scavenge执行过程
让我们一步步看Scavenge是如何工作的:
步骤1:初始状态
javascript
// 假设已创建这些对象
const a = { name: 'A' }; // 仍在使用
const b = { name: 'B' }; // 仍在使用
// c 已经不再使用(没有引用)From Space:
┌───────────────────────────────────────┐
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │ C │ │...│ │
│ │ ✓ │ │ ✓ │ │ ✗ │ │ │ │
│ └───┘ └───┘ └───┘ └───┘ │
│ 活跃 活跃 垃圾 │
└───────────────────────────────────────┘
To Space:
┌───────────────────────────────────────┐
│ │
│ (完全空闲) │
│ │
└───────────────────────────────────────┘步骤2:遍历并复制存活对象
从GC Roots出发,将可达对象复制到To Space:
From Space: To Space:
┌──────────────────────┐ ┌──────────────────────┐
│ ┌───┐ ┌───┐ │ │ ┌───┐ ┌───┐ │
│ │ A │──────────────────────→│ A'│ │ B'│ │
│ └───┘ │ │ │ │ └───┘ └───┘ │
│ │ │ │ │ │
│ │ B │──────────────────────→ │
│ └───┘ │ │ │
│ ┌───┐ │ │ │
│ │ C │ (不可达) │ │ │
│ └───┘ │ │ │
└──────────────────────┘ └──────────────────────┘步骤3:交换From和To
回收完成后,交换两个空间的角色:
原 To Space → 新 From Space:
┌──────────────────────┐
│ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │ ← 现在是活跃空间
│ └───┘ └───┘ │
│ │
└──────────────────────┘
原 From Space → 新 To Space:
┌──────────────────────┐
│ │
│ (清空后空闲) │ ← 下次GC使用
│ │
└──────────────────────┘为什么Scavenge高效?
优势1:只处理存活对象
传统的Mark-Sweep需要遍历整个堆来清除死对象。Scavenge只复制活对象,跳过死对象:
假设 1000 个对象,90% 是垃圾:
Mark-Sweep 工作量:
标记 100 个活对象
清除 900 个死对象
总操作:1000 次
Scavenge 工作量:
复制 100 个活对象
总操作:100 次新生代中大部分对象都是短命的,Scavenge的效率优势非常明显。
优势2:自动整理内存
复制过程中,对象自然地紧凑排列,没有碎片:
整理前(碎片化):
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ │ B │ │ │ C │ │ D │
└───┴───┴───┴───┴───┴───┴───┴───┘
复制后(紧凑):
┌───┬───┬───┬───┬───────────────┐
│ A │ B │ C │ D │ free │
└───┴───┴───┴───┴───────────────┘优势3:分配极快
由于空间紧凑,分配新对象只需移动指针:
javascript
// 分配过程(简化)
class Allocator {
constructor(space) {
this.space = space;
this.ptr = space.start; // 当前分配位置
}
allocate(size) {
if (this.ptr + size > this.space.end) {
return null; // 空间不足,需要GC
}
const result = this.ptr;
this.ptr += size; // 只是指针移动!
return result;
}
}这种分配方式叫Bump Pointer Allocation,只需一次指针运算。
劣势:空间利用率
牺牲了50%的空间:
可用空间 = 新生代总大小 / 2
例如:新生代 8MB → 实际可用 4MB但由于新生代本身较小(默认1-8MB),这个代价是可接受的。
Cheney算法详解
Scavenge使用的Cheney算法是一种广度优先的复制算法:
javascript
// Cheney 算法伪代码
function scavenge(fromSpace, toSpace, roots) {
// 1. 初始化 To Space 的两个指针
let allocationPtr = toSpace.start; // 分配指针
let scanPtr = toSpace.start; // 扫描指针
// 2. 复制根对象到 To Space
for (const root of roots) {
if (isInFromSpace(root.target)) {
root.target = copyObject(root.target, toSpace, allocationPtr);
allocationPtr += root.target.size;
}
}
// 3. 扫描 To Space 中的对象,复制它们引用的对象
while (scanPtr < allocationPtr) {
const obj = getObjectAt(scanPtr);
for (const field of obj.fields) {
if (isInFromSpace(field.target)) {
field.target = copyObject(field.target, toSpace, allocationPtr);
allocationPtr += field.target.size;
}
}
scanPtr += obj.size;
}
// 4. 交换 From 和 To
swap(fromSpace, toSpace);
}
function copyObject(obj, toSpace, ptr) {
// 检查是否已复制(避免重复)
if (obj.forwardingPtr) {
return obj.forwardingPtr;
}
// 复制到 To Space
const newLocation = ptr;
memcpy(newLocation, obj, obj.size);
// 设置转发指针
obj.forwardingPtr = newLocation;
return newLocation;
}双指针机制
Cheney算法的精妙之处在于使用两个指针来避免显式的队列:
To Space:
┌─────────────────────────────────────────────────┐
│ │
│ scanPtr allocationPtr │
│ ↓ ↓ │
│ ┌───┬───┬───┬───┬───┬───────────────────┐ │
│ │ A │ B │ C │ D │ E │ free │ │
│ └───┴───┴───┴───┴───┴───────────────────┘ │
│ │←── 已扫描 ──→│←─ 待扫描 ─→│ │
│ │
└─────────────────────────────────────────────────┘
扫描过程:
1. scanPtr 指向当前正在扫描的对象
2. 扫描发现新的存活对象,复制到 allocationPtr 位置
3. allocationPtr 向后移动
4. scanPtr 扫描完当前对象后向后移动
5. 当 scanPtr == allocationPtr 时,扫描结束转发指针
避免重复复制同一个对象:
初始状态:
From Space: To Space:
┌───────┐ ┌───────┐
│ obj │ │ │
│ │ │ │
└───────┘ └───────┘
第一次复制后:
From Space: To Space:
┌───────┐ ┌───────┐
│forward├────────────→│ obj' │
│ ptr │ │ │
└───────┘ └───────┘
第二次访问:
发现已有转发指针,直接使用新地址对象晋升机制
如果一个对象在多次Scavenge中存活,说明它可能是长命对象,应该移到老生代:
晋升条件
javascript
// V8 的晋升策略(简化)
function shouldPromote(obj) {
// 条件1:经历过一次 Scavenge 存活
if (obj.age >= 1) {
return true;
}
// 条件2:To Space 使用率超过 25%
if (toSpace.usedRatio > 0.25) {
return true;
}
return false;
}晋升过程
第一次 Scavenge:
From Space: To Space:
┌───────┐ ┌───────┐
│ obj │ ─────────→ │ obj' │ age: 0 → 1
└───────┘ └───────┘
第二次 Scavenge:
From Space: To Space: Old Space:
┌───────┐ ┌───────┐ ┌───────┐
│ obj' │ ───────────────────────────────→│ obj'' │
└───────┘ └───────┘ └───────┘
↑
晋升到老生代晋升阈值调优
bash
# 调整晋升年龄(默认为1,即存活一次就晋升)
# V8 内部参数,通常不需要调整
# 调整新生代大小
node --max-semi-space-size=16 app.js # 每个半区 16MB写屏障:跨代引用问题
当老生代对象引用新生代对象时,会产生跨代引用问题:
javascript
// 场景:老生代对象引用新生代对象
const cache = {}; // 假设 cache 已晋升到老生代
function addEntry(key, value) {
cache[key] = { data: value }; // 新对象在新生代
}问题
Scavenge只扫描GC Roots和新生代:
Old Space: New Space:
┌───────────┐ ┌───────────┐
│ cache │────────→│ entry │
│ (不扫描) │ │ (需要保留) │
└───────────┘ └───────────┘
↑
如何知道这个对象是活的?解决方案:记忆集(Remembered Set)
V8维护一个记忆集,记录所有"老生代 → 新生代"的引用:
┌────────────────────────────────────────┐
│ Remembered Set │
├────────────────────────────────────────┤
│ cache.entry → 新生代地址 0x1234 │
│ config.temp → 新生代地址 0x5678 │
│ ... │
└────────────────────────────────────────┘写屏障(Write Barrier)
每次老生代对象的写操作都会检查并更新记忆集:
javascript
// 写屏障伪代码
function writeField(obj, field, value) {
// 原始写操作
obj[field] = value;
// 写屏障
if (isInOldSpace(obj) && isInNewSpace(value)) {
rememberedSet.add({ obj, field, value });
}
}Scavenge时,记忆集中的引用也被当作GC Roots:
Scavenge Roots = 正常的 GC Roots + Remembered Set性能特征
Scavenge性能数据
javascript
const v8 = require('v8');
const { PerformanceObserver } = require('perf_hooks');
// 监控 Scavenge
const obs = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.detail.kind === 1) { // 1 = Scavenge
console.log(`Scavenge: ${entry.duration.toFixed(2)}ms`);
}
});
});
obs.observe({ entryTypes: ['gc'] });
// 典型结果:
// Scavenge: 0.5ms
// Scavenge: 0.3ms
// Scavenge: 0.8ms
// 通常在 1ms 以内触发频率
javascript
// 高分配率 = 频繁 Scavenge
function highAllocation() {
for (let i = 0; i < 100000; i++) {
const temp = { x: i }; // 每次循环分配新对象
}
}
// 低分配率 = 少量 Scavenge
function lowAllocation() {
const buffer = { x: 0 };
for (let i = 0; i < 100000; i++) {
buffer.x = i; // 复用对象
}
}调优建议
bash
# 增大新生代空间,减少 Scavenge 频率
node --max-semi-space-size=32 app.js
# 观察效果
node --trace-gc --max-semi-space-size=32 app.js实战:对象池模式
减少分配是避免频繁Scavenge的最佳策略:
javascript
class ObjectPool {
constructor(factory, initialSize = 100) {
this.factory = factory;
this.pool = [];
// 预分配对象
for (let i = 0; i < initialSize; i++) {
this.pool.push(factory());
}
}
acquire() {
return this.pool.pop() || this.factory();
}
release(obj) {
// 重置对象状态
this.reset(obj);
this.pool.push(obj);
}
reset(obj) {
// 清理对象,准备复用
for (const key in obj) {
obj[key] = null;
}
}
}
// 使用示例
const pointPool = new ObjectPool(() => ({ x: 0, y: 0 }));
function processPoints(data) {
const results = [];
for (const item of data) {
const point = pointPool.acquire();
point.x = item.x;
point.y = item.y;
// 处理...
results.push(transform(point));
pointPool.release(point); // 归还对象池
}
return results;
}小结
Scavenge算法是V8新生代回收的核心:
- 空间换时间:牺牲50%空间,获得O(存活对象)的复制效率
- Cheney算法:双指针广度优先遍历,避免递归开销
- 转发指针:防止重复复制,正确更新引用
- 晋升机制:存活足够久的对象进入老生代
- 写屏障:处理跨代引用,维护记忆集
理解Scavenge的工作原理,能帮助我们:
- 理解为什么短命对象对GC友好
- 理解对象池模式的价值
- 合理配置新生代大小
下一章,我们将探讨老生代的Mark-Sweep-Compact算法,了解长命对象是如何被回收的。