◆

JavaScript 内存溢出与内存泄漏详解

2023-02-02 01:370

内存溢出和内存泄漏经常被混着说,其实是因果关系:泄漏是原因,溢出是结果。JavaScript 有垃圾回收,我们不需要手动 free,但回收的判断依据是「还有没有引用能到达这个对象」,只要引用还在,哪怕业务上早就不用了,引擎也不敢回收。这些回收不掉的对象一点点堆积,最终就把可用内存吃光,抛出溢出错误。

单页应用尤其容易中招:页面不刷新,一个泄漏点会随着用户操作反复累积。下面先分清这两个概念,再逐个看常见的泄漏来源和对应的清理写法。

内存溢出

  • 一种程序运行出现的错误
  • 当程序运行需要的内存超过了剩余的内存时,就抛出内存溢出的错误
  • 在浏览器执行以下代码就会造成内存溢出
var obj = {};
for (var i = 0; i < 100000; i++) {
  obj[i] = new Array(10000000);
}
console.log('------');

这段代码之所以必崩,是因为每个数组都被挂在 obj 上,循环期间它们全都是可达的,垃圾回收一个也动不了。堆用满后引擎抛出的通常是 RangeError 或者 Out of Memory,Node 环境下会看到 FATAL ERROR: Reached heap limit Allocation failed,浏览器里则是标签页直接崩掉,最后那行 console.log 永远不会执行。

需要区分的是,溢出不一定意味着有泄漏。像上面这种一次性申请超量内存属于用法问题,改成分批处理或者流式处理就好。真正需要排查的是另一种:内存占用随着使用时间单调上涨,回到同一个页面、做同一个操作,占用却一次比一次高,这才是泄漏。

常见的 GC 判断依据

现代引擎用的是可达性分析(mark-and-sweep),从一组根对象(全局对象、当前执行栈上的变量等)出发遍历所有引用,遍历不到的才回收。所以判断一个对象会不会被回收,只需要问一句:从根出发还能不能找到通往它的引用链。下面几类泄漏本质上都是同一个原因——某条我们没意识到的引用链还连着。

内存泄漏

什么叫内存泄漏:不再用到的内存,没有及时释放,内存泄漏积累多了就容易导致内存溢出 常见的内存泄露意外的全局变量

全局对象(浏览器里是 window)本身就是 GC 的根,挂在它上面的东西活到页面关闭为止。危险的地方在于非严格模式下漏写 var/let 会隐式创建全局变量,看起来像局部变量,实际不是。下面这个函数每调用一次都会把一个大数组挂到全局上,而且后一次会覆盖前一次的引用——覆盖掉的那份能回收,但只要函数还在被调用,总有一份大数组常驻内存:

function fn() {
  b = new Array[1000000]();
  a = []; // 没有var定义,这时候a变量是全局的
}

fn();

防住这类问题最省事的办法是开严格模式:'use strict' 下给未声明的变量赋值会直接抛 ReferenceError,问题在开发阶段就暴露了。ES Module 默认就是严格模式,所以用现代构建工具的项目基本不会踩到这一条。

没有及时清理的计时器或回调函数

setInterval 注册的回调会被定时器系统一直持有,回调又持有它闭包里引用的所有变量。也就是说只要不 clearInterval,回调里用到的 DOM 节点、组件实例、请求数据全都回收不掉。组件卸载时忘记清理定时器是单页应用里最高频的泄漏来源:

// 没有及时清理的计时器
var fx = setInterval(function () {
  console.log('fx');
}, 1000);
clearInterval(fx); // 及时清理计时器

同类的还有事件监听。addEventListener 挂上去的处理函数如果不 removeEventListener,只要目标节点还在(尤其是挂在 window、document 上的),处理函数和它捕获的整个作用域都跑不掉。写法上有两个技巧能减少遗漏:一是注册和注销成对写在一起,二是用 AbortSignal 一次性撤销一批监听。

const controller = new AbortController();

window.addEventListener('resize', onResize, { signal: controller.signal });
window.addEventListener('scroll', onScroll, { signal: controller.signal });

// 组件卸载时一行搞定,不用逐个 removeEventListener
controller.abort();

闭包

闭包本身不是泄漏,它是语言特性。问题出在闭包的存活期比我们预期的长:只要返回出去的内部函数还被引用,它捕获的整个外层作用域就都活着,哪怕内部函数只用到了其中一个变量:

function fn1() {
  var arr = new Array[100000]();

  function fn2() {
    console.log(arr.length);
  }

  return fn2;
}

var f = fn1();
f();
f();
f = null; // 让内部函数成为垃圾对象,释放闭包

把 f 置为 null 之所以有效,是因为断掉了从全局到闭包的最后一条引用链,arr 随之变得不可达。实际项目里我们很少这样手写置空,更常见的做法是不要把大对象留在闭包能捕获的位置——需要的数据算完就丢,只让闭包持有真正要用的那一小部分。

在 IE6,7,8 中的问题:由于 IE 的 JS 对象和 DOM 对象使用不同的垃圾收集方式,因此闭包在 IE 中会导致一些问题。就是内存泄漏的问题,也就是无法销毁驻留在内存中的元素。

function fx() {
  var oDiv = document.getElementById('oDiv');
  var text = oDiv.innerHTML;
  oDiv.onclick = function () {
    alert(text);
  };
  oDiv = null; // 解除引用,等待垃圾回收
  alert(oDiv); // null,说明已经不存在了
}

fx();

老 IE 的 DOM 用引用计数回收,JS 对象用标记清除,两套机制互相看不到对方的引用,DOM 节点和 JS 闭包一旦形成循环引用就都回收不掉,只能像上面这样手动置空来打破环。现代浏览器的 GC 统一处理两者,能识别并回收循环引用,所以这段置空代码在今天已经没有必要,留着当历史背景看即可。

游离 DOM 节点

现在更常见的一类泄漏是「游离节点」(detached DOM):节点已经从文档树里移除了,但 JS 里还留着变量指向它。此时它不在页面上,用户看不见,内存却还占着,而且它的整棵子树都跟着留下:

const cache = [];

function addRow() {
  const tr = document.createElement('tr');
  document.querySelector('tbody').appendChild(tr);
  cache.push(tr); // 缓存了节点引用
}

function clearTable() {
  document.querySelector('tbody').innerHTML = '';
  // cache 没清,所有 tr 变成游离节点,一个都回收不掉
  cache.length = 0; // 加上这行才真正释放
}

需要用对象关联 DOM 节点时,WeakMap 是更安全的容器:它对键持弱引用,节点被移除后条目会自动消失,不需要我们手动清理。

const meta = new WeakMap();
meta.set(node, { createdAt: Date.now() });
// node 被移除且无其他引用时,这条记录随之被回收

怎么定位泄漏

排查泄漏靠猜效率很低,用 Chrome DevTools 的 Memory 面板有两条比较可靠的路径:

  1. Heap snapshot 对比。先做一次操作前的快照,反复执行怀疑有问题的操作(进入再离开某个页面若干次),再做一次快照,用 Comparison 视图看哪类对象的数量只增不减。在 Summary 里搜 Detached 能直接列出游离节点。
  2. Allocation instrumentation on timeline。录制一段时间的分配情况,图上留下没有被回收的柱条就是泄漏点,点开能看到分配时的调用栈,直接定位到代码行。

判断标准是趋势而不是绝对值:每次强制 GC 之后基线内存仍然逐次抬高,就说明有东西没释放。反过来,占用一直在涨但 GC 后能回落到同一水平,那只是正常的分配节奏。

日常防范清单

写代码时把这几条当成习惯,能挡掉绝大部分泄漏:定时器和事件监听在创建的地方就想好在哪里销毁;组件的卸载钩子里统一清理副作用(定时器、监听、WebSocket、未完成的请求);缓存要有上限或过期策略,别做成只增不减的 Map;关联 DOM 或临时对象优先用 WeakMap/WeakSet;不要把大对象留在长生命周期的闭包里。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEJavaScript 内存溢出与内存泄漏详解加载中