Node程序运行中,此进程占用的所有内存称为常驻内存(Resident Set)。
代码区(Code Segment):存放即将执行的代码片段
栈(Stack):存放局部变量
堆(Heap):存放对象、闭包上下文
上图中的带斜纹的区域代表暂未使用的内存,新生代(new_space)被划分为了两个部分,其中一部分叫做inactive new space,表示暂未激活的内存区域,另一部分为激活状态
除堆外内存,其余部分均由V8管理。
通过 process.memoryUsage()
可以查看此Node进程的内存使用状况:
// process.memoryUsage()
{
rss: 22573056, // rss是Resident Set Size的缩写,常驻进程内存,所有内存占用
heapTotal: 9682944, // V8为堆分配的总大小,已申请堆内存
heapUsed: 5263128, // 已使用堆内存
external: 16910 // c++对象绑定到js的内存
}
默认情况下,V8为堆分配的内存不超过1.4G:64位系统1.4G,32位则仅分配0.7G。也就是说,如果你想使用Node程序读一个2G的文件到内存,在默认的V8配置下,是无法实现的。不过我们可以通过Node的启动命令修改V8为堆设置的内存上限:
// 修改老年代堆内存
--max-old-space-size=2048 // 单位为MB
// 修改新生代堆内存,默认是16M
--max-semi-space-size=1024 // 单位为MB
堆的内存上限在启动时就已经决定,无法动态更改,想要更改,唯一的方法是关闭进程,使用新的配置重新启动。
垃圾回收机制演变至今,已经出现了数种垃圾回收算法,各有千秋,适用于不同场景,没有一种垃圾回收算法能够效率最优于所有场景。因此研发者们按照存活时间长短,将对象分类,为每一类特定的对象,制定其最适合的垃圾回收算法,以提高垃圾回收总效率。
V8将堆中的对象分为两类:
新生代:年轻的新对象,未经历垃圾回收或仅经历过一次
在V8引擎的内存结构中,新生代主要用于存放存活时间较短的对象。新生代内存是由两个 semispace(半空间)
构成的,内存最大值在 64
位系统和 32
位系统上分别为 32MB
和 16MB
,在新生代的垃圾回收过程中主要采用了 Scavenge
算法。
Scavenge
算法是一种典型的牺牲空间换取时间的算法,对于老生代内存来说,可能会存储大量对象,如果在老生代中使用这种算法,势必会造成内存资源的浪费,但是在新生代内存中,大部分对象的生命周期较短,在时间效率上表现可观,所以还是比较适合这种算法。
新对象都会被分配到新生代中,当新生代空间不足以分配新对象时,将触发新生代的垃圾回收。
新生代中的对象主要通过Scavenge算法进行垃圾回收,这是一种采用复制的方式实现内存回收的算法。
Scavenge算法将新生代的总空间一分为二,只使用其中一个,另一个处于闲置,等待垃圾回收时使用。使用中的那块空间称为From,闲置的空间称为To。
当新生代触发垃圾回收时,V8将From空间中所有应该存活下来的对象依次复制到To空间。
有两种情况不会将对象复制到To空间,而是晋升至老年代:
From空间所有应该存活的对象都复制完成后,原本的From空间将被释放,成为闲置空间,原本To空间则成为使用中空间,两个空间进行角色翻转。
为何To空间使用超过25%时,就需要直接将对象复制到老年代呢?因为To空间完成垃圾回收后将会翻转为From空间,新的对象分配都在此处进行,如果没有足够的空闲空间,将会影响程序的新对象分配。
因为Scavenge只复制活着的对象,而根据统计学指导,新生代中大多数对象寿命都不长,长期存活对象少,则需要复制的对象相对来说很少,因此总体来说,新生代使用Scavenge算法的效率非常高。且由于Scavenge是依次连续复制,所以To空间永远不会存在内存碎片。
不过由于Scavenge会将空间对半划分,所以此算法的空间利用率较低。
在老年代中的对象,至少都已经历过一次甚至更多次垃圾回收,相对于新生代中的对象,它们有更大的概率继续存活,只有相对少数的对象面临死亡,且由于老年代的堆内存是新生代的几十倍,其中生活着大量对象,因此如果使用Scavenge算法回收老年代,将会面临大量的存活对象需要复制的情况,将老年代空间对半划分,也会浪费相当大的空间,效率低下。因此老年代垃圾回收主要采用标记清除(Mark-Sweep)和标记整理(Mark-Compact)。
这两种方式并非互相替代关系,而是配合关系,在不同情况下,选择不同方式,交替配合以提高回收效率。
新生代中死亡对象占多数,因此采用Scavenge算法只处理存活对象,提高效率。老年代中存活对象占多数,于是采用标记清除算法只处理死亡对象,提高效率。
当老年代的垃圾回收被触发时,V8会将需要存活对象打上标记,然后将没有标记的对象,也就是需要死亡的对象,全部擦除,一次标记清除式回收就完成了:
灰色为存活对象,白色为清除后的闲置空间
一切看起来都完美了,可是随着程序的继续运行,却会出现一个问题:被清除的对象遍布各个内存地址,空间有大有小,其闲置空间不连续,产生了很多内存碎片。当需要将一个足够大的对象晋升至老年代时,无法找到一个足够大的连续空间安置这个对象。
为了解决这种空间碎片的问题,就出现了标记整理算法。它是在标记清除的基础上演变而来,当清理了死亡对象后,它会将所有存活对象往一端移动,使其内存空间紧挨,另一端就成为了连续内存:
虽然标记整理算法可以避免空间碎片,但是却需要依次移动对象,效率比标记清除算法更低,因此大多数情况下V8会使用标记清理算法,当空间碎片不足以安放新晋升对象时,才会触发标记整理算法。
早期V8在垃圾回收阶段,采用全停顿(stop the world),也就是垃圾回收时程序运行会被暂停。这在JavaScript还仅被用于浏览器端开发时,并没有什么明显的缺点,前端开发使用的内存少,大多数时候仅触发新生代垃圾回收,速度快,卡顿几乎感觉不到。但是对于Node程序,使用内存更多,在老年代垃圾回收时,全停顿很容易带来明显的程序迟滞,标记阶段很容易就会超过100ms,因此V8引入了增量标记,将标记阶段分为若干小步骤,每个步骤控制在5ms内,每运行一段时间标记动作,就让JavaScript程序执行一会儿,如此交替,明显地提高了程序流畅性,一定程度上避免了长时间卡顿。