1. 真实案例

1.1 关键字
运行缓慢、切换页面、系统卡顿
1.2 初步分析
如果你有类似的处理经验,那么会很容易猜到可能是因为存在着内存泄漏,且大概率为未手动清除定时器,最终造成内存溢出,导致了整个页面崩溃、js 主线程一直被长时间占用
1.3 具体分析
但大多数时候,我们一开始并不知道问题可能的主要原因。不过根据关键字以及问题描述,我们可以大概推测出,这应该是一个性能优化相关的问题
关于性能各方面的情况,我们可以借助浏览器自带的功能进行查看
1.3.1 操作步骤
打开开发者工具(F12)
切换至 Performance 工具
勾选 Memory 选项
点击左上角第一个黑色小圆点,进行录制
对页面进行正常的操作
点击 stop 停止按钮
录制完成,生成 profile 性能报告
分析性能报告
1.3.2 操作动画

1.4 性能排查
1.4.1 内存走势分析
确定是否存在内存泄漏
1.4.1.1 具体操作
页面正常操作,5秒之后,切换到别的页面,不做操作,停留5秒,最后停止录制
1.4.1.2 异常情况

可以看到,内存的下限是在不断的升高,这里大概率发生了内存泄漏
1.4.1.3 正常情况

当内存不再被使用时,会被 GC 回收,不会发生内存泄漏
1.4.2 内存泄漏点分析
确定存在内存泄漏后,进一步定位泄漏的源头
1.4.2.1 操作步骤
打开开发者工具(F12)
切换至 Memory 工具
点击左上角第一个黑色小圆点,记录下当前的堆内存快照(heap snapshot)
在页面上进行操作,特别是可能发生内存泄漏的操作
重复执行几遍步骤3、4,直至结束
找到顶栏的 All objects, 切换至 Objects allocated between snapshots 1 and 2(也可以选择其他的)
切换后,能看到两个快照之间新生成的对象。选择其中一项点开,看看它的 retaining tree 里面保留了哪些对象没有被释放
1.4.2.2 关键词解释
Shallow size:这是对象自身占用内存的大小。通常只有数组和字符串的 Shallow Size 比较大
Retain size:这是将对象本身连同其无法从 GC 根到达的相关对象一起删除后释放的内存大小。因此,如果Shallow Size = Retained Size,说明基本没怎么泄漏。而如果 Shallow Size < Retained Size,就需要多加注意了
closure:函数闭包持有的内存引用
array, string, number, regex:包含着一系列对象,这些对象的属性上有对应类型变量的引用
compiled code:V8引擎为了加快运行速度,会对代码进行一次编译,指与编译后的代码相关联的内存
Detached HTMLDivElement:代码里对指定类型 Dom 节点的引用
1.4.2.3 操作动画

1.4.2.4 原因分析

类似于 setInterval 的源码实现:
function setInterval (fn, time) {
let timer;
function interval () {
fn();
timer = setTimeout(interval, time);
}
setTimeout(interval, time);
return timer;
}可以看出这里是一个动画定时器的场景,但并未在组件卸载时,手动清除定时器,导致定时器中的函数一直在执行,直到刷新或者关闭页面才会停止。这是一个很常见的内存泄漏场景
2. 内存溢出
当内存泄漏严重到超过一定阈值时,就会造成内存溢出,从而导致程序崩溃
2.1 V8引擎的内存限制
默认情况下,V8引擎在 64 位系统下最多只能使用约 1.4GB 的内存,在 32 位系统下最多只能使用约 0.7GB 的内存。具体视浏览器的版本而定
所以在V8引擎中,对内存的使用并不是无限制的。内存泄漏、内存溢出也必然会导致性能问题
不过,在 node 端,V8引擎允许我们可手动地调整内存限制大小,但是需要在初始化时进行配置
2.2 内存限制原因
V8引擎的设计之初,只是作为浏览器端 JavaScript 的执行环境。在浏览器端其实很少会遇到使用大量内存的场景,因此也就没有必要将最大内存设置得过高
垃圾回收机制:垃圾回收本身是一件非常耗时的操作。假设V8的堆内存为1.5G,那么做一次小的垃圾回收需要50ms以上(这已经是一个 long task 了),而做一次非增量式回收甚至需要1s以上,可见其耗时之久。因此如果内存使用过高,那么必然会导致垃圾回收的过程缓慢
JS 单线程机制:垃圾回收的过程会阻碍主线程逻辑的执行,导致浏览器一直处于等待的状态,同时会失去对用户的响应,影响到应用程序的性能,直到垃圾回收结束后才会再次执行 JS 逻辑
基于以上几点,V8引擎为了减少对应用程序的性能造成的影响,采用了一种比较粗暴的手段,那就是直接限制堆内存的大小
所以当程序在申请内存时,系统已经不能再分配出足够的内存空间供其使用,这时就会抛出内存溢出的错误
3. 垃圾回收
栈内存:随着执行上下文在执行环境栈中被弹出,其中所定义和使用的变量也会随之被释放,垃圾回收很容易实现
堆内存:V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间进行不同的分代,然后对不同的分代采用不同的垃圾回收算法
3.1 V8的堆内存结构
新生代(new_space):大多数的对象刚开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁。该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来
老生代(old_space):新生代中的对象在存活一段时间后,就会被转移到老生代中。相对于新生代,老生代的垃圾回收频率较低。老生代又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象
大对象区(large_object_space):存放体积超越其他区域大小的对象。每个对象都会有自己的内存,垃圾回收不会移动大对象区
代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域
map 区(map_space):存放 Cell 和 Map ,每个区域都是存放相同大小的元素,结构简单
垃圾回收主要发生在新生代和老生代
3.2 新生代垃圾回收
新生代主要用于存放存活时间较短的对象。由两个 semispace(半空间)构成的,内存最大值在64位系统上为32MB,在32位系统上为16MB
3.2.1 垃圾回收算法
新生代的垃圾回收过程主要采用了 Scavenge 算法,是一种典型的牺牲空间换取时间的算法
在具体实现中,主要采用了 Cheney 算法,它将新生代一分为二,即 semispace(半空间),其中处于激活状态的区域为 From 空间,未激活的区域为 To 空间。这两个空间,始终只有一个处于使用状态,另一个处于闲置状态
程序中声明的对象首先会被分配到 From 空间,当进行垃圾回收时,如果 From 空间中尚有存活对象,则会被复制到 To 空间进行保存,非存活的对象会被自动回收。当 From 空间中的所有非存活对象被清除之后,From 空间和 To 空间完成一次角色互换:From 空间会变为新的 To 空间,原来的 To 空间则会变成新的 From 空间
3.2.2 垃圾回收过程
基于以上算法,流程图如下:
假设在 From 空间中分配了三个对象 A、B、C

第一次垃圾回收时,对象 A 已经没有其他引用,则表示可以对其进行回收;对象 B、C 依旧处于活跃状态,因此会被复制到 To 空间中进行保存

清除 From 空间中的所有非存活对象

From 空间和 To 空间完成一次角色互换

在 From 空间中分配了一个新的对象 D

第二次垃圾回收时,对象 D 已经没有其他引用,则表示可以对其进行回收;对象 B、C 依旧处于活跃状态,再次被复制到 To 空间中进行保存

再次清除 From 空间中的所有非存活对象

From 空间和 To 空间继续完成一次角色互换

通过以上的流程图,可以很清楚地看到,Scavenge 算法主要就是将存活对象在 From 空间和 To 空间之间进行复制,同时完成两个空间的角色互换
因此该算法的缺点也比较明显,就是浪费了一半的内存用于复制
3.3 对象晋升
3.3.1 晋升概念
对象从新生代转移到老生代的过程
3.3.2 晋升条件
对象是否经历过一次 Scavenge 算法:通过检查该对象的内存地址来进行判断
To 空间的内存占比是否已经超过 25%:原因是因为 To 空间在经历过一次 Scavenge 算法后,会和 From 空间完成角色互换,变为了 From 空间,后续的内存分配都是在 From 空间中进行的。如果内存使用过高甚至溢出,则会影响后续对象的分配。因此超过此限制之后对象会直接晋升
3.3.3 晋升流程

3.4 老生代垃圾回收
在老生代中,因为管理着大量的存活对象,如果依旧使用 Scavenge 算法的话,很明显会浪费一半的内存,因此 Scavenge 算法不适合老生代
3.4.1 垃圾回收算法
在早前有一种算法叫做引用计数,该算法的原理比较简单,就是计算对象的引用次数。当引用次数为0时,则该对象就会被视为垃圾并被回收。但如果对象之间存在循环引用,则无法被回收,导致内存泄漏
截至2012年所有的现代浏览器均放弃了这种算法,转而采用新的 Mark-Sweep(标记清除)和 Mark-Compact(标记整理)算法等
3.4.1.1 标记清除
分为了标记和清除两个阶段。在标记阶段,会遍历堆中所有的对象,然后标记活着的对象;在清除阶段,会将非存活的对象进行清除
根列表:
全局对象
函数的参数和局部变量
当前嵌套调用链上的其他函数的参数和变量
3.4.1.1.1 具体步骤
垃圾回收器会在内部构建一个根列表
然后,从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的。根节点不能到达的地方即为非活动的,将会被视为垃圾
最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统
3.4.1.1.2 演示动画
1.

2.

3.

3.4.1.2 标记整理
3.4.1.2.1 内存碎片化
因为非存活的对象的内存地址可能不是连续的,所以在标记清除之后,内存空间可能会出现不连续的状态,这就是内存碎片化
它会导致如果后面需要分配一个大的对象,虽然有很多空闲内存,但是是不连续的,不足以分配的情况下,就会提前触发垃圾回收,而这次垃圾回收其实是没有必要的
3.4.1.2.2 整理过程
将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存。流程图如下:
假设在老生代中有 A、B、C、D 四个对象

在标记阶段,将对象 A 和对象 C 标记为活动的

在整理阶段,将活动的对象往堆内存的一端进行移动

在清除阶段,将活动对象左侧的内存全部回收

3.4.1.3 增量标记
一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,这将是一个耗时的过程,同时也会阻塞主线程的执行,这势必会导致性能问题
因此,为了减少垃圾回收带来的停顿时间,V8引擎引入了 Incremental-Marking(增量标记)的算法
即将原本需要一次性遍历堆内存的操作改为增量标记的方式:先标记堆内存中的一部分对象,然后暂停,将执行权重新交给 JS 主线程,待主线程任务执行完毕后,再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个算法其实有点像 React 框架中的 Fiber 架构
3.4.1.4 其他算法
V8引擎后续又引入了 Lazy-Sweeping(延迟清理)和 Incremental-Compaction(增量整理),让清理和整理的过程也变成增量式的
同时,为了充分利用多核 CPU 的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能
4. 内存泄漏场景(重点)
在我们写代码的过程中,很多时候都不太需要关注内存泄漏,因为浏览器和大部分的前端框架在底层已经帮我们处理了常见的内存泄漏问题
虽然V8引擎的垃圾回收机制能回收绝大部分的垃圾内存,但是如果是因为代码疏忽,这样还是存在回收不了的情况
所以我们需要了解一些常见的内存泄漏场景,遇到内存泄漏问题时可以先自查一遍常见场景,能解决日常开发中遇到的大多数内存泄漏问题
4.1 意外的全局变量
创建的变量无形地挂载到 window 全局对象上
4.1.1 示例
在全局作用域中以 var 声明的方式创建一个变量
var a = 1; // 等价于 window.a = 1在函数作用域中不以任何声明的方式创建一个变量
function fn() {
a = 1; // 等价于 window.a = 1
}this 指向,一种比较隐蔽的方式
function fn() {
this.a = 1;
}
fn(); // 此时 this 指向 window,等价于 window.a = 14.1.2 解决方案
全局变量使用完毕后可以将其设置为 null 从而触发垃圾回收
4.2 定时器
常见的定时器 setTimeout、setInterval、setImmediate、requestAnimationFrame、requestIdleCallback,在使用结束后(如组件卸载、离开页面时),务必手动清除定时器
4.3 事件监听器
监听器回调函数中的内存都是没法回收的(浏览器会认为这是必须的内存,不是垃圾内存)
Performance 里,监听器数量会持续上升

4.3.1 解决方案
当组件卸载、离开页面时,务必手动移除事件监听器
注意:
// 存在内存泄漏的代码
mounted() {
window.addEventListener('resize', debounce(this.fn, 100));
},
beforeDestroy() {
window.removeEventListener('resize', debounce(this.fn, 100));
}乍一看好像没问题,但其实每次调用 debounce(this.fn, 100)时,都会返回一个新的函数,导致 addEventListener 和 removeEventListener 方法中传入的回调函数已经不是同一个回调函数,事件监听器没有被正确地移除,存在内存泄漏
// 改进后的代码
mounted() {
this.debounceFn = debounce(this.fn, 100);
window.addEventListener('resize', this.debounceFn);
},
beforeDestroy() {
window.removeEventListener('resize', this.debounceFn);
}4.4 闭包
闭包和作用域链有关
4.4.1 核心概念
自由变量:在函数中使用的,但既不是函数的参数,也不是函数的局部变量的变量
闭包是指创建它的上下文已经销毁,但它仍然存在(比如函数从父函数中返回),并且在代码中使用了自由变量的函数
所以由于存在变量引用,导致变量无法回收,会一直存在于执行环境栈,造成内存泄漏
4.4.2 示例
function fn () {
const local = 1;
return function () {
console.log(local);
}
}
const func = fn();
func(); // 如果不调用返回的函数,就会造成内存泄漏4.4.3 解决方案
调用返回的函数,执行结束后,该函数的执行上下文会被弹出执行环境栈,随之引用的变量也会被释放
4.5 DOM 引用
页面上的 DOM 都是占用内存的
4.5.1 示例
const button = document.getElementById('button');
document.body.removeChild(button); // 或者 document.body.removeChild(document.getElementById('button'));
button = null; // 如果不再手动置为 null,就会造成内存泄漏将 DOM 对象赋值给一个变量后(内存指向是一样的),虽然在页面上移除了该元素,但内存指向换为了赋予的变量,内存占用还是存在的,造成内存泄漏
4.5.2 解决方案
将变量再手动置为 null
4.6 弱引用
4.6.1 概念
垃圾回收的过程中,不会将键名对该对象的引用考虑进去,只要所引用的对象没有其他引用了,垃圾回收器就会释放该对象所占用的内存
4.6.2 示例
// 存在内存泄漏的代码
const map = new Map();
const obj = { value: 1 };
map.set(obj, 1);
obj = null; // 虽然 obj 手动置为了 null,但是 map 的一个健值引用着该对象,内存还是占用的,造成内存泄漏// 改进后的代码
const map = new Map();
const obj = { value: 1 };
map.set(obj, 1);
map.delete(obj);
obj = null;// 更便捷的方式
const map = new WeakMap();
const obj = { value: 1 };
map.set(obj, 1); // 内存回收不会考虑到这个引用是否存在
obj = null;4.6.3 解决方案
在 ES6 中新增了两个有效的数据结构 WeakMap 和 WeakSet,就是专门为了解决内存泄漏问题而诞生的。其表示弱引用,它的键名所引用的对象均是弱引用
这也就意味着,我们不需要关心其中键名对其他对象的引用,也不需要手动地清除引用
注意:但也不能乱用,因为二者的键名只能为对象
4.7 console
因为打印后的对象需要支持在控制台上查看,所以传递给 console 方法的对象是不能被垃圾回收的
4.7.1 解决方案
避免在生产环境用 console 打印对象