news 2026/10/15 13:40:59

JavaScript内存管理详解:从垃圾回收机制到内存泄漏排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaScript内存管理详解:从垃圾回收机制到内存泄漏排查

1. 先把话说在前面:JS的内存管理到底在管什么

在 JavaScript 里写业务时,最省心的一件事就是不用手动释放内存。你 new 一个对象、定义一个数组、用一个闭包,运行时都在背后帮你做内存的分配和回收,本地没有free,也没有delete这种“杀对象”的语法。有人说这是“自动管理”,有人说这是“垃圾回收”,但很多人没意识到:自动回收不等于自动正确,它只负责收那些已经没有任何引用的对象,至于业务上那个对象还该不该被引用、该不该被留在某个闭包里,运行时一概不知。

内存问题是我在日常排障中遇到频率最高的一类,尤其是页面越用越卡、切路由后内存不降、服务端进程内存只涨不跌。这些问题表面看是回收器不够聪明,根源却是代码中留下了不该存在的引用链。只要有一个全局变量把你本来已经关掉的页面组件引用住,整个组件树就永远“可达”,垃圾回收器只能放行它,让它一直在堆里躺着。

所以今天这篇文章写的是内存管理“如何工作”,但我会刻意把重点放在三个层面:栈和堆怎么划分、一个对象从出生到回收要经过什么、垃圾回收器用哪些策略来决定“谁该死”。最后配合排查内存泄漏的完整思路,以及我处理过的几个典型事故复盘。适合前端开发者、跨端开发、以及服务端 JavaScript 开发者阅读。不打算把引擎源码捞出来逐行分析,但看完之后,你至少能在写代码的时候判断:这个变量会造成泄漏吗?这个闭包会不会留住一堆不该留的数据?为什么内存只涨不降?

2. 底层机制拆解:内存分配与回收的本质

2.1 值类型与引用类型:一个存在栈上,一个住在堆里

很多人第一次接触“栈”和“堆”是在数据结构课,但在 JS 运行时里,这两个词更像内存区域的分工。基础类型的值,比如 number、string、boolean、null、undefined,通常分配在栈区。栈区的特点是跟随函数调用自动入栈和出栈,速度极快,作用域一结束,这些值就直接从栈顶弹出,不需要垃圾回收器介入。

而对象、数组、函数这些引用类型,则生活在堆区。堆区是一大片动态分配的内存,大小不固定,生命周期也不能简单地跟着某个函数走。栈上保存的只是指向堆区地址的“引用”,也就是指针。我们平时说“把一个对象赋给另一个变量”,本质上只是复制了引用,并没有复制对象本身。

let user = { name: '小明' }; let copy = user; copy.name = '小红'; console.log(user.name); // '小红'

这个例子很容易解释清楚。user和copy在栈上是两个独立变量,但它们指向同一个堆对象,修改copy.name其实就改了同一个对象的数据。堆区内存什么时候回收,不能靠变量出栈来判断,因为堆对象可能还被其他变量、其他函数、其他闭包引用着。

由此可以带出一个非常关键的判断:JS 里判断“要不要回收一个对象”,看的不是“这个变量还在不在作用域里”,而是“还有没有一条可达的引用链能从根节点走到这个对象”。顺着这条线索继续往下看。

2.2 从产生到回收:一个对象的完整生命周期

一个对象从被创建出来,到被回收,通常会经历这样几个阶段:

第一,创建阶段。执行let obj = { data: new Array(10000).fill(0) }时,运行时先在堆区分配一块空间给这个对象,然后把地址写入栈上的obj。

第二,使用阶段。对象被传入函数、被作为返回值、被挂到全局对象上、被闭包捕获,这些操作的本质都是往不同的“引用”里写入这个对象的地址。只要还有一个引用存在,对象就不会被回收。

第三,失去引用阶段。如果obj = null,那原来那条从当前作用域到对象的路径就断了。但对象不一定马上被回收,它只是“不可达”了,垃圾回收器会在某个合适的时机把它标记为垃圾,然后清理掉。

我有一次给团队做分享时用过一个不太严谨但很好懂的类比:可以把对象想象成一个正在酒店里开会的客人。房间号写在接待本上,本子上的记录就是引用。只要有一个接待员(某条引用链)知道他在哪个房间,酒店就得让房间一直保留;当所有接待员都忘了这个人,他住的房间就成了“无主房”,保洁阿姨(垃圾回收器)会在空闲时来收拾。

问题是,内存回收的“空闲时”不好把握。运行时得在“少停用户交互”和“尽快释放内存”之间做平衡,于是垃圾回收器常见做法是“分代回收”。这个后面细说。

2.3 闭包与回调:最容易延长对象生命周期的隐形引用

闭包可以说是 JS 内存管理里最容易踩坑的设计。它的语法很美,能让你在函数外访问函数内的变量,但代价就是被捕获的变量会随着闭包一起存活。

function createBigCache() { const bigData = new Array(100000).fill('x'); return function () { return bigData.length; }; } const getLength = createBigCache();

这段代码里,bigData按普通函数理解应该在createBigCache执行完就被释放,但因为返回的函数还在被外部变量getLength持有,而内部函数引用着bigData,所以bigData会一直活着。只要getLength不被释放,这十万个字符串就永远占着堆空间。

事件监听器也是同一个道理。dom.addEventListener('click', handler)会把handler和它内部引用的变量都绑到dom上。如果dom本身已从文档里移除,但没有removeEventListener,浏览器仍然可能判断dom对象和handler都是可达的,因为事件系统内部还有一条引用链。这样“分离”的 DOM 节点会堆积,形成前端里非常经典的一种泄漏。

当然了,闭包本身不是洪水猛兽,该用还得用。关键是要意识到它所带来的生命周期延长,以及反推“这个闭包会被谁引用、能活多久”。这一段先建立这个概念,后面的排查案例全部围绕这个展开。

3. 垃圾回收策略:标记-清除与分代回收

3.1 为什么引用计数在 JS 里行不通

很多讲 GC 的文章会先介绍引用计数:每个对象身上记一个“被引用次数”,有人引用它时加一,引用解除时减一,次数归零就立即回收。听起来很合理,但它有一个致命问题:循环引用。

let a = {}; let b = {}; a.next = b; b.prev = a; a = null; b = null;

此时a和b已经不可达了,但它们两个对象互相引用,引用计数都不会归零。如果垃圾回收器只靠引用计数,这两个对象就会永远站在内存里,形成活死人式的泄漏。所以现代 JavaScript 运行时基本不会把引用计数当作主算法,最多作为某些局部结构的辅助手段。

引用计数还有一个缺点:每次给对象赋值、传参都要同步修改计数器,固定消耗性能,而且频繁的大对象引用变化会让计数器抖动。在追求吞吐和低延迟的 Web 场景里,这种同步开销并不划算。

3.2 标记-清除:从根出发,顺藤摸瓜

真正的通用做法是标记-清除(Mark-Sweep)。它的核心逻辑是:先把所有能到达的根节点找出来,然后从根节点出发,沿着对象的引用关系一层一层遍历,所有碰到过的对象全都打上“存活”标记;一次遍历结束后,没有被标记的对象就是垃圾,直接回收。

根节点包括哪些?简单说:全局对象、当前正在执行的函数调用栈里的局部变量和参数、还挂在定时器或事件队列里的回调、以及其他由运行时所维护的内部结构。你可以把它理解成“系统一开始就攥在手里的那些根”。

标记-清除最漂亮的地方在于它不关心对象之间怎么互相引用。a和b循环引用也没关系,只要从根节点出发已经摸不到它们,它们照样会被当成垃圾。这正解决了引用计数的最大痛点。

但标记-清除也不是没有成本。标记阶段需要遍历整个对象图,清除阶段可能要移动或整理内存,这个过程发生时程序的其他执行会被暂停,术语上叫“Stop The World”。如果堆里的对象数量巨大,一次 GC 会造成明显的卡顿。为了缓解这个问题,引擎在实现上做了大量优化,比如增量标记、并发标记、并行清除等,让停顿时间摊开到多个小片段里。这也是为什么我们在浏览器里感知上的 GC 停顿越来越不明显,但写代码时仍然要尽量避免制造超大对象图。

3.3 分代回收:新生代、老生代和弱引用

一直有观点说“ 90% 的对象都活不过几次 GC”。很多前端对象是临时计算出来的,比如事件处理函数里的配置对象、渲染流程里的临时数组,用完就应该被丢弃。所以现代运行时把堆分成新生代和老生代两个区域。

新生代里放刚创建的对象,空间小,回收频率高。新生代的回收思路通常采用复制式算法:把存活对象从一个半区复制到另一个半区,剩下所有对象一次性清空。因为新生代对象大部分活不久,所以复制存活对象的成本很低,比较高效。当某个对象在新生代里撑过几轮回收,或者它的内存占用达到一定阈值,它就会被晋升到老生代。

老生代的空间更大,里面的对象生命周期长,回收频率低。老生代如果没有足够大的“新鲜血液”,内存里的对象会越堆越多。这里的回收更多依赖标记-清除以及复杂度更高的整理算法。

理解分代之后,一个很重要的实用结论就是:如果业务代码制造了大量“长生命周期的对象”,比如无限增长的缓存、常驻的闭包、被全局变量引用的 DOM 树,那么这些对象都会被送到老生代,让老生代越来越膨胀,最终导致 GC 时间变长,页面或服务端进程出现周期性停顿时长增加。

为了配合这套机制,JS 提供了WeakMap和WeakSet。它们的“弱引用”特性很有意思:键(或值)不会阻止垃圾回收器回收对象。换句话说,如果你把一个对象放进WeakMap,当这个对象没有其他强引用时,即使WeakMap里还存在这张表,GC 照样可以把对象收走。这个特性特别适合做缓存、事件监听元数据、以及一切“不应该影响对象存亡”的附加信息。

const cache = new WeakMap(); let target = { id: 1 }; cache.set(target, '缓存数据'); target = null; // 此时 cache 里对应的键值对会随 target 的回收自动消失

有了这个基础,后面在讲缓存类泄漏时,你会更清楚为什么该用WeakMap而不是普通Map。

4. 内存泄漏定位:从一次卡顿页面开始的排查

4.1 先看症状:内存问题不只从数字看出来

做内存排查,不能光盯着“内存占用 2GB”这种数值。数值高不代表泄漏,很多应用本来就要用大量内存;反而更值得关注的是“趋势”和“稳定点”。如果页面打开后内存一路向上,切到后台再回来看,内存没有回到正常水位;或者同一个操作重复执行一百次后,内存比第一遍时高出一大截,这基本就是有对象在持续堆积。

我有一个很具体的经验:内存问题出现时,用户往往不是直接说“卡”,而是说“用着用着就白屏了”“切页面越来越慢”。这在排查时非常有引导性。因为 GC 停顿会拖住主线程,用户操作越多,停顿造成的卡顿感越明显。只要抓住“重复一个操作 -> 内存持续上涨 -> 操作变慢”这个链路,通常就能圈定泄漏发生的模块。

浏览器开发者工具里的 Performance 面板可以帮助观察 GC 活动。录制一段操作过程,你会在时间线上看到很多锯齿状的内存变化:上升段是分配内存,下降段是 GC 回收。如果锯齿的“底”一直在抬高,说明每次操作都会残留一部分内存没有释放,这就是典型的泄漏。

4.2 用快照对比找出漏点:两个 Heap Snapshot 的对照实验

最直接的定位方法是拍两组堆快照。核心步骤是:

  1. 先进入一个稳定状态,做一次堆快照,记作 Snapshot A。
  2. 连续执行同一种操作,比如打开一个弹窗再关闭、切到一个详情页再返回。
  3. 等待一段时间让 GC 跑完,再次拍堆快照,记作 Snapshot B。
  4. 在快照列表里选择“比较”,过滤出 Snapshot B 比 Snapshot A 新增的对象。

新增对象里通常有一个明显的“大头”,可能是某个组件实例、某个内部类实例、或某个字符串数组。每一条都可以展开看它的“保留路径”,也就是通过哪些引用链被根节点持有。顺着保留路径,几乎一定能找到某个全局变量、某个事件监听器、或者某个长期存活的闭包。

我印象最深的一次记忆是,我们某后台系统里一堆弹窗关闭后不及时销毁。用快照比较后发现,内存里多了很多 DOM 节点和组件实例,保留路径完整指向全局缓存列表里的“历史弹窗配置”。修复方式就是在弹窗关闭时清理全局配置,并移除弹窗 DOM。两次快照之间只改了几行代码,内存曲线马上恢复稳定。

这里有一个技巧要提醒:拍摄快照的最佳时机是“GC 之后”,不是操作刚结束就立刻拍。因为操作结束的瞬间,仍然有很多对象正处于即将被回收但还没到回收点的状态,这时快照里的噪音很大。你可以在操作完成后稍等片刻,手动触发一次 GC,然后再拍摄第二张。如果工具里不好手动触发,就多等几秒,让运行时自己跑完一轮 GC 再拍。

4.3 服务端 JavaScript 的排查思路:没有页面,就看曲线

服务端进程没有“页面卡顿”这种直观感受,最早出现的通常是内存只涨不跌,甚至被监控平台告警。如果没有开启自动重启,进程会一直吃到系统内存上限,最后被宿主环境杀掉。这种情况如果只盯一次内存快照,很难区分“正常业务缓存”和“真泄漏”,必须结合时间来做。

服务端排查我会分三步走:

第一步,看内存趋势。用运行时自带的内存统计接口,比如process.memoryUsage(),定期采样并记录。重点关注heapUsed这个值,它代表 JS 堆当前实际使用的内存。如果heapUsed随着请求量增加,等请求高峰过后仍然回不到之前的位置,就有泄漏嫌疑。

第二步,生成堆快照。服务端运行时通常可以在进程运行中通过命令行参数或调试协议把当前堆快照导出,再传到本地用浏览器开发工具打开分析。分析和浏览器端快照对比一样,找“新增对象”和“保留路径”。

第三步,做二分定位。在怀疑泄漏的业务模块里,手动把某个功能开关打开/关闭,观察内存曲线的斜率变化。比如每分钟跑一次定时任务,如果关掉后内存曲线不再上升,那定时任务所在的模块大概率是元凶。

服务端还有一个很容易忽略点:外部依赖和原生绑定的内存。有些问题根本不在于 JS 堆,而是库创建了自己的缓冲区、线程、文件句柄。遇到这种问题,堆快照看不到,就不展开讨论了,但你也得知道。

5. 三个真实且常见的内存事故复盘

5.1 事件监听重复注册:组件关了,回调还挂在全局上

一次在做一个数据可视化大屏时,页面里需要轮询刷新图表数据。最初实现的思路是每次进入可视化模块创建一个新的轮询函数,并注册给全局事件总线;退出模块时,只清除了定时器,没有把监听函数从全局事件总线移除。结果用户反复切换大屏和主菜单,每切换一次,老的事件回调就留在内存中一次,新的定时器又不断触发回调,堆里堆积了几十份图表实例和轮询闭包。

这类问题最迷惑人的地方是,从表面看,用户每次进入看到的数据和图表都是正常的,完全不觉得有哪些代码在重复执行。直到打开堆快照,发现同一类型的轮询对象实例数量比实际应该存在的多了很多。

修复方法也很简单,关键是“对称清除”:在注册的同时,保存函数引用;在关闭销毁时,用同一个引用去移除监听。很多人注册时习惯直接写匿名函数,这会导致之后想移除也没有“把手”,这是最常见的坑。正确做法是用命名函数或变量保存:

const handler = () => { refresh(); }; eventBus.on('refresh', handler); // 退出时 eventBus.off('refresh', handler);

5.2 闭包把大对象“钉”在内存里:一个小缓存拖垮整个页面

另一个项目是做管理后台的列表页,列表里有几千条记录,业务方希望切换分页时不要重新请求,前端做了一个 Map 当缓存。这个需求很常见,但因为 Map 本身对键持强引用,只要不手动 delete,缓存里的每条旧数据都会一直存在。随着用户翻页翻了几百页,Map 里积累了大量完整数据对象,最终列表页的性能一落千丈。

这个问题的本质,是完全违背了“对象生老病死”的规律:本来一页数据只应该活到用户不再看它为止,结果被一个无限增长的 Map 给强行续命了。修复方案是把缓存容器换成WeakMap,并调整使用方式:如果键对象本身也是一个有短生命周期的对象,那么当没有其他引用时,缓存会自动消失,不会阻止回收。

不过这里要特别说明:WeakMap的键不能是基本类型,而且不支持遍历和统计数量。做缓存时必须想清楚,这个缓存到底该以谁为键。另外,如果真要做“保留最近 50 条”这种有上限的 LRU 缓存,WeakMap不一定合适,得自己在封装层维护淘汰策略,或者直接用 Map 并显式删除。

5.3 大数据列表造成的 GC 停顿:不是泄漏,但比泄漏更难受

还有一种情况特别容易误导人:明明没有泄漏,内存占用也稳定,但页面照样卡。我遇到过某表格页一次性渲染了上万行数据的同时,还在每行里做字符串拼接、数组排序、格式换行。表面看,操作完成后内存会回收,但因为数据量太大,GC 每次工作都要扫描一个非常庞大的对象图,遇到极限场景时明显感受到主线程停顿。

这种情况的源头是“一次性分配太多短期对象”。即使这些对象最后都被回收,在高频 GC 的阶段依然会吃掉大量时间。优化思路不是从“怎么让回收更快”入手,而是想办法减少分配:

  • 把全局一次性大列表改成分批渲染或虚拟滚动,只保留可视区数据。
  • 避免在渲染函数里频繁创建中间数组和对象,能复用就复用。
  • 用流式处理代替一次性 full array 的操作。
  • 如果某些大数组确实在一个生命周期内必需,尽量保持引用稳定,不要反复替换整个数组引用触发新的分配。

那次改动后,尤其是把一次性渲染改为按页渲染,页面内存和 GC 停顿都明显下降。这件事让我对“内存管理如何工作”有了更实际的理解:回收器再努力,也弥补不了代码凭空制造大量垃圾的问题。

6. 落地方案与团队约定

6.1 十条约法式编码建议

如果你不想每次都靠堆快照才能发现内存问题,最好的办法是在编码阶段就挡一层。下面十条是我在做代码评审时经常会提的建议,每一条背后都有真实的踩坑经历:

  1. 不要往全局对象上随意挂业务缓存和临时组件实例。全局变量会让对象生命周期无限延长,而且很难追踪。
  2. 注册事件监听时保存函数引用,不使用匿名函数,确保销毁阶段可以移除。
  3. 创建setInterval和setTimeout后,要在明确不用时清除;页面关闭和模块销毁时都要检查。
  4. 弹窗、抽屉、列表项这类可能被反复创建销毁的组件,要确认销毁逻辑也执行,视觉上关闭不算销毁。
  5. 大型对象在不再需要时可以考虑置空或者重新赋值,虽然不是万能钥匙,但能帮助引用链尽快断开。
  6. 用WeakMap和WeakSet做附加数据和缓存,不要让缓存反过来决定业务对象生不生死不死。
  7. 闭包捕获的数据尽量缩小范围,不要在闭包里长期引用一个巨大对象图的一小部分数据。
  8. DOM 节点移除后,检查是否还有代码持有该节点的引用,特别是事件回调、全局数组、数据映射表。
  9. 大量数据展示优先考虑分页、分批、虚拟滚动,不要为了省事一次性把全量数据渲染出来。
  10. 建立内存检查习惯,尤其在频繁切换页面、反复开关弹窗、反复请求接口的模块里,跑一次内存曲线观察。

6.2 关于排查和预防的一点个人看法

我见过很多团队,平时完全不关注内存,等到线上报警才开始查。这倒也没什么可怪罪的,毕竟功能迭代压力大,内存问题又多发生在特定操作路径上。但如果能在一开始就建立“生命周期意识”,很多事故根本不会发生。

所谓生命周期意识,其实就是写代码前问一句:这个对象活着的时候对谁有用?它该死的时候,有没有代码挡在回收路上?把这个问题变成肌肉记忆之后,你会发现自己写出来的代码天然更整洁,因为你会主动减少全局状态、主动清理副作用、主动拆分长生命周期的内部引用。

另外,排查内存问题一定要沉住气。很多问题不是看第一眼快照就能看出来的,可能要反复对比、反复试。别指望有一个命令能把所有泄漏一键定位,堆快照对比、保留路径分析、代码走读三者配合,才是完整的路子。如果最后确认某个模块实在没法做到完全无泄漏,那就给进程或者页面加一道定期重启或上线的兜底策略,这也是一种务实选择。

这轮讲下来,回到题目那句话:JS 的内存管理是如何工作的?其实它就是一个全自动的“房间管家”,不停地在分配空间、清扫无主对象、调整存储布局之间循环。作为开发者,我们不需要伸手去捡垃圾,但至少要能看懂哪些行为会造成“无人认领又没法清扫”的死角,以及怎样在写业务代码时尽量不让垃圾堆积到影响主流程。记住这一点,内存就不再是启动时的玄学,而是你可控的一部分。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 13:40:15

APP开发者必读:软件著作权登记代码规范与避坑指南

1. 为什么APP开发者必须重视软件著作权登记去年有个做社交APP的团队找我咨询,他们花了大半年开发的APP在上架前被卡在软件著作权登记环节。原因是提交的源代码中存在第三方SDK的版权声明,导致审核被驳回。最后不得不重新整理代码,耽误了整整三…

作者头像 李华
网站建设 2026/10/15 13:39:23

SpringBoot仓库管理系统开发与优化实践

1. 项目概述这个基于SpringBoot的仓库管理系统毕业设计项目,是一个典型的Java Web应用开发案例。作为企业信息化建设的基础模块,仓库管理系统(WMS)在实际业务场景中承担着物资流转、库存控制、供应链协同等核心职能。我去年指导过3个类似项目&#xff0c…

作者头像 李华
网站建设 2026/10/15 13:38:39

ArrayList扩容原理:grow方法深度解析与性能优化

1. ArrayList不是“无限容器”,它的扩容逻辑才是真功夫很多人刚学Java集合时,看到ArrayList能add()一百次、一千次都不报错,就下意识觉得它是个“无限容量”的动态数组。我带过不少新人做性能调优项目,他们第一次看到线上服务GC频…

作者头像 李华
网站建设 2026/10/15 13:37:36

Vue 3 shallowRef性能优化实战指南

1. 为什么需要关注Vue 3的shallowRef性能优化在大型前端项目中,性能优化往往成为决定用户体验的关键因素。Vue 3的响应式系统虽然强大,但默认的深度响应式在某些场景下会造成不必要的性能开销。这就是shallowRef的设计初衷——它提供了一种更轻量级的响应…

作者头像 李华
网站建设 2026/10/15 13:37:19

Spring Boot驾校管理系统架构设计与实战优化

1. 项目背景与核心需求驾校管理系统是传统驾驶培训行业数字化转型的关键载体。随着近年来学车人数持续增长(2023年全国驾培市场规模已突破2000亿元),传统纸质档案Excel统计的管理方式暴露出诸多痛点:学员信息分散在多个Excel表中&…

作者头像 李华