前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

JS内存泄漏与垃圾回收机制完全梳理

首页2021-01-16 15:55:24Front-End
垃圾回收内存泄漏JavaScript

一个后台管理系统的页面,来回切几十次路由之后开始卡,任务管理器里那个标签页占了一两个 G。刷新一下又好了,第二天用户继续来提工单。这类问题的定位难度不在于修,而在于你根本不知道该从哪儿看起。

要看懂内存泄漏,得先看懂 JS 引擎是按什么规则回收内存的。这篇从标记清除和引用计数两种算法讲起,把「什么样的对象算垃圾」这条判定线说清楚,再回过头看那些经典的泄漏写法为什么会逃过回收,最后给一条能落地的 Chrome DevTools 排查路径。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 内存泄漏的准确定义,以及它和内存占用高的区别
  • JS 为什么必须有垃圾回收机制
  • 标记清除算法的判定依据是可达性而不是引用数
  • 引用计数的工作方式与它天生的缺陷
  • 循环引用在现代引擎里到底还会不会泄漏
  • 四类高频泄漏写法:全局变量、定时器、闭包、游离 DOM
  • 用 Chrome DevTools 的三次快照法定位泄漏源
  • 面试被问到这块该怎么组织回答

# 一、内存泄漏到底指什么

程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须供给内存。

对于持续运行的服务进程,必须及时释放内存,否则内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。不再用到的内存没有及时释放,就叫做内存泄漏。

这里有个容易混淆的点。内存占用高不等于内存泄漏。你打开一个渲染了十万行表格的页面,占几百 M 很正常,只要关掉页面内存能降下来,那就不是泄漏。泄漏的判定标准是「这块内存已经没有任何代码路径能再用到它,但引擎认为它还有用,所以不敢回收」。

有些语言(比如 C 语言)必须手动释放内存,程序员负责内存管理。这很麻烦,所以大多数语言提供自动内存管理,减轻程序员的负担,这被称为垃圾回收机制。

JavaScript 的垃圾回收机制会定期(周期性)找出那些不再用到的内存,然后释放掉。注意是「定期」,不是「立刻」。你把一个变量置成 null 的那一瞬间内存并不会马上降,得等下一轮 GC 跑起来。这一点在你盯着 DevTools 的内存曲线做验证时特别容易误判。

# 二、为什么必须有垃圾回收

字符串、对象和数组没有固定大小,只有当它们的大小已知时,才能对它们进行动态的存储分配。JavaScript 程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。

只要像这样动态地分配了内存,最终都要释放这些内存以便它们能够被再用,否则 JavaScript 引擎将会消耗完系统中所有可用的内存,造成系统崩溃。

JS 不像 C/C++,它有自己的一套垃圾回收机制(Garbage Collection)。引擎可以检测到何时程序不再使用一个对象,当它确定一个对象是无用的时候,就可以把它所占用的内存释放掉。例如:

var a = "before";
var b = "override a";
var a = b; // 重写 a
@前端进阶之旅: 代码已经复制到剪贴板

这段代码运行之后,"before" 这个字符串失去了引用(之前是被 a 引用的),引擎检测到这个事实之后,就会释放该字符串的存储空间以便这些空间可以被再利用。

顺着上面聊,这段用的是 var,是 2021 年前后很常见的写法。现在一般会用 const 加解构来替代,一方面 const 能在编译期就拦住重复声明(上面那个 var a 声明两次在 const 下直接报错),另一方面块级作用域会让变量更早离开作用域,也就更早变成可回收状态。原理没变,只是作用域粒度更细了。

# 三、策略一:标记清除

这是 JavaScript 中最常用的垃圾回收方式。

当变量进入执行环境时,就标记这个变量为「进入环境」。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。当变量离开环境时,则将其标记为「离开环境」。

整个过程可以拆成三步:

  • 垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记
  • 去掉环境中的变量、以及被环境中的变量引用的变量的标记
  • 此后仍然带着标记的变量将被视为准备删除的变量,因为环境中的变量已经无法访问到它们了

最后垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收它们所占用的内存空间。

这套算法真正的判定依据是可达性,不是引用数。

引擎内部维护了一组「根」(GC roots),包括全局对象、当前调用栈上的局部变量、以及一部分内置对象。从这些根出发能沿着引用链走到的对象就是活的,走不到的就是垃圾。这个区别在下一节看引用计数的时候会变得很关键。

# 四、策略二:引用计数

语言引擎有一张引用表,保存了内存里面所有资源(通常是各种值)的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到了,因此可以将这块内存释放。

引用计数示意图,左下角两个没有任何引用的值可以被释放

上图中,左下角的两个值没有任何引用,所以可以释放。

const arr = [1, 2, 3, 4];
console.log("hello world");
@前端进阶之旅: 代码已经复制到剪贴板

上面的代码中,数组 [1,2,3,4] 是一个值,会占用内存。变量 arr 是仅有的对这个值的引用,因此引用次数为 1。尽管后面的代码没有再用到 arr,它还是会持续占用内存。

如果增加一行代码,解除 arr 对 [1,2,3,4] 的引用,这块内存就可以被垃圾回收机制释放了。

let arr = [1, 2, 3, 4];
console.log("hello world");
arr = null;
@前端进阶之旅: 代码已经复制到剪贴板

上面代码中,arr 重置为 null,就解除了对 [1,2,3,4] 的引用,引用次数变成 0,内存就可以释放出来了。

所以并不是有了垃圾回收机制,程序员就可以完全不管内存。那些很占空间的值,一旦不再用到,你还是需要检查是否存在对它们的引用,必要时手动解除。

# 引用计数的缺陷

引用计数的问题在于,它只看「有几个人指着我」,不看「这些人自己还活不活着」。

function problem() {
  var objA = new Object();
  var objB = new Object();

  objA.someOtherObject = objB;
  objB.anotherObject = objA;
}
@前端进阶之旅: 代码已经复制到剪贴板

在这个例子中,objA 和 objB 通过各自的属性相互引用,这两个对象的引用次数都是 2。在采用引用计数的策略下,函数执行完成之后这两个对象虽然离开了作用域,但它们的引用次数永远不会降到 0,于是永远不会被回收。这样的相互引用如果大量存在,就会导致明显的内存泄漏。

手动切断循环引用可以解决这个问题:

function problem() {
  var objA = new Object();
  var objB = new Object();

  objA.someOtherObject = objB;
  objB.anotherObject = objA;

  // 函数结束前手动断开,引用计数立刻归零
  objA.someOtherObject = null;
  objB.anotherObject = null;
}
@前端进阶之旅: 代码已经复制到剪贴板

这里要补一句原文没说清楚的事。上面这段手动断开的写法,在今天的主流浏览器里其实已经不需要了。

V8、SpiderMonkey、JavaScriptCore 这些现代引擎用的都是标记清除(配合分代回收、增量标记等优化),判定依据是从 GC roots 出发的可达性。objA 和 objB 虽然互相指着对方,但函数返回后没有任何根能走到它们,整个环会被当成一坨垃圾一起清掉。

引用计数导致循环引用泄漏,主要是 IE 6/7 时代的历史问题。当时 IE 的 DOM 和 BOM 对象由 COM 组件实现,走的是独立于 JS 引擎的引用计数,DOM 节点和 JS 对象互相引用就会两边都不敢回收。这个坑在 IE 9 之后基本就没有了。

回到我们要解决的问题上,既然循环引用不再是主要成因,那今天的内存泄漏到底从哪儿来?

# 五、四类高频泄漏写法

答案是,泄漏几乎都来自「你以为它已经没人用了,但实际上还有一条引用链挂在根上」。

# 5.1 意外的全局变量

function foo() {
  bar = "这是一个隐式全局变量"; // 少写了 var/let/const
}
@前端进阶之旅: 代码已经复制到剪贴板

非严格模式下,给未声明的变量赋值会挂到 window 上。window 是 GC root,挂上去的东西这辈子都不会被回收。开启严格模式('use strict')或者用打包工具默认的 ESM 模块作用域,这类问题基本就绝迹了。

# 5.2 没清掉的定时器与事件监听

// 组件里起了定时器,卸载时忘了清
const timer = setInterval(() => {
  render(bigData); // bigData 被闭包持有,跟着定时器一起活着
}, 1000);
@前端进阶之旅: 代码已经复制到剪贴板

setInterval 的回调被浏览器的定时器队列持有,只要不 clearInterval,回调以及它闭包里引用的所有东西都是可达的。SPA 里这是最高频的泄漏来源,路由切走了组件销毁了,定时器还在跑,跑一次就多留一份数据。

addEventListener 同理。绑在 window 或 document 上的监听尤其危险,因为宿主对象的生命周期跟页面一样长。

# 5.3 闭包意外持有大对象

function createHandler() {
  const hugeData = new Array(1000000).fill("x");
  return function () {
    // 这里其实只用到了 hugeData.length
    console.log(hugeData.length);
  };
}
@前端进阶之旅: 代码已经复制到剪贴板

返回的函数只用了一个 length,但闭包捕获的是整个变量。V8 在部分场景下会做优化只保留用到的变量,但你不能指望这个优化在所有写法下都生效。稳妥的做法是在闭包里只捕获真正需要的那个值。

# 5.4 游离 DOM 节点

const cache = [];
const el = document.getElementById("panel");
cache.push(el);
document.body.removeChild(el); // 从文档树摘掉了,但 cache 还拿着
@前端进阶之旅: 代码已经复制到剪贴板

节点从 DOM 树上摘下来了,视觉上已经消失,但因为 cache 数组还引用着它,整棵子树连同上面绑的事件、数据都还留在内存里。DevTools 里管这种叫 detached DOM node,是排查时最容易一眼认出来的泄漏类型。

这块可以用 WeakMap、WeakSet 来缓解,它们持有的是弱引用,不会阻止键对象被回收。关于弱引用集合的具体用法,可以看看 Set WeakSet Map WeakMap 梳理。

# 六、用 DevTools 把泄漏抓出来

知道了成因,还得有办法定位到具体哪一行。Chrome DevTools 的 Memory 面板提供了一套很成熟的流程,我自己常用的是三次快照法。

第一步,打开 Memory 面板,在页面加载完、还没做任何操作时拍一张 Heap snapshot,作为基线。

第二步,把你怀疑泄漏的操作重复做十次以上。比如反复进出某个路由、反复打开关闭某个弹窗。次数多是有讲究的,重复次数越多,泄漏对象的数量差异越明显,越容易从噪音里被挑出来。做完之后再拍第二张快照。

第三步,手动点一下面板上的垃圾桶图标强制触发一次 GC,再拍第三张。

然后把快照的对比模式切成 Comparison,用第三张对比第一张,按 Delta 排序。正常情况下大部分对象的增量应该接近 0,那些增量恰好等于你操作次数(或者它的整数倍)的构造函数,基本就是泄漏源。点开之后看下方的 Retainers 面板,它会告诉你这个对象是被谁一路引用到根上的,顺着这条链往上找就能定位到代码。

fe
  • 一、内存泄漏到底指什么
  • 二、为什么必须有垃圾回收
  • 三、策略一:标记清除
  • 四、策略二:引用计数
    • 引用计数的缺陷
  • 五、四类高频泄漏写法
    • 5.1 意外的全局变量
    • 5.2 没清掉的定时器与事件监听
    • 5.3 闭包意外持有大对象
    • 5.4 游离 DOM 节点
  • 六、用 DevTools 把泄漏抓出来
  • 七、面试怎么答
    • 什么是垃圾
    • 如何检测垃圾
    • 遇到内存泄漏怎么排查
  • 总结
  • 参考

← Vue响应式原理从defineProperty到Proxywebpack plugin原理分析与实践 手写四个实用插件 →