垃圾回收:垃圾数据如何自动回收|浏览器篇
# 垃圾回收:垃圾数据如何自动回收
30 秒速记
- 垃圾数据是已经使用完毕、后续不再需要的数据。
- 若垃圾数据持续留在内存中,可用内存会被不断消耗。
- 垃圾回收的目标是识别并释放这类数据占据的空间。
- 内存容量有限,因此回收是维持程序持续运行所必需的内存管理环节。
- 这段材料只说明回收的必要性,没有给出垃圾判定算法或具体执行时机。
垃圾回收就是识别后续无法再使用的数据,并释放它们占用的有限内存空间。 本质上,判断垃圾不能只看函数是否结束或变量是否离开作用域,而要看对象能否从调用栈、全局对象等根节点沿引用关系到达。比如函数返回后,大部分临时对象可能已不可达,但保存到 globalThis 的对象仍然有引用,因此还不具备回收条件。对象何时真正被释放由引擎决定,切断引用也不代表垃圾回收会立即执行。
有些数据被使用之后,可能就不再需要了,我们把这种数据称为垃圾数据。如果这些垃圾数据一直保存在内存中,那么内存会越用越多,所以我们需要对这些垃圾数据进行回收,以释放有限的内存空间
原理拆解: 垃圾并不等同于“函数已经执行完”或“变量离开了局部作用域”,更准确的判断依据是数据是否仍能从程序的根集合沿引用关系到达。根通常包括当前调用栈中的变量、全局对象及宿主环境保留的引用。只要存在一条有效引用链,对象仍可能被后续代码使用;当所有引用链都断开时,它才具备被回收的条件。回收器随后识别不可达对象并重新利用其内存,但回收时机由引擎决定。
最小验证: 输入一批临时对象,只把其中一个保存到全局变量中,用来验证“是否保留引用”比“是否执行完函数”更关键。
function createRecords() {
const records = Array.from({ length: 1000 }, (_, id) => ({
id,
payload: new Array(100).fill(id)
}));
globalThis.savedRecord = records[0];
return records.length;
}
console.log(createRecords());
console.log(globalThis.savedRecord.id);
globalThis.savedRecord = null;
records 在函数返回后不再可访问,其中未被保存的对象具备回收条件;savedRecord 指向的对象仍可从 globalThis 到达,所以第二次输出为 0。最后赋值为 null 才切断这条引用链。代码只能制造并观察引用关系,不能保证垃圾回收立即发生,也不能通过一次内存读数证明对象已经释放。
边界与排查: 定时器回调、事件监听器、闭包、缓存容器和未结束的异步任务都可能间接持有对象。排查持续增长的内存时,可在 Chrome DevTools 的 Memory 面板中分别获取操作前后的堆快照,重复目标操作后比较对象数量与保留路径;若对象仍能沿 Window、监听器或闭包到达,应先解除对应引用,而不是依赖手动触发回收。
面试官追问
追问 1代码评审中有人说,一个创建 1000 条记录的函数已经返回,所以其中所有对象都必然成了垃圾;但函数把一条记录写入了 globalThis.savedRecord,你同意吗?
不同意,被 globalThis.savedRecord 引用的对象仍能从根集合到达,函数返回并不会让它成为垃圾。其余未返回、未被闭包或外部容器保存的记录才具备回收条件;即便已经不可达,实际回收时机仍由引擎决定。
追问 2单页管理后台每切换一次详情页就创建一批对象,重复 200 次后堆占用持续增长;前端负责人准备在退出页面时把所有局部变量设为 null,你会怎么处理?
仅清空局部变量未必能解除真正的保留链,应先比较多轮操作前后的堆快照,并查看增长对象的 Retainers 路径。重点排查 Window、事件监听器、定时器、闭包、缓存和未结束异步任务;快照本身会扰动内存,单次占用升高不能直接判定泄漏。
追问 3业务把临时记录从局部数组迁入全局 Map 以便跨页面复用,产品要求缓存永久保留;这些对象还能依赖垃圾回收器自动释放吗?
只要全局 Map 仍持有记录,它们就可从根对象到达,回收器不能把仍可能使用的数据当作垃圾。需要由业务定义容量、淘汰条件或生命周期,并在失效时删除引用;提高命中率的代价是长期占用有限内存,不能用自动回收掩盖无界缓存。
追问 4线上编辑器关闭文档后内存没有立刻下降,值班同学据此认定垃圾回收失效;你会怎样区分“尚未回收”和“仍被引用”?
页面关闭文档后内存未立即下降,只说明对象可能尚未回收,不能证明回收器失效。应重复打开和关闭流程、获取前后堆快照并检查对象数量及保留路径;若路径仍连接到监听器、闭包或全局容器,应先解除引用,否则等待回收没有意义。
追问 5一个聊天页用事件监听器保存最近消息对象,移除 DOM 节点后对象数量仍持续增加;UI 开发认为节点消失就足以释放数据,你如何取舍修复方案?
移除 DOM 节点不等于切断监听器或闭包形成的引用链,消息对象仍可能从宿主环境的根到达。应在组件销毁时解除监听器、终止不再需要的异步任务,并清理对应缓存;不要依赖手动触发回收,因为它既不能替代生命周期治理,也不保证立即执行。
追问 6同事看到两个对象互相引用,就把它们认定为内存泄漏;在一个反复创建环形数据的页面中,你会如何判断?
互相引用本身不构成泄漏,关键是整个对象环是否仍能从调用栈、全局对象或宿主引用等根集合到达。若外部引用全部断开,环形对象也具备回收条件;若全局数组或活动监听器仍指向其中任一对象,则必须先清理那条根引用。
# 不同语言的垃圾回收策略
30 秒速记
- 内存释放策略主要分为手动管理和自动垃圾回收两类。
- 在
C/C++的手动模式中,代码通过malloc申请堆空间,并在不再使用时调用free释放。 - 已经无用的内存若未被手动释放,会继续占用空间并形成内存泄漏。
JavaScript、Java、Python等语言由垃圾回收器处理垃圾数据,业务代码通常不直接释放对应内存。- 自动回收不等于开发者可以忽略内存管理;理解数据位于栈或堆,是继续分析其回收方式的基础。
不同语言的内存释放大体分为手动管理和自动垃圾回收两种策略。 C/C++ 通常由代码通过 malloc 申请堆内存,并在不用时调用 free;已经无用却没有释放的空间会形成内存泄漏。JavaScript、Java、Python 等语言则由垃圾回收器处理无用数据,业务代码通常不直接释放对应内存。自动回收不等于可以忽略内存问题,继续分析 JavaScript 时仍要区分数据位于栈还是堆,因为两类空间的回收方式不同。
通常情况下,垃圾数据回收分为手动回收和自动回收两种策略。
如 C/C++ 就是使用手动回收策略,何时分配内存、何时销毁内存都是由代码控制的,你可以参考下面这段 C 代码:
// 在堆中分配内存
char* p = (char*)malloc(2048); // 在堆空间中分配 2048 字节的空间,并将分配后的引用地址保存到 p 中
// 使用 p 指向的内存
{
//....
}
// 使用结束后,销毁这段内存
free(p);
p = NULL;
从上面这段 C 代码可以看出来,要使用堆中的一块空间,我们需要先调用 mallco 函数分配内存,然后再使用;当不再需要这块数据的时候,就要手动调用 free 函数来释放内存。如果这段数据已经不再需要了,但是又没有主动调用 free 函数来销毁,那么这种情况就被称为内存泄漏。
另外一种使用的是自动垃圾回收的策略,如 JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放。
对于 JavaScript 而言,也正是这个“自动”释放资源的特性带来了很多困惑,也让一些 JavaScript 开发者误以为可以不关心内存管理,这是一个很大的误解。
那么在本文,我们将围绕“JavaScript 的数据是如何回收的”这个话题来展开探讨。因为数据是存储在栈和堆两种内存空间中的,所以接下来我们就来分别介绍“栈中的垃圾数据”和“堆中的垃圾数据”是如何回收的。
面试官追问
追问 1代码评审发现一个 C 函数通过 malloc(2048) 分配内存,正常路径执行 free(p),错误码分支却提前 return;服务长期运行会有什么后果?
错误分支会遗失已分配内存的释放责任,反复命中后可能形成内存泄漏。手动回收要求每条结束使用的控制路径都执行匹配的释放操作,并避免释放后继续使用指针;仅在正常路径补 free 不能覆盖提前返回风险。
追问 2团队把一段图像处理逻辑从 C 迁到浏览器 JavaScript,负责人要求保留 free(p) 式接口以确保每次任务结束立即释放;你会如何改造?
JavaScript 的垃圾数据由垃圾回收器自动释放,业务代码不能照搬 free(p) 来精确控制回收时点。应在任务结束后解除全局容器、监听器或闭包中的引用,让对象变为不可达;具备回收条件不等于立即释放,因此高峰内存仍需通过分批处理等设计约束。
追问 3一个单页应用准备将缓存从 1000 项扩大到无限增长,开发者以“JavaScript 有自动垃圾回收”为理由支持该方案;你会怎样反驳?
自动回收只处理已经成为垃圾的数据,缓存容器仍引用的条目不会被释放。应给缓存设置容量、淘汰或生命周期规则,并用堆快照验证保留关系;若业务坚持永久可达,内存成本就必须由方案承担,不能期待回收器突破引用语义。
追问 4线上页面运行数小时后崩溃,排查者只看到堆增长便认定是垃圾回收算法缺陷;在自动回收语言里你会先检查什么?
应先确认增长对象是否仍被程序引用,而不是直接归因于回收器。重复目标操作后比较堆快照,检查全局缓存、定时器、事件监听器、闭包和未结束异步任务的保留路径;只有对象已经不可达却持续异常累积时,才有理由继续调查引擎层问题。
追问 5内存敏感的桌面模块在 C++ 与 JavaScript 实现之间选型,架构师只用“手动回收更可控、自动回收更省事”作结论,你会补充哪些取舍?
两种策略的核心差异是释放责任与时机控制:C++ 路径要求代码覆盖分配后的所有释放分支,JavaScript 路径则要求正确管理引用并接受回收时机由引擎决定。选型还需结合错误路径复杂度、峰值内存和宿主约束,不能把自动回收理解为无需内存治理。
追问 6一名 JavaScript 开发者排查内存时,把函数返回、栈帧退出和堆对象释放视为同一时刻;你会怎样串联栈与堆的回收差异?
函数返回会使对应栈上执行上下文失效,但局部引用指向的堆对象不会因此同步删除。堆对象只有在无法从根集合沿引用链到达后才具备回收条件,随后由回收器择机处理;因此栈退出是引用变化的一部分,不是堆内存立即释放的证明。
# 调用栈中的数据是如何回收的
30 秒速记
- 函数调用时,执行上下文依次压入调用栈,
ESP指向当前正在执行的上下文。 - 函数返回时,引擎将
ESP移回调用者的执行上下文,完成栈帧的逻辑回收。 - 被移出有效范围的栈数据可能暂时保留原值,但已经不能作为有效执行上下文使用。
- 后续函数调用可以直接覆盖这段无效栈空间,因此栈回收不需要逐项清除变量。
- 该机制只解释调用栈数据的释放;栈中引用失效后,对应堆对象仍需由垃圾回收器处理。
函数执行结束后,引擎会把 ESP 移回调用者的执行上下文,从而完成当前栈帧的逻辑回收。 被移出有效范围的数据可能仍保留在栈内存中,但已经不能再作为有效的执行上下文使用。后续函数调用时,这块空间可以直接被新的栈帧覆盖,所以不需要逐个清除其中的变量。需要注意,这只处理调用栈中的数据;如果失效的栈变量曾引用堆对象,堆对象还要交给垃圾回收器处理。
