JavaScript 内存管理
JavaScript 自动分配和回收程序使用的内存,但它不能判断一份仍被引用的数据是否已经失去业务用途。垃圾回收根据运行时能否继续访问对象,识别可回收的内存;内存泄漏则是程序已经不再需要某些对象,却仍通过引用关系把它们保留下来。
因此排查泄漏的重点是找到保留关系:哪个长期存在的对象持有引用,为什么清理时没有解除,修改后是否真的停止了积累。只给变量赋 null,或者看到内存曲线下降一次,都不足以说明问题已经解决。
1. 面试核心
先记住这几条,再读正文- 执行栈与对象存储解释的是不同层次。不能把“基本类型全在栈、对象全在堆”当作 JavaScript 的语言保证;闭包和引擎优化都会影响实际存储。卡 · 执行栈与对象生命周期
- 回收器从根引用出发追踪对象。对象即使互相引用,只要整个对象组已经不可达,也可以回收;仍然可达但业务上无用的对象不会自动变成垃圾。卡 · 可达性与垃圾回收
- 标记负责识别可达对象,清扫回收不可达内存,整理减少碎片。分代、增量、并行与并发主要调整回收成本和停顿方式,不改变业务引用需要及时解除的事实。卡 · 回收过程与成本
- 闭包会保留后续执行需要的绑定。闭包本身不是泄漏,长期存活的回调意外保留大对象才是需要排查的关系。卡 · 闭包、监听器与定时器
- DOM 从文档移除后仍可能被缓存、监听器或闭包引用。“页面上看不见”不等于“对象不可达”,Detached 节点也需要结合保留原因判断。卡 · 脱离文档的 DOM
- 定时器、事件监听、观察器和缓存需要明确结束条件。业务资源应在不再使用时主动清理,不能等待垃圾回收替代取消和解绑。卡 · 业务资源的生命周期
- 弱引用只改变特定引用关系。WeakMap 不会削弱其他容器里的强引用,FinalizationRegistry 也不能保证及时执行关键清理。卡 · 弱引用与清理边界
- 排查需要可重复的操作和可比较的快照。比较多轮操作后的存活对象、沿 Retainers 找到保留路径,再用同样步骤验证修复。卡 · 综合题
- JavaScript 与 Java 都使用自动垃圾回收,C++ 则常用对象生命周期和所有权管理资源。自动回收与确定时机的资源释放不是同一件事。卡 · 不同语言的资源管理
2. 执行栈与对象生命周期
执行栈保存当前函数调用所需的执行状态;堆通常指运行时管理对象等动态数据的内存区域。调用结束可以退出栈帧,但函数创建的对象不一定随之失效,因为其他代码还可能引用它。
function createReader() {
const record = { count: 3 };
return () => record.count;
}
const read = createReader();
console.log(read()); // 3
createReader 已经返回,read 仍能访问 record。这个例子说明“函数调用结束”和“相关数据不再需要”不是同一个时刻。闭包的语义要求被使用的绑定继续可用,具体如何存放由引擎实现。
面试中可以用“栈适合随调用进入退出,堆支持跨调用保存对象”解释基本差异,但不能把每个 JavaScript 值的物理位置写死。字符串的内部表示、闭包环境、寄存器分配,以及对象被优化掉的情况,都不是单靠值类型就能确定的。
同样,不能仅因一段代码创建了对象,就断定它一定有固定的堆分配成本;也不能为了“避免堆内存”改变正确的引用语义。需要讨论实际性能时,应结合目标引擎和测量结果。
3. 可达性与垃圾回收
可达性表示运行时从仍然存在的根引用出发,能否沿引用关系找到某个对象。根的来源包括当前执行状态、全局环境,以及宿主持有的有效对象或回调等。对象只要还能被这些路径访问,回收器就必须考虑保留它。
const cache = new Map();
let record = { count: 3 };
cache.set("current", record);
record = null;
console.log(cache.get("current").count); // 3
cache.delete("current");
console.log(cache.size); // 0
给 record 赋 null 只移除了这一个绑定中的引用,cache 仍然持有对象。删除缓存项后,如果没有其他保留路径,对象才具备被回收的条件;这仍不意味着引擎必须立刻回收,或者立刻把内存归还操作系统。
3.1 循环引用
循环引用本身不会阻止基于可达性的回收。真正的问题是从根能否走到这个循环。
function createPair() {
const first = {};
const second = {};
first.peer = second;
second.peer = first;
return first;
}
let pair = createPair();
console.log(pair.peer.peer === pair); // true
pair = null;
最后一行移除了示例中对这组对象的外部引用。两个对象仍互相指向,但没有从外部进入的路径时,它们可以作为一组被回收。这里不能用日志断言“下一行已经回收”,因为普通代码没有这样的时间保证。
简单引用计数只看对象还有多少引用,会被这种互相计数的环困住;可达性分析则从根出发,不把对象之间互相引用本身当作存活理由。现代 JavaScript 引擎的对象回收不能概括为“引用计数减到零”。
3.2 回收过程与成本
基于可达性的回收可以分成几项工作:标记可达对象,回收未标记对象占用的空间,并在需要时移动存活对象来整理碎片。移动对象时,运行时也要保持引用关系正确,业务代码不能依赖对象拥有固定的物理地址。
实际回收器会根据对象存活时间和性能目标采用不同策略:
| 策略 | 解决的成本问题 |
|---|---|
| 分代 | 利用许多对象很快失效的特征,更频繁地处理年轻对象,减少每次都扫描全部长期对象的工作 |
| 增量 | 把一部分回收工作拆成小段,减少单次长停顿 |
| 并行 | 使用多个线程协作完成某一阶段,缩短该阶段的时间;并不自动表示 JavaScript 同时运行 |
| 并发 | 让部分回收工作与应用执行重叠,需要协调对象和引用在此期间的变化 |
V8 使用这些机制来平衡吞吐、停顿和内存占用,具体收集器及其实现随版本变化。分代还需要记录老对象指向年轻对象等引用变化,否则只扫描年轻对象时可能漏掉仍可达的对象。
自动回收有成本。大量短命对象即使没有泄漏,也可能引发频繁回收和暂停;大量长期保留的数据会增加占用和扫描压力。优化时应区分“没用却没释放”和“本来就创建、保留了过多数据”,再决定是修复引用、减少分配还是改变数据规模。
4. 业务资源的生命周期
资源生命周期是从取得资源到不再使用并释放它的过程。对页面代码来说,它不仅包括对象,还包括定时器、监听器、观察器、请求与外部连接。
这些资源应与页面、组件或任务的实际寿命对应。全局事件源通常比组件活得更久,如果它保存了组件回调,回调又引用组件数据,那么组件在界面上消失后仍可能被保留。
4.1 闭包、监听器与定时器
下面的面板注册全局监听器和周期回调,两个回调都使用 model 与 panel。返回的 dispose 负责对称地结束这些关系。
function mountPanel(root) {
let model = { items: new Array(1000).fill("item") };
let panel = document.createElement("div");
root.append(panel);
function render() {
if (panel !== null) panel.textContent = String(model.items.length);
}
window.addEventListener("resize", render);
const timer = setInterval(render, 1000);
render();
return function dispose() {
if (panel === null) return;
window.removeEventListener("resize", render);
clearInterval(timer);
panel.remove();
panel = null;
model = null;
};
}
const root = document.createElement("section");
document.body.append(root);
const dispose = mountPanel(root);
console.log(root.textContent); // 1000
dispose();
dispose();
console.log(root.children.length); // 0
root.remove();
如果只调用 panel.remove(),全局监听器和定时器还可能继续持有 render,进而保留数据。清理时既解除调度来源,也移除节点;把内部绑定设为 null,还可以避免调用方继续保存 dispose 时保留这两份已失效的数据。
两次调用 dispose 都安全,是为了让卸载和错误处理等多个出口可以重复清理。解绑监听器要使用原来的回调对象,并匹配注册时的 capture 设置;重新写一个外形相同的匿名函数不是同一个监听器。
闭包需要保留的值本来就在使用时,保留是正确行为。不要把所有闭包都删掉;应检查是谁继续持有回调,以及这个持有者是否超过了业务寿命。
4.2 脱离文档的 DOM
脱离文档的 DOM 节点是当前不在文档树中的节点。它可能是准备插入、暂存待复用的节点,也可能是已经不需要却被 JavaScript 保留的节点,必须结合用途判断。
const retained = [];
function createRemovedNode() {
const node = document.createElement("div");
node.textContent = "temporary";
document.body.append(node);
retained.push(node);
node.remove();
}
createRemovedNode();
console.log(retained[0].isConnected); // false
console.log(retained[0].textContent); // temporary
retained.length = 0;
isConnected 为 false,但数组仍能访问节点。这里要检查的保留路径是 retained 到节点的引用,而不只是 DOM 树中的父子关系。清空数组后,还要确认没有其他容器、闭包或调试工具持有它。
节点上的事件监听器也不是“一旦忘记 remove 就永远泄漏”。如果节点、监听器和相关闭包整体都不可达,循环关系本身可以被回收;更值得警惕的是 window、长期事件总线等仍然可达的事件源持有已经卸载组件的回调。
4.3 缓存与其他长期引用
缓存需要容量、有效期或显式删除条件。只要 Map、数组或全局对象还保留条目,这些值通常就仍然可达。给原来的局部变量赋 null 不会清理缓存,设置过期时间但从不真正移除记录,也可能保留越来越多数据。
其他资源也应有匹配的清理动作:定时器清除,观察器断开观察,事件订阅取消,失效请求按接口支持情况取消,关闭不再使用的连接。具体资源是否由宿主自动释放,取决于 API 契约;业务代码不能只依赖某个包装对象“以后可能被回收”。
Promise 待定本身不是永久存活的保证。如果它和所有相关对象都不可达,就不能仅因 pending 推断泄漏;反过来,一个长期保存的待定 Promise 可以积累很多后续回调。排查仍应找真实引用路径,而不是按状态名称猜测。
5. 弱引用与清理边界
WeakMap 适合给对象附加元数据,同时避免容器单独延长键对象的寿命。它不是“自动清空缓存”的通用工具:如果其他地方还强引用这个键,对象仍然存活;值还被别的容器保留时,也要分析那条路径。API 的键类型和遍历限制见 JavaScript 集合。
WeakRef 可以保存不阻止目标被回收的引用,读取时可能已经没有目标。它适合允许对象消失、能重新取得结果的场景,不适合要求结果一定存在的关键业务状态。
FinalizationRegistry 可以在目标被回收后的某个时机收到清理通知,但这个通知没有及时执行的保证,某些情况下甚至可能不执行。因此不能依靠它按时关闭连接、释放锁、移除事件监听或完成支付状态等业务动作。
把资源释放放在明确的 dispose、finally 或对应框架清理回调里,才能表达业务上何时不再需要资源。弱引用与终结通知只适合能接受不确定性的辅助用途,不能替代这个契约。
6. 内存泄漏的排查
诊断需要先定义预期寿命。打开面板后创建一组对象,关闭面板后应该释放;首次加载后保留一份模块缓存,则可能符合设计。没有这个预期,很难仅凭对象数量判断异常。
6.1 区分三类现象
| 现象 | 应优先验证的假设 |
|---|---|
| 重复完成同一操作,回收后存活对象仍持续增长 | 是否有未清理的引用或无上限缓存 |
| 内存长期很高,但重复操作后趋于稳定 | 是否存在数据规模过大或有意保留过多内容 |
| 内存频繁升降,同时有卡顿 | 是否大量创建短命对象,引发频繁回收 |
单次峰值可能只是计算的临时分配,首次增长也可能来自模块初始化和缓存预热。浏览器进程内存还包含 DOM、图片、代码和其他原生资源,不等于 JavaScript 堆大小;GC 后堆空间也不必立刻归还操作系统。
6.2 堆快照与保留路径
堆快照记录某一时刻的对象和引用关系。在 Chrome DevTools 的 Memory 面板中,可以使用 Heap snapshot 记录快照,再比较重复操作前后仍存活的对象。
一次有说服力的排查可以按下面顺序进行:
- 使用稳定的测试数据,加载页面并完成必要预热,等待上一轮异步工作结束,记录基线快照。
- 重复执行“打开—使用—关闭”这类应该回到稳定状态的操作,避免只打开不关闭就期待对象消失。
- 在可比较的回收条件下记录后续快照。测试中可以借助工具请求 GC,但不把它当成网页业务 API。
- 在比较视图中看新增且仍然存活的对象,按相关构造器或 DOM 节点类型缩小范围;涉及节点时检查 Detached 对象。
- 从可疑对象的 Retainers 追踪谁在保留它,直到找到缓存、监听器、定时器或其他长期持有者。
- 修复具体清理路径,再用相同数据和操作次数复测,确认对象不再随每轮操作积累,并确认功能仍然正常。
Shallow size 表示对象自身占用的内存;Retained size 包括对象自身,以及仅因它而保持可达的依赖所占的内存;解除让这个对象存活的全部路径后,这部分有机会一起被回收。一个很小的数组可能保留大量节点或数据,所以不能只按自身大小判断重要性。不同对象的保留大小也不能随意相加,否则可能重复计算同一批依赖。
Retainers 说明“为什么还在”,分配记录说明“在哪里创建”。Allocation instrumentation on timeline 或 Allocation sampling 可以帮助关联分配热点与具体操作,再回到保留路径判断是否泄漏。创建得多不一定保留得久,两类证据要一起使用。
6.3 避免测量本身改变结果
控制台输出对象、把对象存为临时全局变量、在调试器中保留求值结果,都可能让工具继续引用它。复测时应清理这类调试引用,或在新的隔离页面中重现;不要一边长期保存待检查对象,一边期待它被回收。
堆快照可能包含运行中的数据,收集实际用户页面时应按项目的数据处理要求使用。复现问题通常可以先用小型测试数据,保留必要的引用结构,而不需要复制完整业务数据。
7. 不同语言的资源管理
JavaScript 与 Java 的常见运行时都通过可达性分析进行自动垃圾回收。实现、内存布局和收集器不同,不能把 JVM 的代大小、参数或对象布局直接套到浏览器上;共享的核心问题是对象是否还被存活路径引用。Java 的具体机制见 GC Roots。
C++ 则通常通过对象生命周期和所有权约定管理资源。自动存储期对象离开作用域时会结束生命周期,RAII 把资源释放放入析构过程;动态对象可以由 unique_ptr、shared_ptr 等管理。shared_ptr 的共享计数也需要处理强引用环,具体见 C++ 智能指针。
这不是“C++ 全靠手动,JavaScript 什么都不用管”的区别。自动回收减少了手动释放对象内存的负担,业务仍然要结束订阅和外部资源;确定性的析构便于关联资源寿命,也要求正确表达所有权。
8 · 面试题卡片
本页题目与参考回答
8.1 JavaScript 的基本类型一定在栈上,对象一定在堆上吗?
考察重点:语言语义、调用状态与实际存储实现。
相关内容:执行栈与对象生命周期。
参考回答
不能作为语言保证,实际存储由引擎和优化决定。 执行栈主要描述调用中的状态,堆用于动态对象等数据;闭包需要的绑定可以在调用结束后继续可用。
可以用栈的调用寿命和堆的动态寿命解释基本模型,但字符串表示、寄存器和对象优化等都超出“按类型分两个区域”的简化。讨论性能还需要具体引擎与测量证据。
8.2 两个对象互相引用,JavaScript 能回收它们吗?
考察重点:根引用、可达性与引用计数的区别。
参考回答
如果这组对象从根已经不可达,就可以被回收;互相引用本身不构成存活理由。 可达性分析从根开始追踪,简单引用计数则可能被环中的非零计数困住。
给一个变量赋 null 只解除这一条引用,还要确认缓存、闭包和宿主等没有其他保留路径。具备回收条件也不意味着下一行就已经回收。
8.3 JavaScript 垃圾回收怎样工作,分代、增量与并发分别解决什么问题?
考察重点:可达性判断、空间回收与停顿成本。
相关内容:回收过程与成本。
参考回答
先识别可达对象,再回收不可达对象占用的空间,需要时整理碎片。 分代利用对象寿命差异减少扫描范围,增量拆小回收工作,并行让多线程协作,并发允许部分工作与应用重叠。
这些策略需要处理引用变化,并在吞吐、停顿和占用之间权衡。具体实现依赖引擎版本,不改变业务无用但仍可达的对象会被保留这一点。
8.4 闭包会导致内存泄漏吗,应该怎样处理?
考察重点:必要保留与意外延长的生命周期。
相关内容:闭包、监听器与定时器、缓存与其他长期引用。
参考回答
闭包本身不是泄漏,问题在于回调是否在业务结束后仍被长期对象持有。 全局监听器、定时器或缓存可能保存闭包,闭包再保留已卸载组件的数据。
应找到持有者并在正确时机取消、解绑或删除引用;如果清理函数本身还长期保留,也要避免它继续保留已失效的大对象。不能仅给原局部变量赋 null 就认定所有路径已经解除。
8.5 DOM 已经从页面移除,为什么仍然没有释放?
考察重点:文档树关系与 JavaScript 保留关系。
相关内容:脱离文档的 DOM。
参考回答
移除节点只解除文档树中的关系,缓存、闭包或全局监听器仍可能持有它。 应在堆快照中追踪具体 Retainers,而不是只检查 isConnected。
Detached 节点也可能是有意暂存的对象,需要结合预期寿命判断。节点和自身监听器若整体不可达,循环关系本身不代表一定泄漏。
8.6 怎样为组件中的定时器和事件监听设计清理?
考察重点:对称释放、重复清理与回调身份。
相关内容:业务资源的生命周期、闭包、监听器与定时器。
参考回答
让资源获取与清理成对,组件或任务结束时主动取消,并让重复清理安全。 保存定时器标识和原始监听函数,解绑时匹配 capture;同时清理节点、观察器和其他实际持有的资源。
垃圾回收不负责替代业务上的关闭时机。已经开始的请求或副作用还需要按自身契约处理,不能认为清除定时器就撤销了此前的操作。
8.7 换成 WeakMap 或 FinalizationRegistry,就不需要主动清理了吗?
考察重点:弱引用作用范围与非确定性的终结通知。
相关内容:弱引用与清理边界。
参考回答
仍然需要,弱引用不会改变其他地方的强引用,终结通知也不保证及时发生。 WeakMap 适合不想单独延长键寿命的附属元数据;其他容器仍保留键时,对象不会因此消失。
关键资源应通过 dispose、finally 或框架清理回调明确释放。WeakRef 和 FinalizationRegistry 只适合能够接受目标消失或通知延迟的辅助用途。
8.8 怎样定位并验证页面中的内存泄漏?
题型:综合题
考察重点:复现步骤、存活对象、保留路径与修复回归。
参考回答
先定义什么操作结束后应释放哪些对象,再重复操作并比较可比条件下的堆快照。 看哪些对象持续存活增加,沿 Retainers 找到缓存、监听器等保留来源,并用分配记录定位创建位置。
修复后用相同数据和操作次数复测,确认不再逐轮积累且功能正常。排除预热缓存、短期峰值和调试工具引用;JavaScript 堆与整个浏览器进程内存也要分开看。
8.9 JavaScript、Java 与 C++ 的内存和资源管理有什么区别?
考察重点:自动回收、所有权和确定性资源释放。
相关内容:不同语言的资源管理。
参考回答
JavaScript 与 Java 的常见运行时依赖自动垃圾回收,C++ 通常通过对象生命周期和所有权管理资源。 C++ 的 RAII 与智能指针减少手动释放负担,但共享计数仍需避免强引用环。
自动回收判断可达性,不等于按业务结束时机关闭外部资源;确定性的析构也要求正确表达所有权。不同运行时的布局、收集器和参数不能直接互相套用。
9. 参考资料
- MDN:Memory management
- V8:Trash talk — the Orinoco garbage collector
- Chrome DevTools:Fix memory problems
- Chrome DevTools:Record heap snapshots
- MDN:WeakRef
- MDN:FinalizationRegistry
- C++ Core Guidelines:Resource management
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明