理解 Node.js 的 GC 机制
《深入浅出Node.js》第五章《内存控制》阅读笔记
随着 Node 的发展,JavaScript 的应用场景早已不再局限在浏览器中。本文不讨论网页应用、命令行工具等短时间执行,且只影响终端用户的场景。由于运行时间短,随着进程的退出,内存会释放,几乎没有内存管理的必要。但随着 Node 在服务端的广泛应用,JavaScript 的内存管理需要引起我们的重视。
V8 的内存限制
在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在 Node 中通过 JavaScript 使用内存时就会发现只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB)。在这样的限制下,将会导致 Node 无法直接操作大内存对象。
造成这个问题的主要原因在于 Node 的 JavaScript 执行引擎 V8。
在 V8 中,所有的 JavaScript 对象都是通过堆来进行分配的。Node 提供了 V8 中内存的使用量查看方法
process.memoryUsage()。
heapTotal
已申请到的堆内存heapUsed
当前使用的堆内存
为什么 V8 要限制堆的大小:
- V8 为浏览器而设计,不太可能遇到用大量内存的场景
- V8 的垃圾回收机制的限制。(按官方的说法,以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收需要1s以上)
V8提供了选项让我们可以控制使用内存的大小
node --max-old-space-size=1700 test.js
设置老生代内存空间最大值,单位为MBnode --max-new-space-size=1024 test.js
设置新生代内存空间最大值,单位为KB
比较遗憾的是,这两个最大值需要在启动时执行。这意味着 V8 使用的内存没办法根据使用的情况自动扩充,当内存分配过程中超过极限值时,就会引起进程出错。
V8 的垃圾回收机制
V8 的垃圾回收策略主要基于分代式垃圾回收机制。在 V8 中,主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
V8 堆的整体大小就是新生代的内存空间加上老生代的内存空间
Scavenge 算法
在分代的基础上,新生代中的对象主要通过
Scavenge算法进行垃圾回收。在 Scavenge 的具体实现中,主要采用了
Cheney算法。
Cheney 算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间成为
semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置中。处于使用中的 semispace 空间成为From空间,处于闲置状态的空间成为To空间。当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间中的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将被释放。完成复制后, From 空间和 To 空间的角色发生对换。
Scavenge 的缺点是只能使用堆内存的一半,但 Scavenge 由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上表现优异。Scavenge 是典型的牺牲空间换取时间的算法,无法大规模地应用到所有的垃圾回收中,但非常适合应用在新生代中。
晋升
对象从新生代中移动到老生代中的过程称为晋升。
From 空间中的存活对象在复制到 To 空间之前需要进行检查,在一定条件下,需要将存活周期长的对象移动到老生代中,也就是完成对象的晋升。
晋升条件主要有两个:
- 对象是否经历过一次 Scavenge 回收
- To 空间已经使用超过 25%
设置 25% 这个限制值得原因是当这次 Scavenge 回收完成后,这个 To 空间将变成 From 空间,接下来的内存分配将在这个空间中进行,如果占比过高,会影响后续的内存分配。
Mark-Sweep & Mark-Compact
V8 在老生代中主要采用了 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收。
Mark-Sweep 是标记清除的意思,它分为两个阶段,标记和清除。Mark-Sweep 在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除未被标记的对象。
Mark-Sweep 最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
为了解决 Mark-Sweep 的内存碎片问题,Mark-Compact 被提出来。Mark-Compact是标记整理的意思,是在 Mark-Sweep 的基础上演进而来的。它们的差别在于对象在标记为死亡后,在整理过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
下表为3种主要垃圾回收算法的简单比较
从表中可以看出,在 Mark-Sweep 和 Mark-Compact 之间,由于 Mark-Compact 需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8 主要使用 Mark-Sweep,在空间不足以从新生代中晋升过来的对象进行分配时才使用 Mark-Compact 。
Incremental Marking
为了避免出现 JavaScript 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种算法都需要将应用逻辑暂停下来,这种行为称为“全停顿” (stop-the-world)。
由于新生代配置的空间较小,存活对象较少,全停顿对新生代影响不大。但老生代通常配置的空间较大,且存活对象较多,全堆垃圾回收(full 垃圾回收)的标记、清除、整理等动作造成的停顿就会比较可怕。
为了降低全堆垃圾回收带来的停顿时间,V8 先从标记阶段入手,将原本要一口气停顿完成的动作改成增量标记(Incremental Marking),也就是拆分为许多小“步进”,每做完一“步进”就让JavaScript应用逻辑执行一小会儿,垃圾回收和应用逻辑交替执行直到标记阶段完成。
V8 在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的 1/6 左右。
查看GC日志
查看垃圾回收日志的方式主要是在启动时添加
--trace_gc参数。
小结
- Node 的 JavaScript 执行引擎为 V8,内存使用和控制也受限于 V8。
- V8 把内存分为新生代和老生代,分别存放存活时间较短和存活时间较长或常驻内存的对象。
- 在新生代中使用 Scavenge 算法进行垃圾回收,优点是速度快无内存碎片,缺点是占用双倍内存空间。
- 在老生代中将 Mark-Sweep 和 Mark-Compact 两种算法结合使用,主要使用 Mark-Sweep,优点的是无需移动对象,缺点是产生内存碎片。Mark-Compact 是对 Mark-Sweep 的补充,在空间不足以对新晋升的对象进行分配时整理内存,清除内存碎片,由于要移动对象,速度较慢。
- V8 使用 Incremental Marking 来减少全停顿带来的影响。
- Node.js机制及原理理解初步
- Node.js机制及原理理解初步
- Node.js机制及原理理解初步
- Node.js机制及原理理解初步
- Node.js机制及原理理解初步
- Node.js--异步机制下保证逻辑顺序的一些理解
- 深入理解 Node.js Stream 内部机制
- 理解 Node.js 事件驱动机制的原理
- Node.js机制及原理理解初步
- 理解GC机制
- 深入浅出Node.js(四):Node.js的事件机制
- 初步理解js事件循环机制
- Node.js和PHP运行机制对比
- JS核心系列:理解 new 的运行机制
- 理解node.js
- node.js require 实现机制初窥;pomelo代码分析5----------- pomel-loader模块
- 如何通过setTimeout理解JS运行机制详解
- 深入理解Node.js中的垃圾回收和内存泄漏的捕获
- node.js module初步理解
- Node.js的模块机制