浅谈V8引擎内存管理与垃圾回收算法

1,454 阅读12分钟

你好,我是愣锤。

众所周知,JS是自动管理垃圾回收的,开发者不需要关心内存的分配与回收。而且垃圾回收机制在前端面试中也是常考的部分。本文主要讲解V8的分代垃圾回收算法,希望阅读本文后的小伙伴能够对V8垃圾回收机制有个痛彻(哈哈,是痛彻!!!)的了解,文章主要涵盖如下内容:

  • V8的内存限制与解决办法
  • 新生代内存对象的Scavenge算法
  • 基于可达性分析算法标记存活对象的逻辑以及优化手段
  • 新生代内存对象的晋升条件、
  • Scavenge算法的深度/广度优先区别
  • 跨代内存的的写屏障
  • 老生代内存对象的标记清除/整理算法
  • GCSTW原因及优化策略

V8的内存限制与解决办法

V8最初为浏览器设计,遇到大内存使用的场景较少,在设计上默认对内存使用存在限制,只允许使用部分内存,64位系统可允许使用内存约1.4g,32位系统约0.7g。如下代码所示,在Node中查看所依赖的V8引擎的内存限制方法:

process.memoryUsage();

// 返回内存的使用量,单位字节
{
  rss: 22953984,
  // 申请的总的堆内存
  heapTotal: 9682944,
  // 已使用的堆内存
  heapUsed: 5290344,
  external: 9388
}

image.png

V8限制内存使用大小还有另一个重要原因,堆内存过大时V8执行垃圾回收的时间较久(1.5g50ms),做非增量式的垃圾回收要更久(1.5g1s)。在后续讲解了V8的垃圾回收机制后相信大家更能感同身受。

虽然V8引擎对内存使用做了限制,但是同样暴露修改内存限制的方法,就是启动V8引擎时添加相关参数,下面代码演示在Node中修改依赖的V8引擎内存限制:

# 更改老生代的内存限制,单位mb
node --max-old-space-size=2048 index.js

# 更改新生代的内存限制,单位mb
node --max-semi-space-size=1024=64 index.js

这里需要注意的是更改的新生代的内存的语法已经更改为上述的写法,且单位也由kb变成了mb,旧的写法是node --max-new-space-size,可以通过下面命令查询当前Node环境修改新生代内存的语法:

node --v8-options | grep max

image.png

V8垃圾回收策略

在引擎的垃圾自动回收机制的历史演变中,人们发现是没有一种通用的可以解决任何场景下垃圾回收的算法的。因此现代垃圾回收算法根据对象的存活时间将内存垃圾进行分代分代垃圾回收算法就是对不同类别的内存垃圾实行不同的回收算法。

V8将内存分为新生代老生代两种:

  • 新生代内存中的对象存活时间较短
  • 老生代内存中代对象存活时间较长或是常驻内存

新生代内存存放在新生代内存空间(semispace)中,老生代内存存放在老生代内存空间中(oldspace),如下图所示:

image.png

  • 新生代内存采用Scavenge算法
  • 老生代内存采用Mark-SweepMark-Compact算法

下面我们看看Scavenge的算法逻辑吧!

Scavenge算法

对于新生代内存的内存回收采用Scavenge算法,Scavenge的具体实现采用的是Cheney算法。Cheney算法是将新生代内存空间一分为二,一个空间处于使用状态(FromSpace),一个空间处于空闲状态(称为ToSpace)。

image.png

在内存开始分配时,首先在FromSpace中进行分配,垃圾回收机制执行时会检查FromSpace中的存活对象,存活对象会被会被复制到ToSpace,非存活对象所占用的空间将被释放,复制完成后FromSpaceToSpace的角色将翻转。当一个对象多次复制后依然处于存活状态,则认为其是长期存活对象,此时将发生晋升,然后该对象被移动到老生代空间oldSpace中,采用新的算法进行管理。

image.png

Scavenge算法其实就是在两个空间内来回复制存活对象,是典型的空间换时间做法,所以非常适合新生代内存,因为仅复制存活的对象且新生代内存中存活对象是占少数的。但是有如下几个重要问题需要考虑:

  • 引用避免重复拷贝

假设存在三个对象temp1、temp2、temp3,其中temp2、temp3都引用了temp1,js代码示例如下:

var temp2 = {
  ref: temp1,
}

var temp3 = {
  ref: temp1,
}

var temp1 = {}

FromSpace中拷贝temp2ToSpace中时,发现引用了temp1,便把temp1也拷贝到ToSpace,是一个递归的过程。但是在拷贝temp3时发现也引用了temp1,此时再把temp1拷贝过去则重复了。

要避免重复拷贝,做法是拷贝时给对象添加一个标记visited表示该节点已被访问过,后续通过visited属性判断是否拷贝对象。

  • 拷贝后保持正确的引用关系

还是上述引用关系,由于temp1不需要重复拷贝,temp3被拷贝到ToSpace之后不知道temp1对象在ToSpace中的内存地址。

做法是temp1被拷贝过去后该对象节点上会生成新的field属性指向新的内存空间地址,同时更新到旧内存对象的forwarding属性上,因此temp3就可以通过旧temp1forwarding属性找到在ToSpace中的引用地址了。

内存对象同时存在于新生代和老生代之后,也带来了问题:

  • 内存对象跨代(跨空间)后如何标记
const temp1 = {}

const temp2 = {
  ref: temp1,
}

比如上述代码中的两个对象temp1temp2都存在于新生代,其中temp2引用了temp1。假设在经过GC之后temp2晋升到了老生代,那么在下次GC的标记阶段,如何判断temp1是否是存活对象呢?

在基于可达性分析算法中要知道temp1是否存活,就必须要知道是否有根对象引用引用了temp1对象。如此的话,年轻代的GC就要遍历所有的老生代对象判断是否有根引用对象引用了temp1对象,如此的话分代算法就没有意义了。

解决版本就是维护一个记录所有的跨代引用的记录集,它是写缓冲区的一个列表。只要有老生代中的内存对象指向了新生代内存对象时,就将老生代中该对象的内存引用记录到记录集中。由于这种情况一般发生在对象写的操作,顾称此为写屏障,还一种可能的情况就是发生在晋升时。记录集的维护只要关心对象的写操作和晋升操作即可。此是又带来了另一个问题:

  • 每次写操作时维护记录集的额外开销

优化的手段是在一些Crankshaft操作中是不需要写屏障的,还有就是栈上内存对象的写操作是不需要写屏障的。还有一些,更多的手段就不在这里过多讨论。

  • 缓解Scavenge算法内存利用率不高问题

新生代内存中存活对象占比是相对较小的,因此可以在分配空间时,ToSpace可以分配的小一些。做法是将ToSpace空间分成S0S1两部分,S0用作于ToSpaceS1与原FromSpace合并当成FromSpace

image.png

Scavenge算法中深度/广度优先的区别

垃圾回收算法中,识别内存对象是否是垃圾的机制一般有两种:引用计数基于可达性分析

基于可达性分析,就是找出所有的根引用(比如全局变量等),遍历所有根引用,递归根引用上的所有引用,凡是被遍历到的都是存活对象并打上标记,此时空间中的其他内存对象都是死对象,由此构建了一个有向图

考虑到递归的限制问题,递归逻辑一般采用非递归实现,常见的有广度优先和深度优先算法。两者的区别在于:

  • 深度优先拷贝到ToSpace时改变了内存对象的排列顺序,使得有引用关系的对象距离较近。原因是拷贝完自己之后直接拷贝自己引用的对象,因此相关的对象便在ToSpace中靠的较近
  • 深度优先正好相反

因为CPU的缓存策略,会在读取内存对象时有很大概率把他后面的对象一起读,目的是为了更快的命中缓存。因为在代码开发期间很常见的场景就是obj1.obj2.obj3,此时CPU读取obj1时如果把后面的obj2obj3一起读的话,则很利于命中缓存。

所以深度优先的算法更利于业务逻辑命中缓存,但是其实现需要依赖额外的栈辅助实现算法,对内存空间有消耗。广度优先则相反,无法提升缓存命中,但是其实现可以利用指针巧妙的避开空间消耗,算法的执行效率高。

新生代内存对象的晋升条件

新生代中的内存对象如果想晋升到老生代需要满足如下几个条件:

  • 对象是否经历过Scavenge回收
  • ToSpace的内存使用占比不能超过限制

判断是否经历过Scavenge的GC的逻辑是,每次GC时给存活对象的age属性+1,当再次GC的时候判断age属性即可。基本的晋升示意图如下所示:

image.png

老生代内存中,长期存活的对象较多,无法采取Scavenge算法回收的原因在于:

  • 存活对象较多导致复制效率低下
  • 浪费了一半的内存空间

老生代内存对象的回收算法

老生代内存空间的垃圾回收采用的是标记清除Mark-Sweep)和标记整理Mark-Compact)结合的方式。标记清除分为两部分:

  • 标记阶段
  • 清除阶段(如果是标记整理则是整理阶段)

在标记阶段遍历老生代堆内存中的所有内存对象,并对活着的对象做标记,清除阶段只清理未被标记的对象。原因是:老生代内存中非存活对象占少数。

image.png

如上图所示,标记清除存在的一个问题是清理之后存在了不连续的空间导致无法继续利用,所以对于老生代内存空间的内存清理需要结合标记整理的方案。该方案是在标记过程中将活着的对象往一侧移动,移动完成后再清理界外的所有非存活对象移除。

image.png

垃圾回收的全暂停

垃圾回收时需要暂停应用执行逻辑,待垃圾回收机制结束后再恢复应用执行逻辑,该行为称为“全暂停”,也就是常说的Stop The World,简称STW。对新生代内存的垃圾回收该行为对应用执行影响不大,但是老生代内存由于存活对象较多,所以老生代内存的垃圾回收造成的全停顿影响非常大。

image.png

V8为了优化GC的全暂停时间,还引入了增量标记并发标记并行标记增量整理并行清理延迟清理等方式。

STW优化

衡量垃圾回收所用时间的一个重要指标是执行 GC 时主线程暂停的时间量。STW所带来的影响是无法接受的,因此V8也采取的很多优化手段。

  • 并行GC

GC的过程需要做大量的事情从而在主线程上导致STW现象,并行GC的做法是开多个辅助线程分担GC的事情。该做法依然无法避免STW现象的,但是可以减少STW的总时间,取决于开启的辅助线程数量。

image.png

  • 增量GC

增量GC将GC工作进行拆分,并在主线程中间歇的分步执行。该做法并不会减少GC的时间,相反会稍微花销,但是它同样会减少GC的STW的总时间。

image.png

  • 并发GC

并发GC是指GC在后台运行,不再在主线程运行。该做法会避免STW现象。

image.png

  • 空闲时间GC

Chrome中动画的渲染大约是60帧(每帧约16ms),如果当前渲染所花费时间每达到16.6ms,此时则有空闲时间做其他事情,比如部分GC任务。

image.png

减少垃圾回收的影响

想要提高执行效率要尽量减少垃圾回收的执行和消耗:

  • 慎把内存当作缓存,小心把对象当作缓存,要合理限制过期时间和无限增长的问题,可以采用lru策略

  • Node中避免使用内存存储用户会话,否则在内存中存放大量用户会话对象导致老生代内存激增,影响清理性能进而影响应用执行性能和内存溢出。改进方式使用使用redis等。将缓存转移到外部的好处:

    • 减少常驻内存对象的数量,垃圾回收更高效
    • 进程之间可以共享缓存

结束语

你好,我是愣锤,如果喜欢这篇文章,欢迎点赞👍收藏❤️哦~

如果你还想更进一步,还有像并发标记等算法逻辑,大家可以进一步深入了解~~~

参考资料