一篇让你了解chrome V8垃圾回收

这篇博客探讨了Chrome V8引擎为何需要垃圾回收机制,详细解释了V8内存限制,尤其是全停顿(Stop-The-World)现象。文章重点介绍了V8的垃圾回收策略,包括新生代和老生代的划分,Scavenge算法,以及Mark-Sweep和Mark-Compact算法。此外,还讨论了Orinoco项目中的增量标记、懒性清理、并发和并行技术,以优化垃圾回收过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1. 为什么需要垃圾回收机制

在浏览器中,Chrome V8引擎实例的生命周期不会很长(谁没事一个页面开着几天几个月不关),而且运行在用户的机器上。如果不幸发生内存泄露等问题,仅仅会 影响到一个终端用户。且无论这个V8实例占用了多少内存,最终在关闭页面时内存都会被释放,几乎没有太多管理的必要(当然并不代表一些大型Web应用不需 要管理内存)。但如果使用Node作为服务器,就需要关注内存问题了,一旦内存发生泄漏,久而久之整个服务将会瘫痪(服务器不会频繁的重启)

2. V8内存限制

  • 在64位操作系统可以使用1.4G内存
  • 在32位操作系统中可以使用0.7G内存
2.1 为何限制内存大小
  1. 因为V8的垃圾收集工作原理导致的, 1.4G内存完全一次垃圾收集需要1秒以上
  2. 这个暂停时间成为 Stop The World,在这个期间,应用的性能和响应能力都会下降
全停顿 Stop-The-World

为了避免JavaScript应用逻辑和垃圾回收器的内存资源竞争导致的不一致性问题,垃圾回收器会将JavaScript应用暂停,这个过程,被称为全停顿(stop-the-world)。

3. V8的垃圾回收机制

V8是基于分代的垃圾回收,不同代垃圾回收机制也不一样,按存活的时间分为新生代老生代
在这里插入图片描述

3.1 新生代和老生代

年龄小的是新生代,
由From区域和To区域两个区域组成
在64位系统里,新生代内存是32M,From区域和To区域各占用16M
在32位系统里,新生代内存是16M,From区域和To区域各占用8M

年龄大的是老生代,默认情况下,
64为系统下老生代内存是1400M
32为系统下老生代内存是700M

如何判断回收内容

如何确定哪些内存需要回收,哪些内存不需要回收,这是垃圾回收期需要解决的最基本问题。我们可以这样假定,一个对象为活对象当且仅当它被一个根对象或另一个活对象指向。根对象永远是活对象,它是被浏览器或V8所引用的对象。被局部变量所指向的对象也属于根对象,因为它们所在的作用域对象被视为根对象。全局对象(Node中为global,浏览器中为window)自然是根对象。浏览器中的DOM元素也属于根对象

3.1.1 新生代的特点

大多数的对象被分配在这里,这个区域很小但是垃圾回特别频繁。在新生代分配内存非常容易,我们只需要保存一个指向内存区的指针,不断根据新对象的大小进行递增即可。当该指针到达了新生代内存区的末尾,就会有一次清理(仅仅是清理新生代)

3.1.2 新生代的垃圾回收算法-Scavenge算法

新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法。

Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。在这两个semispace中,一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间,当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中(复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。也就是说,在垃圾回收的过程中,就是通过将存活对象在两个semispace之间进行复制。可以很容易看出来,使用Cheney算法时,总有一半的内存是空的。但是由于新生代很小,所以浪费的内存空间并不大。而且由于新生代中的对象绝大部分都是非活跃对象,需要复制的活跃对象比例很小,所以其时间效率十分理想。复制的过程采用的是BFS(广度优先遍历)的思想,从根对象出发,广度优先遍历所有能到达的对象

在这里插入图片描述
举个栗子(以及凑篇幅),如果有类似如下的引用情况:

          +----- A对象
          |
根对象----+----- B对象 ------ E对象
          |
          +----- C对象 ----+---- F对象 
                           |
                           +---- G对象 ----- H对象 D对象 

在执行Scavenge之前,From区长这幅模样

+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H |        |
+---+---+---+---+---+---+---+---+--------+

那么首先将根对象能到达的ABC对象复制到To区,于是乎To区就变成了这个样子:

          allocationPtr
             ↓ 
+---+---+---+----------------------------+
| A | B | C |                            |
+---+---+---+----------------------------+
 ↑
scanPtr  

接下来进入循环,扫描scanPtr所指的A对象,发现其没有指针,于是乎scanPtr移动,变成如下这样

          allocationPtr
             ↓ 
+---+---+---+----------------------------+
| A | B | C |                            |
+---+---+---+----------------------------+
     ↑
  scanPtr  

接下来扫描B对象,发现其有指向E对象的指针,且E对象在From区,那么我们需要将E对象复制到allocationPtr所指的地方并移动allocationPtr指针:

            allocationPtr
                 ↓ 
+---+---+---+---+------------------------+
| A | B | C | E |                        |
+---+---+---+---+------------------------+
     ↑
  scanPtr  

B对象里所有指针都已被复制完,所以移动scanPtr:

            allocationPtr
                 ↓ 
+---+---+---+---+------------------------+
| A | B | C | E |                        |
+---+---+---+---+------------------------+
         ↑
      scanPtr  

接下来扫描C对象,C对象中有两个指针,分别指向F对象和G对象,且都在From区,先复制F对象到To区:

                allocationPtr
                     ↓ 
+---+---+---+---+---+--------------------+
| A | B | C | E | F |                    |
+---+---+---+---+---+--------------------+
         ↑
      scanPtr  

然后复制G对象到To区

                    allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
         ↑
      scanPtr  

这样C对象内部的指针已经复制完成了,移动scanPtr:

                    allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
             ↑
          scanPtr  

逐个扫描E,F对象,发现其中都没有指针,移动scanPtr:

                    allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
                     ↑
                  scanPtr

扫描G对象,发现其中有一个指向H对象的指针,且H对象在From区,复制H对象到To区,并移动allocationPtr:

                        allocationPtr
                             ↓ 
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+
                     ↑
                  scanPtr  

完成后由于G对象没有其他指针,且H对象没有指针移动scanPtr:

                        allocationPtr
                             ↓ 
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+
                             ↑
                           scanPtr  

此时scanPtr和allocationPtr重合,说明复制结束

可以对比一下From区和To区在复制完成后的结果:

//From区
+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H |        |
+---+---+---+---+---+---+---+---+--------+
//To区
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+ 

D对象没有被复制,它将被作为垃圾进行回收

3.2.1 老生代的特点

老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象,而且老生代占用的内存较多
V8在老生代中的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合

3.2.2 mark-sweep(标记清除)

标记活着的对象,随后清除在标记阶段没有标记的对象,只清理死亡对象

 - 标记阶段:对老生代进行第一次扫描,标记活动对象

- 清理阶段:对老生代进行第二次扫描,清除未被标记的对象,即清理非活动对象

问题 在于清除后会出现内存不连续的情况,这种内存碎片会对后续的内存分配产生影响 如果要分配一个大对象,碎片空间无法分配

3.2.3 mark-compact(标记整理)

标记死亡后会对对象进行整理,活着的对象向左移动,移动完成后直接清理掉边界外的内存
在这里插入图片描述

3.3 新生代到老生代的升级

当一个对象经历过多次的垃圾回收依然存活的时候,生存周期比较长的对象会被移动到老生代, 这个移动过程被成为晋升或者升级

  • 经过5次以上的回收还存在
  • TO的空间使用占比超过25%
  • 或者超大对象

在这里插入图片描述

3.4 三种算法的对比

在这里插入图片描述

3.5 优化 Orinoco

orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题,它利用了增量标记懒性清理并发并行来降低主线程挂起的时间。

3.5.1 增量标记 - Incremental marking

为了降低全堆垃圾回收的停顿时间,增量标记将原本的标记全堆对象拆分为一个一个任务,让其穿插在JavaScript应用逻辑之间执行,它允许堆的标记时的5~10ms的停顿。增量标记在堆的大小达到一定的阈值时启用,启用之后每当一定量的内存分配后,脚本的执行就会停顿并进行一次增量标记。
在这里插入图片描述

3.5.2 懒性清理 - Lazy sweeping

增量标记只是对活动对象和非活动对象进行标记,惰性清理用来真正的清理释放内存。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理的过程延迟一下,让JavaScript逻辑代码先执行,也无需一次性清理完所有非活动对象内存,垃圾回收器会按需逐一进行清理,直到所有的页都清理完毕。

增量标记与惰性清理的出现,使得主线程的最大停顿时间减少了80%,让用户与浏览器交互过程变得流畅了许多,从实现机制上,由于每个小的增量标价之间执行了JavaScript代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以也暴露出来增量标记的缺点:

并没有减少主线程的总暂停的时间,甚至会略微增加
由于写屏障(Write-barrier)机制的成本,增量标记可能会降低应用程序的吞吐量

3.5.3 并发 - Concurrent

并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于JavaScript代码在执行,堆中的对象的引用关系随时可能会变化,所以也要进行写屏障操作。
在这里插入图片描述

3.5.4 并行 - Parallel

并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。

在这里插入图片描述

V8当前垃圾回收机制

2011年,V8应用了增量标记机制。直至2018年,Chrome64和Node.js V10启动并发标记(Concurrent),同时在并发的基础上添加并行(Parallel)技术,使得垃圾回收时间大幅度缩短。

参考:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值