JS垃圾回收

240 阅读5分钟

JavaScript如何进行垃圾回收?

JavaScript和Java一样,都是由垃圾回收机制来自动进行内存管理。而对于性能敏感的服务器端程序,内存管理的好坏,垃圾回收状况是否优良,都会对服务造成影响。
这些都与javascript执行引擎V8息息相关。
JavaScript垃圾回收机制其实很简单:找出不再使用的变量,然后释放掉其占用的内存。
但具体V8引擎是怎么操作的,可就大有门道了。

V8的对象分配和内存限制

在V8中,所有的JS对象都是通过堆来进行分配的。
当我们在代码中声明变量并进行赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。
具体来说,在64位系统下,V8最多只能分配1400MB,32位系统下是700MB。
如果想要提高V8的限制,可以在启动时输入以下命令调整。

node --max-old-space-size=2400 test.js //单位为MB
node --max-new-space-size=1025 test.js //单位为KB

为什么V8要限制堆内存的大小呢?
按官方说法,以 1.5G 的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50ms以上,做一次非增量式垃圾回收甚至需要1s以上。
这是垃圾回收中引起的JavaScript线程暂停执行时间,在这样的时间花销下,应用性能和响应能力都会直线下降。

V8的内存分代

在V8中,主要将内存分为新生代老生代
新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
也就是说,活对象在新生代中只占较小部分,死对象在老生代中只占较小部分。这也和它不同的垃圾回收算法有关

新生代中的对象如何才能进入老生代?(晋升)

  1. 对象是否经历过Scavenge回收
  2. To空间的内存占用比超过25%

新生代垃圾回收算法(Scavenge算法)

Scavange算法具体实现中 主要采用了Cheney算法。Cheney算法是一种采用复制的方式实现的垃圾回收算法。
它将堆内存一分为二,每一部分空间称为semispace
其中From部分表示正在使用的内存,To 是目前闲置的内存。
当进行垃圾回收时,V8 将From部分的对象检查一遍,如果是存活对象那么复制到To内存中(在To内存中按照顺序从头放置的),如果是非存活对象直接回收即可。
当所有的From中的存活对象按照顺序进入到To内存之后,From 和 To两者的角色对调,From现在被闲置,To为正在使用,如此循环。

这是典型的牺牲空间换取时间的算法

老生代垃圾回收算法(Mark-Sweep和Mark-Compact相结合)

Mark-Sweep: 标记-清除(只清除死亡对象)
在标记阶段,遍历堆中所有对象,并标记存活的对象。
在清除阶段,清除所有没被标记的对象
最大问题:进行完一次标记清除之后,内存空间会出现不连续的状态。会对后续的内存分配造成问题。如果出现一个大对象,有可能所有的碎片空间 都无法完成这次分配。

Mark-Compact: 标记-整理
在Mark-Sweep基础上演变而来 在标记完之后,在整理的过程中,将活着的对象往一端移动,然后移动完成后,直接清理掉边界外的内存。 因为它需要移动对象,所以所需要的时间也相对较长。

三种垃圾回收算法的简单对比

回收算法 Mark-Sweep Mark-Compact Scavenge
速度 中等 最慢 最快
空间开销 少(有碎片) 少(无碎片) 双倍空间(无碎片)
是否移动对象

V8在垃圾回收上做的提升

为了避免出现 JavaScript应用逻辑垃圾回收操作产生不一致的冲突,垃圾回收的三种基本算法都需要将应用逻辑暂停下来,待垃圾回收完成后,再恢复执行应用逻辑,这种行为被称为全停顿

为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原来一口气停顿完成的动作改为增量标记(Incremental Marking),也就是拆分为许多小步进行,每做完一小步,就让JavaScript应用逻辑执行一会儿,垃圾回收与应用逻辑交替执行,直到标记阶段完成。
V8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。

V8后续还引入了 延迟清理增量式整理,让清理与整理动作也变成增量式的。
同时还计划引入 并行标记并行清理,利用多核性能降低每次停顿的时间。

相关知识

JavaScrit是单线程执行,垃圾回收会引起JavaScript线程暂停执行。
闭包与内存泄漏。

参考文章

《深入浅出nodejs》朴灵著。