5. node-内存控制-垃圾回收

内存控制学习脑图

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将堆分为新生代和老生代两个部分,其中新生代为存活时间较短的对象(需要经常进行垃圾回收),而老生代为存活时间较长的对象(垃圾回收的频率较低)。


image.png

新生代和老生代的默认内存限制在启动的时候就确定了,没办法根据应用使用内存情况自动扩充,当应用分配过多内存时,就会引起OOM(Out Of Memory,内存溢出)进程错误。64位系统和32位系统的内存限制不同,分别如下:


image.png

在node启动时,通过--max-new-space-size和--max-old-space-size可分别设置新生代和老生代的默认内存限制

V8垃圾回收原理

1.常用垃圾回收基本算法

image.png

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).清除掉存活对象边界外的内存

image.png

从上面的表格可以看出,停止-复制(Stop-Copy)、标记-清除(Mark-Sweep)和标记-压缩(Mark-Compact)都需要停止应用逻辑,我们将之称为stop-the-world。但因为新生代内存较小且存活对象较少,即便stop-the-world,对应用的性能影响也不大;而老生代的内存很大,stop-the-world就不能接受了,为此V8引入了增量标记。增量标记使得应用逻辑和垃圾回收交替运行,减少了垃圾回收对应用逻辑的干扰。

2.3 分代垃圾回收的代价
在讨论新生代中的垃圾回收算法Scavenge时,我们忽略了许多细节。

真的仅仅扫描新生代的内存空间,就能确定新生代的活动对象吗?

当然不是,老生代的对象也可能引用新生代的对象啊。如果每次运行Scavenge算法时,都要扫描老生代空间的话,这种操作带来的性能损耗就完全抵消了分代式垃圾回收所带来的性能提升。为此V8使用写屏障技术解决了这个问题:

V8使用一个列表(我们称之为CrossRefList)记录所有老生代对象指向新生代的情况,当有老生代中的对象出现指向新生代对象的指针时,便记录下来这样的跨区指向。由于这种记录行为总是发生在写操作时,因此被称为写屏障。


image.png

每个写操作都要经历这样一关,性能上必然有损失,这是分代垃圾回收的代价之一。通过使用写屏障技术,我们在对新生代进行垃圾回收时,只需要扫描新生代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) 第三次获取继续发生内存泄露时的内存快照

三次快照要求第一次必须在没有出现内存泄露时,是为了过滤一些无用的信息,使得分析结果可读性更强。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,843评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,538评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,187评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,264评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,289评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,231评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,116评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,945评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,367评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,581评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,754评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,458评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,068评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,692评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,842评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,797评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,654评论 2 354

推荐阅读更多精彩内容