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

基础问答

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

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

扩展延伸

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

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

垃圾回收机制

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

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

OOM

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

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

内存泄露的场景

  1. 意外的全局变量

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

    javascript 复制代码
    function fn() {
    	data = {};
    }

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

  2. 闭包导致内存泄露

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

    javascript 复制代码
    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 和监听回调存在互相引用的情况。

    javascript 复制代码
    // 场景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 面板:

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

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

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

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

      Delta 关注持续增长的对象,Retainer 追踪引用该对象的变量

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

面试追问

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

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

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

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

  3. Node.js 服务中,长生命周期对象持有短生命周期对象是一个典型的泄露场景,举例并给出排查思路

    javascript 复制代码
    // 用全局对象做缓存,无淘汰策略
    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 等虚化手段,可以配置服务重启规则,避免人工低效的操作方式。