垃圾回收、内存优化#
我们知道 JS 通过堆和栈来存储数据(分配内存)
- 原始数据类型、代码执行时栈帧的位置存储在栈空间
- 引用类型数据存储在堆空间
但某些数据使用后不再需要,就变成了垃圾数据,如果不回收,内存占用就会越来越多
一般垃圾回收分为手动回收和自动回收两种策略
像 C/C++ 是使用手动回收策略,需要代码来决定如何分配和销毁内存,而 Javascript/Java/Python 等语言都由垃圾回收期来自动释放内存,但并不代表我们不需要关心内存管理(特别是 JS,很多开发很容易被自动垃圾回收迷惑,从而忽略内存管理)
一、栈内存回收#
先看调用栈中的数据是如何回收的,从执行上下文开始
当一个函数执行上下文被创建并入栈,元素类型数据分配到栈,引用类型数据分配到堆。
与此同时,有一个记录当前执行状态的指针(ESP),执行当前的执行上下文 A
当函数 A 调用完毕出栈时,ESP 会下移到下一个执行上下文 B,这个下移操作就是销毁上一个执行上下文 A 的过程,虽然 A 的执行上下文还保存在栈内存中,但已经是无效内存了,下次再调用其他函数时,会直接覆盖这块内存,用来存放其他函数的执行上下文
所以,当一个函数执行结束之后,JS 引擎会通过向下移动 ESP 来销毁该函数的执行上下文
二、堆内存回收#
函数在栈中的执行上下文被销毁后,那还有一些保存在堆中的对象怎么处理呢,那就需要用到垃圾回收器了
2.1 分代收集与主要流程#
V8 中,会把堆分为新生代和老生代两个区域
- 新生代:存放生存时间短的对象 -> 副垃圾回收器 Minor GC
- 老生代:存放生存时间久的对象 -> 主垃圾回收器 Major GC
垃圾回收器有一套统一执行流程
- 标记对象
- 活动对象:还在使用的对象
- 非活动对象:可以进行垃圾回收
- 回收非活动对象占据的内存
- 内存整理:整理内存碎片,留出连续空间,方便后续分配较大连续内存
2.2 副垃圾回收器#
新加入的对象都会加入到对象区,对象区快写满时,需要执行一次垃圾回收
对象区域中的垃圾做标记 -> 存活的对象复制到空闲区域中 -> 有序排列 -> 空闲区域没有垃圾碎片 -> 对象区和空闲区的概念发生翻转 -> 无限重复利用
副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。
2.3 主垃圾回收器#
老生代堆里的对象特点
- 一个是对象占用空间大
- 另一个是对象存活时间长
主垃圾回收器采用的算法有两种
- 标记 - 清除(Mark-Sweep)
- 标记 - 整理(Mark-Compact)
JS 里还有一种最初级的垃圾回收算法:引用计数,如果没有引用指向该对象,则被回收,但该算法的缺陷是无法处理循环引用的问题
先对垃圾数据进行标记。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
然后清除,这就是标记 - 清除算法
不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法 —— 标记 - 整理(Mark-Compact)。
先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存
2.4 垃圾回收小结#
三、垃圾回收的效率优化#
JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。这种行为叫做全停顿(Stop-The-World)。
第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;
第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。
3.1 优化方案#
- 第一个方案是并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。
- 第二个方案是增量式垃圾回收,垃圾回收器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。
- 第三个方案是并发回收,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,难点在于读写锁机制(这里不深入了)
主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案。
四、内存优化#
常见三类内存问题
- 内存泄漏 (Memory leak),它会导致页面的性能越来越差;
- 在 JavaScript 中,造成内存泄漏 (Memory leak) 的主要原因是不再需要 (没有作用) 的内存数据依然被其他对象引用着。
- 内存膨胀 (Memory bloat),它会导致页面的性能会一直很差;
- 频繁垃圾回收,它会导致页面出现延迟或者经常暂停。
4.1 内存泄露常见原因和解决方案#
- 全局变量:尽量避免设置比较大的全局对象
- 监听器、定时器引用没有及时清除
- 闭包常驻内存:比较容易形成对象的循环引用,把变量设置 null 切断联系
- DOM 引用:保留了 DOM 节点的引用,导致 GC 没有回收
除了以上,多用 ES6 的WeakMap/WeakSet
,它们都保持对象的弱引用
4.2 内存泄露识别方法#
- 在浏览器中可通过查看 memory 来观察内存是否有上升的趋势
- 在 Node 环境可通过 headdump 抓取内存信息进行分析