V8引擎垃圾回收

11/22/2022 垃圾回收

# 前言

垃圾数据:某些数据使用过后就无用了。
回收:如果垃圾数据一直保存在内存中,导致内存泄漏,所以需要对垃圾数据进行回收,释放有限的内存空间。

# 垃圾回收策略

# 手动回收(C/C++)

何时分配内存、销毁内存均由代码控制。

// 在堆中分配2048字节的内存,并将分配后的引用地址保存到p中
char* p =  (char*)malloc(2048);  
 
// 使用p指向的内存

// 手动调用free函数释放内存,标识该段数据不再需要。
free(p);
p = NULL;
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// 如果没有主动调用free销毁内存 => 内存泄漏
1
2
3
4
5
6
7
8
9
10

# 自动回收(JavaScript/Java/Python)

垃圾数据由垃圾回收器进行释放,无需手动通过代码进行释放。
对于JavaScript这种自动释放资源的特性其实带来了很多困惑,导致开发者不去关心内存管理。

# JavaScript垃圾回收机制

# 调用栈数据回收

调用栈中:

  • 原始数据类型分配到栈。
  • 引用数据类型分配到堆。
  • 执行函数 => JS引擎创建其执行上下文压入调用栈、更新一个记录当前执行状态指针(ESP)指向当前调用栈中正在执行的函数。
  • 函数执行结束 => JS将调用栈中ESP指针下移到另一个执行上下文(函数执行上下文从堆中销毁) ESP(Extended Stack Pointer)指针下移:ESP指针以上的执行上下文虽然保存在调用栈中,但已经属于无效内存了,后续如果有新执行的函数会直接覆盖该无效内存。 调用栈回收上下文.png

# 堆中数据回收

执行上下文中的引用数据类型保存在堆中,当执行上下文无效销毁,但是保存在堆中的对象依旧占用堆内存空间。此时就需要JavaScript中的垃圾回收器进行回收。

垃圾回收的一个重要基础假说:代际假说(The Generational Hypothesis)。后续所有垃圾回收策略都建立在该基础假说之上。

代际假说的核心:

  • 大部分对象在内存中存在的时间很短(很多对象一旦分配内存,很快就不可访问了)。
  • 不死的对象,存活的更久。

垃圾回收的算法有很多,需要权衡场景,根据对象生命周期不同使用不同的算法。在V8中,会把堆分为新生代、老生代。V8分别使用两个不同的垃圾回收器进行高效的垃圾回收。

新生代:

  • 存放生命时间短的对象。
  • 只支持1~8M的容量。
  • 副垃圾回收器负责回收。

老生代:

  • 存放生命时间长的对象。
  • 容量相比新生代更大。
  • 主垃圾回收器负责回收。

# 垃圾回收器工作流

  1. 对象标记。标记空间中活动对象(在使用的对象)和非活动对象(可以进行垃圾回收的对象)。
  2. 回收非活动对象的内存。所有对象完成标记后,统一清理内存中标记为可回收的对象。
  3. 内存整理。频繁回收对象后,内存中会存在大量不连续的空间,这些不连续的内存空间称为内存碎片,当内存中出现大量内存碎片,如果遇到需要分配大块的连续内存,就可能出现内存不足的情况,所以需要进行内存整理。(此步骤可选,有些垃圾回收器不会产生内存碎片,比如副垃圾回收器)

# 新生代副垃圾回收器

一般情况,大部分小对象会被分配到新生区,虽然容量不大,但垃圾回收频繁。使用Scavenge算法进行处理。
Scavenge算法:将新生代空间对半分为两个区域,对象区域/空闲区域。新加入的对象存放到对象区域,当对象区域快被写满,需要执行一次垃圾清理操作。首先对对象区域中的垃圾进行标记,标记完成进入垃圾清理阶段,副垃圾回收器把存活的对象复制到空闲区域同时有序排列(内存整理)。完成复制后,对象区域和空闲区域角色反转(让新生代的两块区域无限循环使用下去),原对象区域变成空闲区域,原空闲区域变成对象区域。这样就是完成了垃圾对象的回收操作。
新生代采用的Scavenge算法每次执行清理操作都需要将存活对象从对象区域复制到空闲区域,该操作需要时间成本,如果新生代的空间容量设置过大,每次清理时间会更久,所以为了执行效率,一般新生代区域容量空间设置的都比较小。由于新生代容量小,很容易被存活的对象装满,JavaScript引擎采用了对象晋升策略,经过两次垃圾回收依旧存活的对象会被移动到老生代区域中。

# 老生代主垃圾回收器

除了新生区晋升的对象,一些比较大的对象也会直接分配到老生区。所以老生区对象的特点:

  • 对象占用空间大
  • 对象存活时间长

老生区不采用Scavenge算法进行垃圾回收,因为对象太大这种算法效率低还浪费一半空间,所以采用标记-清除算法(Mark-Sweep)进行垃圾回收的。
首先进行标记,从一组根元素开始,递归遍历根元素,能遍历到的元素称为活动对象,未达到的元素判定为垃圾数据。回到上面的调用栈回收,函数执行完毕,ESP向下移动,指向下一个指向上下文,此时遍历调用栈,是不会找到已经失效执行上下文中引用对象的变量,此时对应堆内存中无效执行上下文中的引用类型的数据被标记为垃圾数据(标记为红色),遍历到的数据会被标记为活动对象。标记完成后开始垃圾清除,清除掉标记为红色的数据。
对一块内存多次执行标记-清除算法,会产生大量不连续的内存碎片,碎片过多会导致无法分配足够大的连续内存,此时产生另一个算法标记-整理(Mark-Compact),标记过程和标记-清除算法一致,但后续不会直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清除边界以外的内存。

# 全停顿

V8采用的是副垃圾回收器 + 主垃圾回收器实现垃圾回收的。JavaScript是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的JavaScript脚本暂停,等待垃圾回收完毕再回复脚本执行,这就称之为全停顿(Stop-The-World)。
比如堆中有数据,V8完成一次完整的垃圾回收需要1S以上的时间,这就是由于垃圾回收引起JavaScript线程暂停执行的时间,这种时间的停顿会导致应用的性能和响应时间直线下降。在V8新生代的垃圾回收中,空间小/存活对象少,所以全停顿影响不大,但是对于老生代,如果执行垃圾回收的过程占用主线程过久,让主线程无法做其他事,造成页面上动画无法执行卡顿的现象。
老生代全停顿优化:V8将标记过程拆分成一个个子标记过程,同时让垃圾回收标记和JavaScript应用逻辑交替执行,直至垃圾回收标记整个阶段完成,这个算法称为增量标记(Incremental Marking)算法,基于该算法,将一个完整的垃圾回收任务拆分成很多小任务,这些小任务执行时间比较短,可以穿插在其他JavaScript任务中去执行,降低页面因为垃圾回收任务导致的页面卡顿。

# 总结

  • 调用栈回收:ESP指针移动。
  • 新生代副垃圾回收器。
  • 老生代主垃圾回收器。
  • 垃圾回收导致全停顿。

无论是垃圾回收策略、全停顿策略,都没有一个完美的解决方案,都会牺牲某些指标获得其他几个指标的提示。作为工程师,在满足需求的前提下,权衡各个指标的是,尽可能适应核心的需求。

两害相权取其轻,两利相权取其重,切不可患得患失,果断决策牺牲何种指标实现利益最大化。

Last Updated: 11/22/2022, 5:46:10 PM