众所周知,JS是自动管理垃圾回收的,开发者不需要关心内存的分配与回收。而且垃圾回收机制在前端面试中也是常考的部分。本文主要讲解V8的分代垃圾回收算法,希望阅读本文后的小伙伴能够对V8
垃圾回收机制有个痛彻
(哈哈,是痛彻
!!!)的了解,文章主要涵盖如下内容:
V8
的内存限制与解决办法- 新生代内存对象的
Scavenge
算法 - 基于
可达性分析算法
标记存活对象的逻辑以及优化手段 - 新生代内存对象的晋升条件、
Scavenge
算法的深度/广度优先区别- 跨代内存的的写屏障
- 老生代内存对象的标记清除/整理算法
GC
的STW
原因及优化策略
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 maxV8垃圾回收策略
在引擎的垃圾自动回收机制的历史演变中,人们发现是没有一种通用的可以解决任何场景下垃圾回收的算法的。因此现代垃圾回收算法根据对象的存活时间将内存垃圾进行分代,分代垃圾回收算法就是对不同类别的内存垃圾实行不同的回收算法。
V8
将内存分为新生代
和老生代
两种:
- 新生代内存中的对象存活时间较短
- 老生代内存中代对象存活时间较长或是常驻内存
新生代内存存放在新生代内存空间(semispace
)中,老生代内存存放在老生代内存空间中(oldspace
),如下图所示:
- 新生代内存采用
Scavenge
算法 - 老生代内存采用
Mark-Sweep
和Mark-Compact
算法
下面我们看看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
*** 作中是不需要写屏障的,还有就是栈上内存对象的写 *** 作是不需要写屏障的。还有一些,更多的手段就不在这里过多讨论。
- 缓解
Scavenge
算法内存利用率不高问题
新生代内存中存活对象占比是相对较小的,因此可以在分配空间时,ToSpace
可以分配的小一些。做法是将ToSpace
空间分成S0
和S1
两部分,S0
用作于ToSpace
,S1
与原FromSpace
合并当成FromSpace
。
垃圾回收算法中,识别内存对象是否是垃圾的机制一般有两种:引用计数和基于可达性分析。
基于可达性分析,就是找出所有的根引用(比如全局变量等),遍历所有根引用,递归根引用上的所有引用,凡是被遍历到的都是存活对象并打上标记,此时空间中的其他内存对象都是死对象,由此构建了一个有向图。
考虑到递归的限制问题,递归逻辑一般采用非递归实现,常见的有广度优先和深度优先算法。两者的区别在于:
- 深度优先拷贝到
ToSpace
时改变了内存对象的排列顺序,使得有引用关系的对象距离较近。原因是拷贝完自己之后直接拷贝自己引用的对象,因此相关的对象便在ToSpace
中靠的较近 - 深度优先正好相反
因为CPU的缓存策略,会在读取内存对象时有很大概率把他后面的对象一起读,目的是为了更快的命中缓存。因为在代码开发期间很常见的场景就是obj1.obj2.obj3
,此时CPU读取obj1
时如果把后面的obj2
、obj3
一起读的话,则很利于命中缓存。
所以深度优先的算法更利于业务逻辑命中缓存,但是其实现需要依赖额外的栈辅助实现算法,对内存空间有消耗。广度优先则相反,无法提升缓存命中,但是其实现可以利用指针巧妙的避开空间消耗,算法的执行效率高。
新生代内存对象的晋升条件新生代中的内存对象如果想晋升到老生代需要满足如下几个条件:
- 对象是否经历过
Scavenge
回收 ToSpace
的内存使用占比不能超过限制
判断是否经历过Scavenge
的GC的逻辑是,每次GC
时给存活对象的age
属性+1
,当再次GC
的时候判断age
属性即可。基本的晋升示意图如下所示:
老生代内存中,长期存活的对象较多,无法采取Scavenge
算法回收的原因在于:
- 存活对象较多导致复制效率低下
- 浪费了一半的内存空间
老生代内存空间的垃圾回收采用的是标记清除
(Mark-Sweep
)和标记整理
(Mark-Compact
)结合的方式。标记清除分为两部分:
- 标记阶段
- 清除阶段(如果是标记整理则是整理阶段)
在标记阶段遍历老生代堆内存中的所有内存对象,并对活着的对象做标记,清除阶段只清理未被标记的对象。原因是:老生代内存中非存活对象占少数。
如上图所示,标记清除存在的一个问题是清理之后存在了不连续的空间导致无法继续利用,所以对于老生代内存空间的内存清理需要结合标记整理的方案。该方案是在标记过程中将活着的对象往一侧移动,移动完成后再清理界外的所有非存活对象移除。
垃圾回收的全暂停垃圾回收时需要暂停应用执行逻辑,待垃圾回收机制结束后再恢复应用执行逻辑,该行为称为“全暂停”,也就是常说的Stop The World
,简称STW
。对新生代内存的垃圾回收该行为对应用执行影响不大,但是老生代内存由于存活对象较多,所以老生代内存的垃圾回收造成的全停顿影响非常大。
V8为了优化GC的全暂停时间,还引入了增量标记
、并发标记
、并行标记
、增量整理
、并行清理
、延迟清理
等方式。
衡量垃圾回收所用时间的一个重要指标是执行 GC
时主线程暂停的时间量。STW所带来的影响是无法接受的,因此V8也采取的很多优化手段。
- 并行GC
GC的过程需要做大量的事情从而在主线程上导致STW现象,并行GC的做法是开多个辅助线程分担GC的事情。该做法依然无法避免STW现象的,但是可以减少STW的总时间,取决于开启的辅助线程数量。
- 增量
GC
增量GC将GC工作进行拆分,并在主线程中间歇的分步执行。该做法并不会减少GC的时间,相反会稍微花销,但是它同样会减少GC的STW的总时间。
- 并发
GC
并发GC是指GC在后台运行,不再在主线程运行。该做法会避免STW现象。
- 空闲时间
GC
Chrome
中动画的渲染大约是60
帧(每帧约16ms
),如果当前渲染所花费时间每达到16.6ms
,此时则有空闲时间做其他事情,比如部分GC
任务。
想要提高执行效率要尽量减少垃圾回收的执行和消耗:
慎把内存当作缓存,小心把对象当作缓存,要合理限制过期时间和无限增长的问题,可以采用lru策略
Node
中避免使用内存存储用户会话,否则在内存中存放大量用户会话对象导致老生代内存激增,影响清理性能进而影响应用执行性能和内存溢出。改进方式使用使用redis等。将缓存转移到外部的好处:- 减少常驻内存对象的数量,垃圾回收更高效
- 进程之间可以共享缓存
更多node相关知识,请访问:nodejs 教程!
以上就是聊聊V8的内存管理与垃圾回收算法的详细内容,
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)