v8引擎垃圾内存回收机制
8/11/2020 • ☕️ 4 min read
Js不会让程序员自己开辟或释放内存,而是有自己的一套垃圾回收算法,来进行自动的内存管理
V8内存限制
V8只能使用系统的一部分内存,具体来说,在64位系统下,V8最多只能分配1.4G, 在32位系统中,最多只能分配0.7G。你想想在前端这样的大内存需求其实并不大,但对于后端而言,nodejs如果遇到一个2G多的文件,那么将无法全部将其读入内存进行各种操作了。
所有的对象类型的数据在JS中都是通过堆进行空间分配的。当我们构造一个对象进行赋值操作的时候,其实相应的内存已经分配到了堆上。你可以不断的这样创建对象,让V8为它分配空间,直到堆的大小达到上限。设置内存上限是因为两个方面:
第一,JS是单线程运行的,这意味着一旦进入到垃圾回收,那么其它的各种运行逻辑都要暂停; 另一方面垃圾回收其实是非常耗时间的操作
以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式(ps:后面会解释)的垃圾回收甚至要 1s 以上。
可见其耗时之久,而且在这么长的时间内,我们的JS代码执行会一直没有响应,造成应用卡顿,导致应用性能和响应能力直线下降。因此,V8 做了一个简单粗暴的选择,那就是限制堆内存,也算是一种权衡的手段,因为大部分情况是不会遇到操作几个G内存这样的场景的。
调整内存限制
// 这是调整老生代这部分的内存,单位是MB。后面会详细介绍新生代和老生代内存
node --max-old-space-size=2048 xxx.js或者
// 这是调整新生代这部分的内存,单位是 KB。
node --max-new-space-size=2048 xxx.js新生代内存的回收
V8把堆内存分成了两部分进行处理——新生代内存和老生代内存。顾名思义,新生代就是临时分配的内存,存活时间短, 老生代是常驻内存,存活的时间长。V8 的堆内存,也就是两个内存之和。
新生代的垃圾回收是怎么做的呢,首先,新生代内存空间一分为二:
其中From部分表示正在使用的内存,To 是目前闲置的内存。
当进行垃圾回收时,V8将From部分的对象检查一遍,如果是存活对象那么复制到To内存中(在To内存中按照顺序从头放置的),如果是非存活对象直接回收即可。
当所有的From中的存活对象按照顺序进入到To内存之后,From 和 To 两者的角色对调,From现在被闲置,To为正在使用,如此循环。
老生代内存的回收
刚刚学习了新生代的回收方式,那么新生代中的变量如果经过多次回收后依然存在,那么就会被放入到老生代内存中,这种现象就叫晋升。
发生晋升其实不只是这一种原因,我们来梳理一下会有那些情况触发晋升:
- 已经经历过一次 Scavenge 回收。
- To(闲置)空间的内存占用超过25%。
现在进入到老生代的垃圾回收机制当中,老生代中累积的变量空间一般都是很大的,当然不能用Scavenge算法啦,浪费一半空间不说,对庞大的内存空间进行复制岂不是劳民伤财?
那么对于老生代而言,究竟是采取怎样的策略进行垃圾回收的呢?
第一步,进行标记-清除。这个过程在《JavaScript高级程序设计(第三版)》中有过详细的介绍,主要分成两个阶段,即标记阶段和清除阶段。首先会遍历堆中的所有对象,对它们做上标记,然后对于代码环境中使用的变量以及被强引用的变量取消标记,剩下的就是要删除的变量了,在随后的清除阶段对其进行空间的回收。
当然这又会引发内存碎片的问题,存活对象的空间不连续对后续的空间分配造成障碍。老生代又是如何处理这个问题的呢?
第二步,整理内存碎片。V8 的解决方式非常简单粗暴,在清除阶段结束后,把存活的对象全部往一端靠拢。
增量标记
由于JS的单线程机制,V8 在进行垃圾回收的时候,不可避免地会阻塞业务逻辑的执行,倘若老生代的垃圾回收任务很重,那么耗时会非常可怕,严重影响应用的性能。那这个时候为了避免这样问题,V8 采取了增量标记的方案,即将一口气完成的标记任务分为很多小的部分完成,每做完一个小的部分就"歇"一下,就js应用逻辑执行一会儿,然后再执行下面的部分,如果循环,直到标记阶段完成才进入内存碎片的整理上面来。其实这个过程跟React Fiber的思路有点像,这里就不展开了。
经过增量标记之后,垃圾回收过程对JS应用的阻塞时间减少到原来了1 / 6, 可以看到,这是一个非常成功的改进。