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