为何需要垃圾回收
默认情况下,V8引擎在64位系统下最多只能使用约1.4GB的内存,在32位系统下最多只能使用约0.7GB的内存,如果不进行垃圾回收那一个程序运行不了多久就会把内存打爆了
垃圾回收策略
V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法,这里主要讲新生代和老生代
新生代
新生代主要用于存放存活时间较短的对象。新生代内存是由两个半空间(From、To空间)构成的,内存最大值在64位系统和32位系统上分别为32MB和16MB,在新生代的垃圾回收过程中主要采用了Scavenge算法。
Scavenge算法是一种典型的牺牲空间换取时间的算法,在新生代内存中,大部分对象的生命周期较短,在时间效率上表现可观。
- 假设我们在From空间中分配了三个对象A、B、C
- 当程序主线程任务第一次执行完毕后进入垃圾回收时,发现对象A已经没有其他引用,则表示可以对其进行回收
- 对象B和对象C此时依旧处于活跃状态,因此会被复制到To空间中进行保存
- 接下来将From空间全部清除
- From空间和To空间完成一次角色互换
对象晋升
当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升。
对象晋升的条件主要有以下两个:
- 对象是否经历过一次Scavenge算法
- To空间的内存占比是否已经超过25%
之所以有25%的内存限制是因为如果内存使用过高,则会影响后续对象的分配(就是刚复制过去没写多少内容From空间又满了)。
老生代
在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge算法的话,会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)和Mark-Compact(标记整理)来进行管理。
Mark-Sweep(标记清除)
Mark-Sweep(标记清除)分为标记和清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:
- 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象可以看成一个根节点。
- 然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
- 最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。
Mark-Compact(标记整理)
Mark-Sweep算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,导致后面如果需要分配一个大对象而空闲内存不足以分配。
为了解决这种内存碎片的问题,Mark-Compact(标记整理)算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存。
假设在老生代中有A、B、C、D四个对象
在垃圾回收的标记阶段,将对象A和对象C标记为活动的
在垃圾回收的整理阶段,将活动的对象往堆内存的一端移动
在垃圾回收的清除阶段,将活动对象左侧的内存全部回收
由于JS的单线程机制,垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为全停顿(stop-the-world)
为了减少垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React框架中的Fiber架构
如何避免内存泄露
- 尽可能少地创建全局变量
- 手动清除定时器
- 慎用闭包
- 使用 WeakMap/WeakSet