【每日一面】如何解决内存泄漏
基础问答
问:有没有遇到过内存泄漏?怎么排查处理的
答:前端页面上出现内存泄露,使用 Chrome devtools -> memory 工具排查,选择时间轴分配(Allocations on timeline)功能后开始录制操作,在页面上进行相关组件的操作,停止录制后,查看内存曲线,重点关注内存曲线上升的和下降的位置,如出现只升不降,没有明显回落的区域,再重点操作,重新录制对应位置的操作,逐步缩小定位。对于这种重点关注的区域,可以同时使用堆快照追踪持续增长的对象。对排查出来的点位进行验证的时候,可以通过内存面板的垃圾回收按钮,如下图,回收后如果内存大小还是很高,可以确认是存在无法回收的内存,有泄露的情况。

扩展延伸
内存泄漏是 JavaScript 开发中隐蔽性强且影响严重的问题,尤其在长生命周期应用,如 SPA、后台管理系统中,可能导致页面卡顿、崩溃甚至浏览器无响应的问题。
内存泄露的本质是:本来应该被回收的对象因为意外的引用而保留了下来,导致垃圾回收器无法释放这个对象所占用的内存,使得内存占用持续增长。
垃圾回收机制
JavaScript 采用自动垃圾回收机制,不需要手动释放内存,通过引用计数和标记-清除算法回收不再使用的内存:
- 引用计数:跟踪每个对象被引用的次数,次数为 0 时回收,但是出现循环引用的时候,这个就无法解决了。
- 标记 - 清除:从根对象(如
window)出发,标记所有可达对象,未被标记的对象将被回收,这是目前浏览器主流的算法。
OOM
和内存泄露相关联的还有一个概念,即OOM,内存溢出,指的是在程序申请内存时,发现没有可用内存分配,直接抛出了 OOM 异常。
一般来说,内存泄露是内存溢出的一个原因,但不是唯一的原因,而内存泄露会持续消耗内存资源,最终导致没有可以分配的内存给程序,出现 OOM。
内存泄露的场景
-
意外的全局变量
一般是在非严格模式下出现,使用的变量没有声明,会隐式的绑定到
window对象上,变成持久性的引用,如:function fn() { data = {}; }解决方案:对于这种情况,第一优先的是启动严格模式(现在的框架或项目都是默认为严格模式,通常不需要关注),其次,在现在使用的 es6 规范下,优先使用
let/const 关键字声明,最后如果真的是全局变量,我们应该在确定不再使用后,赋值为null,从而切断对象的引用,让 GC 自动回收。 -
闭包导致内存泄露
对于前端,闭包是一个非常好用的特性,但同时也需要在使用的时候注意,如果创建的闭包被长期使用,则闭包持有的变量就无法释放,一个经典案例就是计时器:
function handleOnClickFac() { let timer = null; return function () { timer = setInterval(() => { console.log('hello'); }, 3000); } } window.clickBtn = handleOnClickFac(); btn.addEventListener('click', window.clickBtn);在这里,每次点击按钮都会触发定时器的创建,但是我们没有清除回收,所以导致这个定时器一直存在,每次点击的时候都会创建一个新的定时器。
这个例子中,包含两个场景,一是闭包,二是定时器。
解决方案:限制闭包生命周期,比如这里在 btn 组件卸载时,销毁闭包,从而实现“不可达”的情况,让 GC 回收,其次需要在使用完成后,清除闭包内的引用,在这个例子中,我们不仅要清楚引用,同时还应该清除定时器,否则依旧存在问题。
-
DOM 元素引用未释放
分两种情况:1. DOM 树中已经没有 DOM 元素了,但是 JavaScript 中还有这个 DOM 元素的链接(变量),2. 事件监听器没有移除,存在 DOM 和监听回调存在互相引用的情况。
// 场景1:DOM已删除但 JS 仍引用 const list = document.getElementById('list'); const data = { element: list }; // 引用DOM元素 document.body.removeChild(list); // list已从DOM树移除,但data.element仍引用它,无法回收 // 场景2:事件监听器未移除 const button = document.getElementById('btn'); button.addEventListener('click', () => { console.log('点击事件'); }); // 按钮被删除后,监听器未移除,导致按钮和回调函数都无法回收解决方案:解决这类场景的核心依旧是在不需要的时候释放引用,不过对于 DOM,还有一种方式就是使用事件委托,从而在子元素删除的时候不受影响。
-
第三方库资源未清理
类似于 Echarts 、地图等库,会要求我们在不使用的时候,调用对应的销毁的 API,如果我们没有调用,这些库创建的临时资源就会持续占用内存,导致内存泄露。
这些场景下的解决方案都是需要我们手动在需要的地方去清除引用,从而使 GC 能够识别并回收内存,通过这些例子也不难发现,虽然在 JavaScript 中不需要我们做类似于 C++ 的手动内存回收,但是依旧需要我们去帮助 GC 更好的判断资源是否需要回收。
检测和分析
内存泄露的检测和分析主要是通过浏览器的内存工具,这里以 Chrome 为例,我们在检测和分析时使用的是 Chrome Devtool Memory 面板:

-
观察时间线上的分配(Allocation Timeline)
- 开启记录后,按照推测的问题,操作页面内容,完成后停止记录,开始自动分析
- 观察只升不降的区域,重复录制该区域对应的操作,查看内存是否确实存在只分配不回收的情况,记录该操作
-
记录堆快照(Heap Snapshot)
-
操作开始前,记录一次初始的堆快照
-
重复第一步记录的操作,拍摄第二次快照,并开启比较(Comparison)模式,重点关注 Delta 和 Retainers 指标(这里对应的面板的中文名是
#增量 和固定装置,翻译不是很准确,这里提供英文界面的图作为参考
Delta 关注持续增长的对象,Retainer 追踪引用该对象的变量
-
-
点击垃圾桶(代表 GC)触发一次 GC,如果 GC 后内存依旧很高,就可以确认是存在内存泄露。
面试追问
-
内存泄露和内存溢出有什么关系?
内存泄露会导致内存溢出,但是内存溢出不一定是内存泄露导致的。
-
常见的内存泄露场景,举个例子?
参考本文【内存泄露的场景】一节
-
Node.js 服务中,长生命周期对象持有短生命周期对象是一个典型的泄露场景,举例并给出排查思路
// 用全局对象做缓存,无淘汰策略 const cache = {}; // 接口每次请求都往缓存加数据 app.get('/api/data', (req, res) => { const key = `data_${req.query.id}`; const largeData = fetchLargeData(req.query.id); // 10MB 数据 cache[key] = largeData; // 只加不删,缓存持续膨胀 res.send(largeData); });由于 cache 没有设置缓存的过期时间、淘汰的方式,导致 largeData 一直被持有,使得内存不断增长。
排查思路:1. Node.js 应用启动时添加
--inspect标志,2. 在 Chrome 浏览器中,访问 chrome://inspect 链接对应的 Node 进程,开始监测,3. 记录初始时的堆快照和多次触发后的堆快照,方式参考【检测和分析】一节,4. 查看 cache 的引用路径以及清理逻辑。5. 设置缓存时间或LRU淘汰策略解决这个问题 -
线上环境 Nodejs OOM 触发报警了,你应该怎么做?
首先,应急止损,滚动重启服务,避免损失扩大,同时增加内存延缓 OOM 时间。
其次,分析问题出现的时间,判断是否可以回滚服务解决。
最后,分析定位根源,按照服务日志和本地排查手段进行。
如果使用的是 k8s 等虚化手段,可以配置服务重启规则,避免人工低效的操作方式。


浙公网安备 33010602011771号