V8的内存分配
在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB),在这样的限制下,将会导致Node无法直接操作大内存对象。比如无法将一个2GB的文件读入内存中进行字符串分析处理,即使物理内存大于2GB,这样在单个Node进程的情况下,计算机的内存资源无法得到充足的使用。
造成这个问题的主要原因在于Node基于V8构建,所以在Node中使用的JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。V8这套内存管理机制在浏览器的应用场景下使用起来是绰绰有余,足以胜任前端页面中的所有需求。但在Node中,却限制了开发者随心所欲使用大内存的想法。
原因
表层原因在于,V8最初为浏览器设计,不大可能设计大内存使用的场景。对于一个网页(在浏览器中,一个tab有一个JS线程),1.5GB已经足够使用。从官方的说法来说,以1.5GB的垃圾回收堆内存为例,V8做一次垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1s以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。这样的情况不仅仅后端无法接受,前端浏览器同样无法接受。
当然,这个限制也不是不能改变,V8依然在启动时提供可选参数来使用更多内存。
1 | node --max-old-space-size = 1700 //老生代,单位为MB |
但是注意,这个参数在运行中是无法改变的,一旦启动,内存分配就无法改变,这一点,确实限制了Node的发挥。
V8的垃圾回收机制
之前在分析闭包的那篇文章中,全面理解JavaScript作用域与闭包,虽然详细分析了V8的内存结构,但是并没有详细分析垃圾回收机制,这里详细看一下。
V8主要的垃圾回收算法
V8的垃圾回收策略主要基于分布式垃圾回收机制。在自动垃圾回收的演变过程中,人们发现暂时没有一种垃圾回收算法能够胜任所有的场景。因为在实际应用中,对象的生存周期长短不一,不同的算法只能针对特定情况具有最好的效果。为此,统计学在垃圾回收算法中产生了较大的作用,现在的垃圾回收算法按对象的存活时间将内存的垃圾回收进行不同的分代。然后分别对不同的内存施以更高效的算法。
V8的内存分代
在V8中,主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象主要为存活时间较长或或者常驻内存的对象。
所以V8的堆的整体大小就是新生代所用内存空间加上老生代的内存空间。前面提到的改变Node的内存限制就是用以改变新生代和老生代的内存空间的大小。
Scavenge算法
在分代的基础上,新生代中的对象主要是通过Scavenge算法进行垃圾回收的。在Scavenge的具体实现中,主要采用了Cheney算法,该算法由C.J.Cheney于1970年首次发表在ACM论文上。
Cheney算法是一种采用复制的方法实现的垃圾回收算法。它将堆的内存一分为二,每一部分空间称为semispace。在这两个semispace中,只有一个处于使用中,另一个处于空闲中。处于使用状态的semispace称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间进行分配。当开始机型垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的工程中,就是通过将存活对象在两个semispace空间之间进行复制。
Scavenge的缺点是只能使用堆内存的一半,这是由划分空间和复制机制决定的。但是Scavenge由于只复制存活的对象,并且对于生命周期短的场景只占少部分,所以它在时间效率上有优异的表现。
由于Scavenge是典型的空间换时间的算法,所以无法大规模地应用到所有的垃圾回收机制中。但是可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。所以,V8的堆内存分配应当如下:
实际使用的堆是新生代的两个semispace空间大小和老生代所使用的内存大小之和。
新生代向老生代的转换
在V8中,在两种情况下,新生代的对象会转变为老生代的对象(对象晋升):
- 当一个对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收,如果已经经历过了,会检查它的内存来判断这个对象是否已经经历过一次Scavenge回收,如果经历过了,将该对象从From空间复制到老生代空间中,如果没有,则复制到To空间中。
- 当要从From空间复制一个对象到To空间时,如果To空间的内存占用使用超过了25%,则这个对象直接晋升到老生代空间中。这个限制的原因在于当这次Scavenge回收完成后,这个To空间将会变成From空间,接下来的内存分将会在这个空间中进行。如果占比过高,会影响后续的内存分配。
Mark-Sweep & Mark-Compact
在老生代的对象中,由于存活对象占较大的比例,再采用Scavenge算法就会出现两个问题:
- 存活的对象较多,复制存活对象的效率会降低。
- 浪费另一半内存的弊端在这个就先会放大。
为此,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
Mark-Sweep
Mark-Sweep即标记清楚,它分为标记和清楚两个阶段。其核心在于,Mark-Sweep再标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清楚阶段中,只清楚没有被标记的对象。
可以看出,Scavenge中只复制标记的对象,而Mark-Sweep只清除死亡的对象。
而活对象在新生代中只占小部分,死对象在老生代只占较少部分,所以这两种算法刚好能够发挥最大性能。示意图如下(黑色表示死亡的对象):
缺点
Mark-Sweep最大问题在于一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后面的内存分配造成问题,因为可能会出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就提前触发垃圾回收,而这次回收是不必要的。
Mark-Compact
为了解决上面Mark-Sweep的碎片问题,在Mark-Sweep上的基础上提出了Mark-Compact,即标记整理。他们的差别在,Mark-Compact会在对象被标记为死亡后,在整理的过程中,将活着的对象向一端移动,移动完成后,直接清理掉边界外的内存。示意图如下(白色各自为存活对象,深色格子为死亡对象,浅色对象为存活对象移动后留下的空间):
完成移动后,就可以直接清除最右边的存活对象后面的内存区域完成回收。
但是由于Mark-Sweep与Mark-Compact的时间复杂度有很大差距,所以V8采用了结合使用的方式。将其对比如下:
参数/回收算法 | Mark-Sweep | Mark-Compact | Scavenge |
---|---|---|---|
速度 | 较慢 | 很慢 | 较快 |
碎片 | 有 | 无 | 无 |
空间开销 | 少 | 少 | 双倍空间 |
发生移动 | 否 | 是 | 是 |
具体上,V8主要使用Mark-Sweep,在空间不足以对从新生代晋升过来的对象进行分配时才使用Mark-Compact。
Incremental Marking
为了避免出现JavaScript逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都要将应该逻辑暂停下来,待执行完毕垃圾回收后再恢复执行应用的逻辑,这种行为被称为”全停顿“(stop-the-world)。在V8的分布式垃圾回收中,一次小垃圾的回收只影响新生代,由于新生代默认配置得比较少,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成得停顿就会比较可怕,需要设法改善。
为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是将回收的时间拆分为多个小”步进“,每做完一个”步进“,就让JavaScript应用逻辑执行一会,垃圾回收与应用逻辑交替执行直到标记阶段完成。
经过改进,垃圾回收的最大停顿时间可以减少到原来的1/6左右。
V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理的动作也变为增量式的。同时还引入了并行标记和并行清理,进一步利用多核性能降低每次的停顿动作。
查看日志
我们可以通过在Node启动的时候加上trace_gc
来查看Node的垃圾回收日志。
1 | node --trace_gc app.js |
下面贴出一部分运行时日志示例:
1 | [14632:000002A161C54EC0] 395853 ms: Scavenge 21.0 (24.3) -> 19.8 (22.0) MB, 1.4 / 0.0 ms (average mu = 0.995, current mu = 0.962) allocation failure |
通过垃圾回收日志,可以了解垃圾回收的运行状态,找出垃圾回收的哪些阶段比较耗时,触发的原因是什么。
在启动的时候加上--prof
参数可以得到V8运行时的性能分析数据。期中也包含垃圾回收时占用的时间。
1 | node --prof app.js |
这将会在文件根目录生成一个xxx-v8.log文件,从这个文件可以读到每个内存的动作(包括shared libraries, code-creation, sfi, tick等),但是仍然不是很好读,下面是一段示例代码:
1 | v8-version,7,7,299,13,-node.16,0 |
我们可以通过V8提供的API:
1 | node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt |
生成profile文件,下面是例子(部分):
1 | Statistical profiling result from .\isolate-000001A24906A4A0-14888-v8.log, (4214 ticks, 0 unaccounted, 0 excluded). |
也可以采用其他第三方工具进行分析,具体不再细讲。
解决办法
V8的这些限制在浏览器端看起来很合理,一个页面很少能占用超多2GB内存,但是在服务端,这就显得捉襟见肘了,为此,Node开发了Buffer
,Stream
等模块,来弥补V8的不足,后面会陆续详解。
引用
本文大部分参考《深入浅出nodejs》以及Node官网