NodeJS内存使用分析实例

最近在使用NodeJS开发大文件上传服务。按照之前使用C/C++开发的经验,对于大文件,显然不能简单地使用读取文件到内存然后再处理发送的模式,这种模式的问题就是导致内存占用会急剧增长,在多线程状态下更加难以维持进程稳定性。常见的解决办法就是采用分片处理上传的模式。不过对于NodeJS来说,有更加优秀的模型封装:Stream来解决此类问题。

对于Stream如何使用就不再赘述了,网上资料非常多。不过就是在我阅读资料并尝试的时候我发现了新的问题,也就是今天分享的案例。

首先是我看了这篇文档,测试代码如下:

function copy(src, target) {
    console.log(target);
    fs.writeFileSync(target, fs.readFileSync(src));
}

本地测试了一下一个1.02GB文件,竟然没有发生想象中的异常,然后又去测试了另外一个4.09GB的文件,终于发生了想象中的异常,不过稍微差异的异常。

RangeError: File size is greater than possible Buffer: 0x7fffffff bytes
    at FSReqWrap.readFileAfterStat [as oncomplete] (fs.js:453:11)

显然,Buffer的尺寸限制发生了变化。

Buffer的实现跟其他普通的JS对象实现不一样,它是由V8的C++代码在Native Heap上分配实现的,而普通的对象则是在V8 Heap上分配内存空间的。V8 Heap的大小由NodeJS运行时通过指定--max-old-space-size=XXXX来设置,默认是512MB。如果是V8 Heap空间不足导致的RangeError应该是如下的样子:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed — process out of memory

确实上篇文档在编写时,NodeJS中关于Buffer的限制是来源于旧版本Node源码(https://github.com/nodejs/node/tree/1cd1d7a182c2d16c28c778ddcd72bbeac6bc5c75)中src/smalloc.h

// mirrors deps/v8/src/objects.h
static const unsigned int kMaxLength = 0x3fffffff;

大小限制为0x3fffffff,1GB的限制,恰好符合上篇文档的描述。而在最新版NodeJS V10.8版本中,Buffer这块的实现已经发生了巨大的变化,关于大小的限制则是由V8 TypedArray中定义的。相关定义如下:
src/v8.h中,

enum { kSmiShiftSize = 31, kSmiValueSize = 32 };
...
const int kSmiValueSize = PlatformSmiTagging::kSmiValueSize; // 32
const int kSmiMinValue = (static_cast<unsigned int>(-1)) << (kSmiValueSize - 1);
const int kSmiMaxValue = -(kSmiMinValue + 1);  // 2147483647
...

static constexpr size_t kMaxLength = internal::kSmiMaxValue;

大小限制为2147483647,2GB。

对于NodeJS进程中内存分布与一般ELF可执行文件(Linux系统下)中无太多差异,不过需要注意的是多出现了V8 Heap的概念,这是由GC来处理的,而Native Heap中的对象则只能够由V8 C++代码手动管理了。写了个简单地demo来介绍下

const fs = require('fs');
const v8 = require('v8');

const filePath ='./Archive2.zip'; //没错,我就是那个1.02GB大的文件

fs.readFile(filePath,(err, data) => {
  if (err) console.log(err);
  console.log(Buffer.isBuffer(data));
  console.log(v8.getHeapSpaceStatistics());
  console.log(v8.getHeapStatistics());
  console.log(process.memoryUsage());
});

运行命令如下:
node --max-old-space-size=1024 fileTest.js
运行输出如下:

可以看出readFlie是将文件内容以Buffer的形式存储在内存中,v8.getHeapSpaceStatistics()获取的是V8 Heap中各段内存分布,这跟GC的分代回收机制有关。v8.getHeapStatistics()算是一个对V8 Heap进行统计的,可以看到我们通过设置max-old-space-size=1024成功限制了total_available_size为1100453736字节。通过process.memoryUsage()我们可以统计到整个进程的内存占用情况,其中RSS是指Resident Set Size,这里面有几个关于内存的占用的定义:
VSS- Virtual Set Size 虚拟耗用内存(包含共享库占用的内存)
RSS- Resident Set Size 实际使用物理内存(包含共享库占用的内存)
PSS- Proportional Set Size 实际使用的物理内存(比例分配共享库占用的内存)
USS- Unique Set Size 进程独自占用的物理内存(不包含共享库占用的内存)

externel指的是由V8管理的C++对象占用的内存,本例中基本上就是那个2.04GB文件(文件大小2,044,752,496)所占的Buffer了(当然还有其他C++对象了)
heapTotal、heapUsed则是我们前面看到的V8 Heap的使用情况了。

在JS中,普通的Object、string、闭包对象都是在V8 Heap上分配的,变量则是在栈上分配的,JS代码本身是在代码段上分配的。

PS: 后面单独成文分析V8 内存结构以及关于GC Space分布的情况