V8内存限制
Node与其他语言不同的一个地方,就是其限制了JavaScript所能使用的内存(64位为1.4GB,32位为0.7GB),这也就意味着将无法直接操作一些大内存对象。这很令人匪夷所思,因为很少有其他语言会限制内存的使用。
原因?
V8之所以限制了内存的大小,表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景。
而深层次的原因则是由于V8的垃圾回收机制的限制。由于V8需要保证JavaScript应用逻辑与垃圾回收器所看到的不一样,V8在执行垃圾回收时会阻塞JavaScript应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。
若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。这样浏览器将在1s内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。因此当时的考虑下,限制内存是最好的选择。
突破限制?
当然这个限制是可以打开的,类似于JVM,我们通过在启动node时可以传递--max-old-space-size或--max-new-space-size来调整内存限制的大小,前者确定老生代的大小,单位为MB,后者确定新生代的大小,单位为KB。这些配置只在V8初始化时生效,一旦生效不能再改变。
V8对象分配
在V8中所有js对象通过堆进行分配。node中提供了V8中内存的使用量查看方式,执行下面代码:
查看node内存使用情况
使用process.memoryUsage()
,除此之外os模块中的totalmen()和freemen()方法也能查看内存使用情况,不过这个是查看操作系统的内存使用情况。
node
> process.memoryUsage()
{ rss: 24633344, heapTotal: 10522624, heapUsed: 5105552 }
//单位字节23MB/ 4MB/ 10MB
//rss:resident set size的缩写,表示进程的常驻内存部分。进程的内存总共有几部分,一部分是rss,其余部分在交换区(swap)或者文件系统(filesystem)中。
除了rss外,heapTotal和headUsed对应的是V8的堆内存信息,前者是堆中总共申请的内存量,后者表示目前堆中使用中的内存量。单位都是字节。
附加查看操作系统内存使用情况
node
>os.totalmen()
858994592
>os.freemen()
4527833088
//单位字节 内存8G,剩余4.2G左右
V8内存分配基础
在V8中所有的JavaScript对象都是通过堆来分配的。为了提高垃圾回收的效率,V8将堆分为新生代和老生代两个部分,其中新生代为存活时间较短的对象(需要经常进行垃圾回收),而老生代为存活时间较长的对象(垃圾回收的频率较低)。
新生代和老生代的默认内存限制在启动的时候就确定了,没办法根据应用使用内存情况自动扩充,当应用分配过多内存时,就会引起OOM(Out Of Memory,内存溢出)进程错误。64位系统和32位系统的内存限制不同,分别如下:
在node启动时,通过--max-new-space-size和--max-old-space-size可分别设置新生代和老生代的默认内存限制
V8垃圾回收原理
1.常用垃圾回收基本算法
2.V8的分代垃圾回收
V8垃圾回收策略主要基于分代式垃圾回收机制。
上面提到过,V8将内存分为新生代和老生代,新生代中对象存活时间较短,老生代中对象存活时间较长。为了最大程度的提升垃圾回收效率,V8使用了一种综合性的方法,其在新生代和老生代中分别使用上文提到的不同的基本垃圾回收算法。
2.1 新生代垃圾回收算法Scavenge
在新生代中,由于内存较小(64位系统为64MB)且存活对象较少,V8采取了一种以空间换时间的方案,即停止-复制算法 (Stop-Copy)。它将新生代分为两个半区域(semi-space),分别称为from空间和to空间。一次垃圾回收分为两步:
(1) 将from空间中的活对象复制到to空间
(2) 切换from和to空间
V8将新生代中的一次垃圾回收过程,称为Scavenge。
2.2老生代垃圾回收算法
老生代的内存空间较大且存活对象较多,因此其垃圾回收算法也就没有新生代那么简单了。为此V8使用了标记-清除算法 (Mark-Sweep)进行垃圾回收,并使用标记-压缩算法 (Mark-Compact)整理内存碎片,提高内存的利用率。老生代的垃圾回收算法步骤如下:
(1).对老生代进行第一遍扫描,标记存活的对象
(2).对老生代进行第二次扫描,清除未被标记的对象
(3).将存活对象往内存的一端移动
(4).清除掉存活对象边界外的内存
从上面的表格可以看出,停止-复制(Stop-Copy)、标记-清除(Mark-Sweep)和标记-压缩(Mark-Compact)都需要停止应用逻辑,我们将之称为stop-the-world。但因为新生代内存较小且存活对象较少,即便stop-the-world,对应用的性能影响也不大;而老生代的内存很大,stop-the-world就不能接受了,为此V8引入了增量标记。增量标记使得应用逻辑和垃圾回收交替运行,减少了垃圾回收对应用逻辑的干扰。
2.3 分代垃圾回收的代价
在讨论新生代中的垃圾回收算法Scavenge时,我们忽略了许多细节。
真的仅仅扫描新生代的内存空间,就能确定新生代的活动对象吗?
当然不是,老生代的对象也可能引用新生代的对象啊。如果每次运行Scavenge算法时,都要扫描老生代空间的话,这种操作带来的性能损耗就完全抵消了分代式垃圾回收所带来的性能提升。为此V8使用写屏障技术解决了这个问题:
V8使用一个列表(我们称之为CrossRefList)记录所有老生代对象指向新生代的情况,当有老生代中的对象出现指向新生代对象的指针时,便记录下来这样的跨区指向。由于这种记录行为总是发生在写操作时,因此被称为写屏障。
每个写操作都要经历这样一关,性能上必然有损失,这是分代垃圾回收的代价之一。通过使用写屏障技术,我们在对新生代进行垃圾回收时,只需要扫描新生代From空间和CrossRefList列表就可以确定活动对象了。
垃圾回收监控
理解了垃圾回收的基本原理以后,我们来看一看如何监控node的垃圾回收情况。查看垃圾回收方式的最方便的方法是通过在启动时使用--trace-gc参数:
node --trace-gc app.js
//可以自己试试
而一种更加程序化的方式是使用memwatch-next模块,该模块在node每一次进行全量垃圾(full-gc,包括标记-清除和标记-压缩)回收时触发相应的事件:
var memwatch = require('memwatch-next');
memwatch.on('stats', function(stats) {
console.log(stats);
});
上述代码监控每一次全量垃圾回收动作,并打印出相应垃圾回收统计信息:
{
"num_full_gc": 8, //目前为止进行全量GC的次数
"num_inc_gc": 18, //目前为止进行增量GC的次数
"heap_compactions": 8, //目前为止进行的内存压缩的次数
"usage_trend": 0, //内存增长趋势,如果一直大于0,则可能有内存泄露
"estimated_base": 2592568,
"current_base": 2592568,
"min": 2499912,
"max": 2592568
}
内存泄露原因
Node对内存泄露十分敏感,哪怕一个字节的内存泄露也会造成堆积,垃圾回收过程中将会消耗更多的时间进行对象的扫描,应用响应速度变慢,直到进程内存溢出,应用崩溃。
尽管内存泄露的情况不尽相同,但其实实质只有一个,那就是应当回收的对象出现意外没有被回收,变成了常驻在老生代中的对象。
通常造成内存泄露的原因:
- 缓存
- 队列消费不及时
- 作用域未释放
内存泄露定位
使用上文提到的垃圾回收监控方法,我们可以知道程序是否有内存泄露,那么具体在什么地方有内存泄露呢?我们需要借助于新的工具。node-heapdump提供了v8的堆内存快照抓取工具。
1. 抓取对内存镜像
我们可以在程序中直接通过它提供的函数抓取内存快照:
var heapdump = require('heapdump');
heapdump.writeSnapshot('/tmp/' + Date.now() + '.heapsnapshot');
在linux下,我们还可以通过向node进程发送信号来抓取内存快照:
kill -USR2 pid
有了内存快照后,我们就可以借助chrome的Profile工具,具体的分析内存泄露发生在什么地方了。
2. 三次快照法
利用chrome的Profile工具分析内存泄露的经典方法是三次快照法,我们需要首选准备3个内存快照文件:
(1) 第一次获取正常情况下内存快照
(2) 第二次获取发生内存泄露时的内存快照
(3) 第三次获取继续发生内存泄露时的内存快照
三次快照要求第一次必须在没有出现内存泄露时,是为了过滤一些无用的信息,使得分析结果可读性更强。