【每日一面】如何解决内存泄漏

【每日一面】如何解决内存泄漏

_

基础问答

问:有没有遇到过内存泄漏?怎么排查处理的

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

扩展延伸

内存泄漏是 JavaScript 开发中隐蔽性强且影响严重的问题,尤其在长生命周期应用,如 SPA、后台管理系统中,可能导致页面卡顿、崩溃甚至浏览器无响应的问题。

内存泄露的本质是:本来应该被回收的对象因为意外的引用而保留了下来,导致垃圾回收器无法释放这个对象所占用的内存,使得内存占用持续增长。

垃圾回收机制

JavaScript 采用自动垃圾回收机制,不需要手动释放内存,通过引用计数标记-清除算法回收不再使用的内存:

  • 引用计数:跟踪每个对象被引用的次数,次数为 0 时回收,但是出现循环引用的时候,这个就无法解决了。
  • 标记 - 清除:从根对象(如 window )出发,标记所有可达对象,未被标记的对象将被回收,这是目前浏览器主流的算法。

OOM

和内存泄露相关联的还有一个概念,即OOM,内存溢出,指的是在程序申请内存时,发现没有可用内存分配,直接抛出了 OOM 异常。

一般来说,内存泄露是内存溢出的一个原因,但不是唯一的原因,而内存泄露会持续消耗内存资源,最终导致没有可以分配的内存给程序,出现 OOM。

内存泄露的场景

  1. 意外的全局变量

    一般是在非严格模式下出现,使用的变量没有声明,会隐式的绑定到 window 对象上,变成持久性的引用,如:

    function fn() {
    	data = {};
    }
    

    解决方案:对于这种情况,第一优先的是启动严格模式(现在的框架或项目都是默认为严格模式,通常不需要关注),其次,在现在使用的 es6 规范下,优先使用 let/const​ 关键字声明,最后如果真的是全局变量,我们应该在确定不再使用后,赋值为 null ,从而切断对象的引用,让 GC 自动回收。

  2. 闭包导致内存泄露

    对于前端,闭包是一个非常好用的特性,但同时也需要在使用的时候注意,如果创建的闭包被长期使用,则闭包持有的变量就无法释放,一个经典案例就是计时器:

    function handleOnClickFac() {
    	let timer = null;
    	return function () {
    		timer = setInterval(() => {
    			console.log('hello');
    		}, 3000);
    	}
    }
    
    window.clickBtn = handleOnClickFac();
    
    btn.addEventListener('click', window.clickBtn);
    

    在这里,每次点击按钮都会触发定时器的创建,但是我们没有清除回收,所以导致这个定时器一直存在,每次点击的时候都会创建一个新的定时器。

    这个例子中,包含两个场景,一是闭包,二是定时器。

    解决方案:限制闭包生命周期,比如这里在 btn 组件卸载时,销毁闭包,从而实现“不可达”的情况,让 GC 回收,其次需要在使用完成后,清除闭包内的引用,在这个例子中,我们不仅要清楚引用,同时还应该清除定时器,否则依旧存在问题。

  3. 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,还有一种方式就是使用事件委托,从而在子元素删除的时候不受影响。

  4. 第三方库资源未清理

    类似于 Echarts 、地图等库,会要求我们在不使用的时候,调用对应的销毁的 API,如果我们没有调用,这些库创建的临时资源就会持续占用内存,导致内存泄露。

这些场景下的解决方案都是需要我们手动在需要的地方去清除引用,从而使 GC 能够识别并回收内存,通过这些例子也不难发现,虽然在 JavaScript 中不需要我们做类似于 C++ 的手动内存回收,但是依旧需要我们去帮助 GC 更好的判断资源是否需要回收。

检测和分析

内存泄露的检测和分析主要是通过浏览器的内存工具,这里以 Chrome 为例,我们在检测和分析时使用的是 Chrome Devtool Memory 面板:
image-20251116153104-s5tb0qs-scjvtike.png

  1. 观察时间线上的分配(Allocation Timeline)

    1. 开启记录后,按照推测的问题,操作页面内容,完成后停止记录,开始自动分析
    2. 观察只升不降的区域,重复录制该区域对应的操作,查看内存是否确实存在只分配不回收的情况,记录该操作
  2. 记录堆快照(Heap Snapshot)

    1. 操作开始前,记录一次初始的堆快照

    2. 重复第一步记录的操作,拍摄第二次快照,并开启比较(Comparison)模式,重点关注 Delta 和 Retainers 指标(这里对应的面板的中文名是 #增量​ 和 固定装置 ,翻译不是很准确,这里提供英文界面的图作为参考

      image-20251116153926-8vvr07i-edfpjyti.png
      Delta 关注持续增长的对象,Retainer 追踪引用该对象的变量

  3. 点击垃圾桶(代表 GC)触发一次 GC,如果 GC 后内存依旧很高,就可以确认是存在内存泄露。

面试追问

  1. 内存泄露和内存溢出有什么关系?

    内存泄露会导致内存溢出,但是内存溢出不一定是内存泄露导致的。

  2. 常见的内存泄露场景,举个例子?

    参考本文【内存泄露的场景】一节

  3. 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淘汰策略解决这个问题

  4. 线上环境 Nodejs OOM 触发报警了,你应该怎么做?

    首先,应急止损,滚动重启服务,避免损失扩大,同时增加内存延缓 OOM 时间。

    其次,分析问题出现的时间,判断是否可以回滚服务解决。

    最后,分析定位根源,按照服务日志和本地排查手段进行。

    如果使用的是 k8s 等虚化手段,可以配置服务重启规则,避免人工低效的操作方式。

系列文章

【每日一面】webpack loader 原理
【每日一面】BOM 是什么
【每日一面】装饰器原理
【每日一面】实现一个深拷贝函数
【每日一面】你怎么理解 Proxy 的
【每日一面】对 Promise.race 的理解
【每日一面】async/await 的原理
【每日一面】手写防抖函数
【每日一面】盒子模型
【每日一面】任意 dom 元素吸顶
【每日一面】获取文字的真实宽度
【每日一面】React Hooks 闭包陷阱

【每日一面】webpack loader 原理 2025-11-17
深度解析 JavaScript 作用域与作用域链 2025-11-21

评论区