本篇文章带大家了解一下V8引擎的内存管理与垃圾回收算法,希望对大家有所帮助!
众所周知,JS是自动管理垃圾回收的,开发者不需要关心内存的分配与回收。而且垃圾回收机制在前端面试中也是常考的部分。本文主要讲解V8的分代垃圾回收算法,希望阅读本文后的小伙伴能够对V8
垃圾回收机制有个痛彻
(哈哈,是痛彻
!!!)的了解,文章主要涵盖如下内容:
V8
的内存限制与解决办法- 新生代内存对象的
Scavenge
算法 - 基于
可达性分析算法
标记存活对象的逻辑以及优化手段 - 新生代内存对象的晋升条件、
Scavenge
算法的深度/广度优先区别- 跨代内存的的写屏障
- 老生代内存对象的标记清除/整理算法
GC
的STW
原因及优化策略
V8的内存限制与解决办法
V8最初为浏览器设计,遇到大内存使用的场景较少,在设计上默认对内存使用存在限制,只允许使用部分内存,64位系统可允许使用内存约1.4g,32位系统约0.7g。如下代码所示,在Node中查看所依赖的V8引擎的内存限制方法:
process.memoryUsage(); // 返回内存的使用量,单位字节 { rss: 22953984, // 申请的总的堆内存 heapTotal: 9682944, // 已使用的堆内存 heapUsed: 5290344, external: 9388 }
V8
限制内存使用大小还有另一个重要原因,堆内存过大时V8
执行垃圾回收的时间较久(1.5g
要50ms
),做非增量式的垃圾回收要更久(1.5g
要1s
)。在后续讲解了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
V8垃圾回收策略
在引擎的垃圾自动回收机制的历史演变中,人们发现是没有一种通用的可以解决任何场景下垃圾回收的算法的。因此现代垃圾回收算法根据对象的存活时间将内存垃圾进行分代,分代垃圾回收算法就是对不同类别的内存垃圾实行不同的回收算法。
V8
将内存分为新生代
和老生代
两种:
- 新生代内存中的对象存活时间较短
- 老生代内存中代对象存活时间较长或是常驻内存
新生代内存存放在新生代内存空间(semispace
)中,老生代内存存放在老生代内存空间中(oldspace
),如下图所示:
- 新生代内存采用
Scavenge
算法 - 老生代内存采用
Mark-Sweep
和Mark-Compact
算法
下面我们看看Scavenge
的算法逻辑吧!
Scavenge算法
对于新生代内存的内存回收采用Scavenge
算法,Scavenge
的具体实现采用的是Cheney
算法。Cheney
算法是将新生代内存空间一分为二,一个空间处于使用状态(FromSpace
),一个空间处于空闲状态(称为ToSpace
)。
在内存开始分配时,首先在FromSpace
中进行分配,垃圾回收机制执行时会检查FromSpace
中的存活对象,存活对象会被会被复制到ToSpace
,非存活对象所占用的空间将被释放,复制完成后FromSpace
和ToSpace
的角色将翻转。当一个对象多次复制后依然处于存活状态,则认为其是长期存活对象,此时将发生晋升,然后该对象被移动到老生代空间oldSpace
中,采用新的算法进行管理。
Scavenge
算法其实就是在两个空间内来回复制存活对象,是典型的空间换时间做法,所以非常适合新生代内存,因为仅复制存活的对象且新生代内存中存活对象是占少数的。但是有如下几个重要问题需要考虑:
- 引用避免重复拷贝
假设存在三个对象temp1、temp2、temp3
,其中temp2、temp3
都引用了temp1
,js代码示例如下:
var temp2 = { ref: temp1, } var temp3 = { ref: temp1, } var temp1 = {}
从FromSpace
中拷贝temp2
到ToSpace
中时,发现引用了temp1
,便把temp1
也拷贝到ToSpace
,是一个递归的过程。但是在拷贝temp3
时发现也引用了temp1
,此时再把temp1
拷贝过去则重复了。
要避免重复拷贝,做法是拷贝时给对象添加一个标记visited
表示该节点已被访问过,后续通过visited
属性判断是否拷贝对象。
- 拷贝后保持正确的引用关系
还是上述引用关系,由于temp1
不需要重复拷贝,temp3
被拷贝到ToSpace
之后不知道temp1
对象在ToSpace
中的内存地址。
做法是temp1
被拷贝过去后该对象节点上会生成新的field
属性指向新的内存空间地址,同时更新到旧内存对象的forwarding
属性上,因此temp3
就可以通过旧temp1
的forwarding
属性找到在ToSpace
中的引用地址了。
内存对象同时存在于新生代和老生代之后,也带来了问题:
- 内存对象跨代(跨空间)后如何标记
const temp1 = {} const temp2 = { ref: temp1, }
比如上述代码中的两个对象temp1
和temp2
都存在于新生代,其中temp2
引用了temp1
。假设在经过GC
之后temp2
晋升到了老生代,那么在下次GC
的标记阶段,如何判断temp1
是否是存活对象呢?
在基于可达性分析算法中要知道temp1
是否存活,就必须要知道是否有根对象引用
引用了temp1
对象。如此的话,年轻代的GC
就要遍历所有的老生代对象判断是否有根引用对象引用了temp1
对象,如此的话分代算法就没有意义了。
解决版本就是维护一个记录所有的跨代引用的记录集,它是写缓冲区
的一个列表。只要有老生代中的内存对象指向了新生代内存对象时,就将老生代中该对象的内存引用记录到记录集中。由于这种情况一般发生在对象写的操作,顾称此为写屏障,还一种可能的情况就是发生在晋升时。记录集的维护只要关心对象的写操作和晋升操作即可。此是又带来了另一个问题:
- 每次写操作时维护记录集的额外开销
优化的手段是在一些Crankshaft
操作中是不需要写屏障的,还有就是栈上内存对象的写操作是不需要写屏障的。还有一些,