v8为什么会有内存限制?
至于V8为何要限制堆的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内
存的场景。对于网页来说,V8的限制值已经绰绰有余。深层原因是V8的垃圾回收机制的限制。
按官方的说法,以1.5 GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一
次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在
这样的时间花销下,应用的性能和响应能力都会直线下降。这样的情况不仅仅后端服务无法接受,
前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存是一个好的选择。
当然,这个限制也不是不能打开,V8依然提供了选项让我们使用更多的内存。Node在启动
时可以传递--max-old-space-size或--max-new-space-size来调整内存限制的大小,示例如下:
node --max-old-space-size=1700 test.js // 单位为MB// 或者
node --max-new-space-size=1024 test.js // 单位为KB
内存分代:设置了最大之后,就不能再更改了
v8的垃圾回收机制(GC)(Garbage Collection):
主要分为新生代(对象存活时间短)和老生代(对象存活时间长)
新生代:
scavenge算法:
是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制。
Scavenge的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。
由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。
实际使用的堆内存是新生代中的两个semispace空间大小和老生代所用内存大小之和。
| 标题 | reversed_semispace_size | max |
|---|---|---|
| 32位系统 | 8M | 4*8+700 |
| 64位系统 | 16M | 4*16+1400 |
当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升。在单纯的Scavenge过程中,From空间中的存活对象会被复制到To空间中去,然后对From空间和To空间进行角色对换(又称翻转)。但在分代式垃圾回收的前提下,From空间中的存活对象在复制到To空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中,也就是完成对象晋升。
对象晋升的条件主要有两个:
- 一个是对象是否经历过Scavenge回收
- 一个是To空间的内存占用比超过限制。
在默认情况下,V8的对象分配主要集中在From空间中。对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会
将该对象从From空间复制到老生代空间中,如果没有,则复制到To空间中。另一个判断条件是To空间的内存占用比。当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代空间中,这个晋升的判断示意图如下图:
当时我们看到了新生代即使有两份,但是仍然是老生代内存空间占据了大部分的比重的,那么老生代能否用Scavenge算法呢?
对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:
一个是存活对象较多,复制存活对象的效率将会很低;
另一个问题依然是浪费一半空间的问题。这两个问题导致应对生命周期较长的对象时Scavenge会显得捉襟见肘。为此,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
老生代:
Mark-Sweep和Mark-Compact算法
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。与Scavenge相比,Mark-Sweep并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与Scavenge复制活着的对象不同,Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出**,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因
Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提出来。Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。Mark-Compact完成标记并移动存活对象后的示意图,白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。
查看垃圾回收日志的方式主要是在启动时添加--trace_gc参数。
node内存占用情况
process.memoryUsage()可以查看内存使用情况
os模块中的totalmem()和freemem()这两个方法用于查看操作系统的内存使用情况,它们分别返回系统的总内存和闲置内存,以字节为单位
rss: Resident Set Size 进程常驻内存部分,即进程实际使用物理内存
heapTotal: 总的堆内存
heapUsed: 已使用的堆内存
external: 堆外内存, 例如Buffer所占内存
下面两个例子分别是不用buffer和用buffer的场景
除了堆上的内存以外,V8 还允许用户自行管理对象的内存,比如 Node.js 中的 Buffer 就是自己管理内存的。这些叫做外部内存(external memory),在垃圾回收的时候会被 V8 跳过,但是外部的代码可以通过向 V8 注册 GC 回调,跟随 JS 代码中暴露的引用的回收而自行回收内存,相关信息也会显示在垃圾回收日志中。外部内存也会影响V8 的 GC,比如当外部内存占用过大时,V8 可能会选择 Full GC(包含老生代)而不是仅仅回收新生代,尝试触发用户的 GC 回调以空出更多的内存来使用。
由于外部代码需要将自己使用的内存通过 Isolate::AdjustAmountOfExternalAllocatedMemory 告知 V8 才能记录下来,假如外部代码没有做好上报,就可能出现进程 RSS(Resident Set Size,实际占用的内存大小)很高,但减去垃圾回收日志中 Memory Allocator 分配的堆内存和 V8 记录下的外部内存之后,有很大一部分“神秘消失”的现象,这个时候就可以定位到 C++ addon 或者是 Node.js 自己管理的内存里去排查问题了
如图所示
算法比较:
**v8主要使用Mark-Sweep,在空间不足以对新晋升对象分配时才用Mark-Compact **
增量标记(incremental =marking)
降低老生代的全堆垃圾回收带来的时间停顿
从标记阶段入手,拆分为许多小步进,与应用逻辑交替运行
垃圾回收最大停顿时间降为原来的1/6
垃圾回收是影响性能的因素之一,要尽量减少垃圾回收,尤其全堆垃圾回收
优化:lazy sweeping, concurrent sweeping, parallel sweeping
在 sweeping 方面,V8 引入了 lazy sweeping,当我们已经标记完哪些对象的内存可以被回收之后,并没有必要马上回收完这些内存,然后再开始运行。我们可以先恢复程序的运行,再一点点对各页的空间做 sweeping。当然,只有当所有页的内存都被回收完之后,我们才能重新开始 marking。
另外,由于这些死亡对象占据的空间不会在被运行中的程序使用,V8 还引入了 concurrent sweeping,让其他线程同时来做 sweeping,而不用担心和执行程序的主线程冲突,这样在 sweeping 的时候,就不需要暂停程序的执行了。
同样地,因为 sweeping 作用的对象们已经确定而且不会被主线程访问,可以比较容易地并行化,V8 引入了 parallel sweeping,让多个 sweeping 线程同时工作,提升 sweeping 的吞吐量,缩短整个 GC 的周期
为什么内容开多了会卡顿呢?(个人理解)
因为内存少了之后会进行大量的垃圾回收,卡顿时间就会比较长,360等一键回收做了些什么呢,大概就是把不常用的进程线程杀死吧,然后内存清空了,就不用进行那么多次垃圾回收,也就没有那么多卡顿了。
内存泄漏
通常,造成内存泄漏的原因有如下几个。
缓存。
缓存限制策略
慎将内存当做缓存
记录键在数组中,一旦超过数量,就以先进先出的方式进行淘汰。当然,这种淘汰策略并不是十分高效,只能应付小型应用场景。如果需要更高效的缓存,可以参见Isaac Z. Schlueter采用LRU算法的缓存,
另一个案例在于模块机制。在第2章的模块介绍中,为了加速模块的引入,所有模块都会通过编译执行,然后被缓存起来。由于通过exports导出的函数,可以访问文件模块中的私有变量,
这样每个文件模块在编译执行后形成的作用域因为模块缓存的原因,不会被释放。示例代码如下所示
(function (exports, require, module, __filename, __dirname) {
var local = "局部变量";
exports.get = function () {
return local;
};
});
由于模块的缓存机制,模块是常驻老生代的。在设计模块时,要十分小心内存泄漏的出现。在下面的代码,每次调用leak()方法时,都导致局部变量leakArray不停增加内存的占用,且不被释放:
var leakArray = [];
exports.leak = function () {
leakArray.push("leak" + Math.random());
};
如果模块不可避免地需要这么设计,那么请添加清空队列的相应接口,以供调用者释放内存。
缓存的解决方案
直接将内存作为缓存的方案要十分慎重。除了限制缓存的大小外,另外要考虑的事情是,进程之间无法共享内存。如果在进程内使用缓存,这些缓存不可避免地有重复,对物理内存的使用是一种浪费。
(1) 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效。
(2) 进程之间可以共享缓存。
目前,市面上较好的缓存有Redis和Memcached。\
队列消费不及时。
深度的解决方案应该是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应,通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。
对于Bagpipe而言,它提供了超时模式和拒绝模式。启用超时模式时,调用加入到队列中就开始计时,超时就直接响应一个超时错误。启用拒绝模式时,当队列拥塞时,新到来的调用会直接响应拥塞错误。这两种模式都能够有效地防止队列拥塞导致的内存泄漏问题。
作用域未释放。
(慎用闭包,全局变量)
内存泄漏排查
node-heapdump
对V8堆内存抓取快照,用于事后分析
使用方法:
安装 npm install heapdump;
在开头引入 var heapdump = require(‘heapdump’);
发送命令kill -USR2 ;
heapdump 会抓拍一份堆内存快照,文件为 heapdump-..heapsnapshot 格式,json文件;
使用 chrome devtool 来查看内存快照;
const http = require('http');
var heapdump = require('heapdump');
var leakArray = [];
var leak = function () {
leakArray.push("leak" + Math.random()); };
http.createServer(function (req, res) { leak();
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n'); }).listen(1337);
console.log('Server running at http://127.0.0.1:1337/');
heapdump.writeSnapshot('/var/local/' + Date.now() + '.heapsnapshot');
heapdump.writeSnapshot(function(err, filename) {
console.log('dump written to', filename);
});
heap snapshot: 堆快照
allocation sampling:分配样本
初始值
刷新多次后
打开快照之后(没用profiles)
node-memwatch
每次全堆垃圾回收,会触发stats事件,该事件会传递内存的统计信息。
如果经过连续的5次垃圾回收后,内存仍没有被释放,意味有内存泄漏,node-memwatch会触发leak事件
大内存应用
由于V8的内存限制,我们无法通过fs.readFile()和fs.writeFile()直接进行大文件的操作,而改用fs.createReadStream()和fs.createWriteStream()方法通过流的方式实现对大文件的操作。