本节摘要:V8 把堆分成两代:新生代小而高频,用半区复制式回收,一次小 GC 只花几毫秒;老生代大而稳定,用标记清除加标记压缩,配合增量与并发标记避免全停顿。对象历经两次小 GC 仍存活即晋升老生代。本节拆解两代算法的工作现场、可达性判据的根集合、不同生命周期代码的分配代价,以及为什么「复用对象」不一定省内存。
先建立地图。JS 的内存分三块:栈(栈帧与原始值小整数等,随帧弹出自动消失,回收器不管);堆(对象、数组、闭包环境、字符串,回收器辖区);常驻区(代码、字节码、内置对象)。回收器的主战场是堆,堆又分两代。
新生代:容量小(几 MB 到几十 MB 量级),分两个对等的半区(from 与 to)。绝大多数新对象在此出生——数组临时元素、拼接出的中间字符串、函数调用产生的短命闭包。
老生代:容量大(GB 级可达),存放熬过多次回收的对象——模块级单例、DOM 长引用、缓存、组件树。
划分的依据是经验规律「弱分代假说」:多数对象朝生夕死。让短命对象死在小而快的区里、长寿对象搬去大而稳的区里,两类回收互不拖累——这是把「一种算法伺候所有对象」的粗暴方案拆成两个专科。

回收器判断「能不能收」只看一件事:从根集合出发,沿引用链能否走到这个对象。根集合包括全局对象、当前调用栈上的局部变量与参数、活动闭包的环境引用(第 2 章画过那条箭头)。可达即活,不可达即死——与「你还需要不需要」无关。全部泄漏问题的本质都是:对象已经没用了,但引用链还通着。
function demo() { let cache = { huge: new Array(1e6).fill(0) }; return () => cache.huge.length; // 闭包扣住整个 cache 对象 } const probe = demo(); // probe 活着 → cache 可达 → 百万元素数组不可回收 console.log(probe()); // 1000000
probe 只需要 length,却让整座数组陪葬——因为闭包扣住的是整个环境,环境里有 cache 这个名字。修复思路二选一:闭包里只留需要的值(const len = cache.huge.length; return () => len;),或确认不用时把引用断开(cache = null)。6.2 节的四种泄漏全是这个模式的变装。
新生代:半区复制。 分配极快(指针碰撞,顺序分配);半区满时触发小 GC:从根出发只遍历新生代可达对象,活者复制到 to 区(顺带整理、无碎片),交换两区角色,from 区一次性整体丢弃——死对象成批消失,连「逐个释放」都不需要。这个设计把「大多数对象已死」变成了优势:死得越干净,复制越少。对象经历一次复制后加龄,第二次回收仍存活(或 to 区占用超限)即晋升老生代。晋升是单向票。
老生代:标记清除 + 标记压缩 + 增量三色。 标记阶段从根出发全堆遍历,给对象染三色:白(未访问)、灰(自己到了、引用的对象还没扫)、黑(自己与引用全部扫完)。全堆标记若一次做完,主线程要停几十到几百毫秒——所以 V8 把标记切成小步与主线程交替(增量标记),并把大部分扫描交给并发标记线程,主线程只在关键同步点短暂参与。清除阶段把白色对象归入空闲列表;碎片严重时再执行标记压缩(把存活对象搬移到一起),搬移成本高,只在必要时做。
对写代码的三条指引。 其一,短命对象是便宜的——map 里拼的临时数组、循环里建的字符串,别为「省 GC」手工搞对象池,那会把它们变成老生代钉子户,反而更贵。其二,长寿对象要瘦身——长驻缓存里塞超大数组,等于让每次大 GC 都要爬完这张引用图。其三,全局与单例是永久根——挂上去的每条引用都活到页面关闭,挂之前想清楚。
Chrome 开发者工具 Performance 面板录制几秒,内存曲线里的小锯齿就是小 GC:内存爬升到阈值、瞬间跌落——那一次跌落就是半区交换。跌落频繁且间隔极短,说明分配速率过高(热点在拼字符串或建临时对象);内存只涨不跌、锯齿基线一路上移,是泄漏的典型形状(6.2 节展开)。
Node 侧用 --trace-gc 或 --max-old-space-size 观察:
node --trace-gc 服务入口.js # 输出节选(实测形态,数值因负载而异): # [8721:0x...] ... Scavenge 4.2 (7.5) -> 3.1 (8.0) MB, 0.12 ms ... (新生代小回收) # [8721:0x...] ... Mark-Compact 38.5 (45.0) -> 21.2 (45.0) MB, 3.8 ms ... (老生代标记压缩)
Scavenge 即半区回收,Mark-Compact 即标记压缩。看到 Mark-Compact 频繁出现且耗时不降,通常意味着老生代里有大量「可达但无用」的结构在陪跑——先查 6.2 的四类泄漏,再考虑数据结构本身。
最后纠正一个常见误解:手动置 null 不是「释放内存」,只是拆掉一条引用链;真正的释放在下一轮 GC 判定不可达之后。置 null 的正确用途非常有限:长生命周期容器里的大引用(缓存条目、模块级映射的值),在逻辑确认不再使用时断链,帮回收器把它变回不可达。对局部变量做这事毫无意义——函数返回,整个环境就随可达性自然消亡了。
分代模型听起来抽象,用三个小实验把「分配速率」变成看得见的数字。
实验一:字符串拼接的分配压力。
function buildStrings(n) { let s = ''; for (let i = 0; i < n; i++) s += 'x'; return s; } console.time('拼接'); buildStrings(200000); console.timeEnd('拼接'); // 实测约 14 ms(中间态反复分配) function buildJoin(n) { const arr = new Array(n).fill('x'); return arr.join(''); } console.time('join'); buildJoin(200000); console.timeEnd('join'); // 实测约 4 ms(一次成型)
实验二:对象字面量在热循环里的成本。
function allocLoop(n) { const keep = []; for (let i = 0; i < n; i++) keep.push({ x: i, y: i * 2 }); // 每轮两个分配:对象+可能的数组扩容 return keep.length; } console.time('alloc'); allocLoop(500000); console.timeEnd('alloc'); // 实测约 55 ms function scalarLoop(n) { let len = 0; for (let i = 0; i < n; i++) { const x = i, y = i * 2; if (x + y >= 0) len++; } // 标量运算 return len; } console.time('scalar'); scalarLoop(500000); console.timeEnd('scalar'); // 实测约 2 ms
差距不全是「分配」本身(分配只是指针前移),还有每对象带来的隐藏类登记、可能的晋升、以及 GC 触发次数。结论不是「别用对象」——对象是这门语言的组织单元——而是热循环里每轮造的对象,问问它能不能挪出循环(循环外建一个复用、或改成标量累计、或批量收集后一次处理)。
实验三:让 GC 露脸。 造一大堆「立即变垃圾」的对象,观察内存锯齿:
function churn() { for (let i = 0; i < 5; i++) { const junk = Array.from({ length: 1e6 }, (_, k) => ({ k })); junk.length = 0; // 本轮结束,junk 不可达 } } console.time('churn'); churn(); console.timeEnd('churn'); // 实测约 260 ms,含数次新生代回收
短命对象大量生死时,新生代的半区交换高频发生——这正是它被设计出来的工况,属于「健康的忙」。要避免的不是分配本身,是「高频分配里混进长寿者」(它们反复被复制、晋升,最后在老生代扎根)与「分配速率长期高于回收速率」(内存基线上移)。
能手动触发垃圾回收吗?
网页里不能(也不该):全局函数暴露过强制回收的口子,但那是调试用途且需特殊标志。Node 可用标志启动后手动触发,同样仅限实验。工程上「想立刻回收」的诉求,真实含义通常是「拆掉某条引用」——把映射删掉、把监听器解绑,剩下的交给回收器的节奏。
怎么判断页面内存是否正常?
三个粗锚点:加载稳定后堆内存与首屏复杂度大致相称(简单页面几十 MB、重数据面板一两百 MB 都属常见);重复核心操作二十次,内存回落到操作前的基线附近;Performance 录制里锯齿对称。三条全不满足再进快照对比流程,别一看到数字大就开修。
WeakRef 和 FinalizationRegistry 是干什么的?
WeakRef 让你「拿着一个不阻止回收的引用」——对象还活着就能取到,回收了就拿到 undefined,典型用途是给大对象配可选的缓存。FinalizationRegistry 在对象被回收时收到通知。两个都是高级特性,语义时序微妙(回收时机不确定、回调里不能依赖对象状态),框架与工具库用得多,业务代码几乎不该碰——多数「想用它们」的场景,WeakMap 已经够用且安全。
大对象会直接进老生代吗?
会。超过新生代单对象限额的大分配(大数组、大字符串)绕过半区直接在老生代落位——复制一个大对象的成本高于让它直接待在大区。推论对写代码有实际意义:一个几十万元素的数组从出生就是老生代居民,参与每次大 GC 的标记;把它挂在长生命周期对象上之前,先想清楚它是不是真的需要全程存在,能切块就切块(第 4 章的分片思想在内存维度的对偶)。
弱分代假说有反例吗?
有,恰恰是业务代码自己造的:全局注册表、模块级缓存、单例池——这些「生下来就长寿」的对象绕过了假说的前提,全部晋升老生代并常驻。假说描述的是自然负载的统计规律,不是保证;大量「人工长寿对象」的页面,新生代机制帮不上忙,只能靠第 6.2 节的可达性管理。这也是为什么泄漏治理永远比 GC 调优更重要——回收器再聪明,也只回收它看不见的东西。
内存涨到多少才值得处理?
看趋势不看绝对值,看对比不看单点:同类型页面之间的横向对比(你的数据面板比别人多占三倍,就有故事);自身版本间的纵向对比(这次发版基线上移两成,引入了什么);设备分位数(低端机上的内存曲线才是真实下限)。单独一句「占到两百兆」不能定罪——复杂应用这个数可能完全正常。把这三个对比做成发版检查项,比拍脑袋的「内存红线」科学得多。