JavaScript和Java一样是由垃圾回收机制来进行自动内存管理的,对于浏览器,几乎不需要考虑内存回收的问题,但服务器对性能更为敏感,内存管理的好坏、垃圾回收是否优良,都会对服务造成影响。再node中,这一切都与JavaScript引擎——V8相关。
一般的后端语言基本在内存上是没什么限制的,然而node中通过JavaScript使用内存时可以发现只能使用部分内存。待补充
node是基于V8的,所以在node中所使用的JavaScript对象基本上都是通过V8自己的方式来分配和管理的。在V8中,所有的JavaScript对象都是通过堆来进行分配的,调用process.memoryUsage()可以看到内存使用信息:
>process.memoryUsage()
{
rss: 26193920,
heapTotal: 7684096,
heapUsed: 4959176,
external: 8656
}
>
当在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制。
至于为什么V8要限制堆的大小,表层原因是V8最初为浏览器设计的,不太可能遇到使用大量内存的场景,深层原因是V8垃圾回收机制的限制。
垃圾回收也是要耗费时间的,回收期间JavaScript线程将会暂停,所以综合各方面因素,直接限制最大使用内存是一个好的选择。
V8的垃圾回收策略主要是基于分代式垃圾回收机制,现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同分代应用不同的算法。
V8的内存分代
在V8中主要将内存分为新生代和老生代。新生代中为存活时间较短的对象,老生代中为存活时间较长或常驻内存的对象。V8堆的整体大小就是这2部分之和。
对于新生代内存,由两个reserved_semispace_size_组成,每个在64位操作系统上是16MB,32位操作系统上是8MB,所以新生代在64位和32位操作系统上分别就是32MB和16MB。
Scavenge算法
新生代对象主要通过Scavenge算法进行垃圾回收,该算法的具体实现采用了Cheney算法。
Cheney算法将堆内存一分为二,每一部分空间称为semispace,这两个semispace中只有一个处于使用中,另一个处于闲置中。处于使用中的称为From空间,处于闲置中的称为To空间。分配对象时,先在From空间中分配,当开始垃圾回收时会检查From空间中的存活对象,将这些存活对象复制到To空间中,非存活对象占用的空间将被释放。完成复制后,From空间和To空间互换角色。
Scavenge算法的缺点是只能使用堆内存的一半,这是由划分空间和复制机制决定的,由于只复制存活的对象,对于生命周期较短的场景存活对象只占少部分,所以它在时间效率上有优势。Scavenge是典型的牺牲空间换时间算法,无法大规模应用到所有垃圾回收中,但非常适合应用在新生代中,因为新生代中的对象生命周期较短。
当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象,这种生命周期较长的对象随后会被移动到老生代中,采用新的算法管理,这个移动过程称为晋升。
在分代式垃圾回收机制中,从From复制到To之前会做检查,如果满足条件,则将这个存活周期较长的对象移动到老生代中。这个晋升条件有两个:
如果一个对象已经经历过Scavenge回收,则将这个对象晋升到老生代中,否则复制到To空间中。如果To空间的使用超过了25%,则直接晋升到老生代中。设置这个25%是因为完成复制后,From和To会发生角色对调,如果占用比过高会影响后续的内存分配。
Mark-Sweep & Mark-Compact
老生代中由于存活对象占比较大,如果继续用Scavenge算法会面临2个问题:一是存活对象较多导致复制效率很低,二是依然浪费一半的空间。所以老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式来进行垃圾回收。
Mark-Sweep是标记清除的意思,分为标记和清除两个阶段。标记阶段会遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有标记的对象。
Scavenge只复制活着的对象,Mark-Sweep只清除死亡的对象,新生代活着的对象较少,老生代死亡的对象较少,所以两种方式都能高效的处理问题。
但是Mark-Sweep有个问题,就是在标记清除回收后,内存空间会出现不连续的状态,这种内存碎片会对后续的内存分配造成问题,因为很可能出现分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
为了解决Mark-Sweep碎片化的问题,Mark-Compact被提出来了,Mark-Compact是标记整理的意思,是在Mark-Sweep上演变过来的。二者的差别在于对象在标记死亡后,在整理的过程中将活着的对象往一段移动,移动完成后,直接清理掉边界外的内存。
因为Mark-Compact需要做额外的移动对象工作,所以在执行速度上不会很快,所以在最终的取舍上V8主要使用Mark-Sweep,在空间不足以应对新生代晋升到老生代的对象进行分配时才使用Mark-Compact。
Incremental Marking
为了避免出现JavaScript应用逻辑与垃圾回收器看到的不一致的情况,这3种垃圾回收机制都会将应用的逻辑暂停下来,待完成垃圾回收之后再执行应用逻辑,这种行为被陈为全停顿。
在V8的分代式垃圾回收机制中,一次小垃圾回收只影响新生代,由于新生代的默认配置较小,里面的存活对象也通常较少,所以全停顿带来的影响也不大。但是老生代的配置通常较大,且存活对象较多,所以全停顿带来的影响就会很大,需要设法改善。
所以,V8从标记阶段入手,将原本一次性完成的动作改为增量标记,也就是拆分成多个小步,每完成一步就让JavaScript应用逻辑执行一会儿,垃圾回收与应用逻辑交替执行,直到完成标记阶段。V8后续也引入了延迟清理、增量式整理、并行标记和并行清理,进一步利用多核性能降低每次停顿的时间,了解即可不做深究。
在JavaScript中,函数、代码块、with语句生成的作用域,还有全局作用域。函数调用时都会创建对应的作用域,执行结束后该作用域将会销毁。作用域中声的变量会绑定到该作用域,随作用域的销毁而销毁。
在JavaScript中,如果在当前作用域没有找到该变量,则会继续向上层查找,直至最顶层,如果还没有找到则抛出异常。这就是作用域链。
对于全局变量(不通过var声明或定义在global),由于全局作用域直到进程退出才会释放,此时将导致引用的对象常驻在老生代内存中。想要释放掉常驻内存的对象,可以调用delete命令或者将变量重新赋值,让旧的对象脱离引用关系,接下来的老生代内存清除和整理过程中会被回收释放。
同样,在非全局作用域中也可以这么操作。通常delete命令可能干扰V8的优化,所以重新赋值解除引用相对较好。
由于JavaScript的链式作用域,外部作用域是无法访问内部作用域的,而实现外部作用域可以访问内部作用域中变量的方法叫做闭包。闭包是得益于高阶函数的。
function foo(){
const bar = function(){
const local = "局部变量";
return function(){
return local;
}
};
const baz = bar();
console.log(baz());
}
foo();
一般,bar()函数执行完成后局部变量local将会随着作用域的销毁而被回收,但是这里的返回值是一个函数,外部作用域若想访问local变量则必须通过这个中间函数,这里baz引用了这个中间函数,所以,除非对baz重新赋值不再引用,否则中间函数将不会被释放,里面的原始作用域也不会被释放。
在JavaScript中,无法立即回收的内存有闭包和全局变量引用这两种情况,由于V8的内存限制,所以要小心此类变量的无限的增加,这会导致老生代中的对象增多。
const showMem = function(){
const mem = process.memoryUsage();
const format = function(bytes){
return (bytes/1024/1024).toFixed(2) + "MB";
};
console.log(`heapTotal ${format(mem.heapTotal)},heapUsed ${format(mem.heapUsed)},rss ${format(mem.rss)}`);
console.log("----------------------------------------------------------");
};
const useMen = function(){
const size = 20*1024*1024;
const arr = new Array(size);
for (let i=0;i<size;i++){
arr[i] = 0;
}
return arr;
};
const total = [];
for(let i=0;i<15;i++){
showMem();
total.push(useMen());
}
showMem();
当达到某一个阶段就内存溢出了,循环体也无法执行完成。
在启动node进程时可以通过–max-old-space-size和–max-new-space-size来设置老生代和新生代的内存上限,老生代的单位是MB,新生代的单位是KB。
>node --max-old-space-size=1700 app
注意这个值只能在进程启动配置,无法在执行中动态增加。
调用os模块的totalmem()方法和freemem()方法返回操作系统的总内存和可用内存,以字节为单位。
const os = require("os");
console.log((os.totalmem()/1024/1024/1024).toFixed(2));
console.log((os.freemem()/1024/1024/1024).toFixed(2));
通过process.memoryUsage()可以看到,heapTotal总是小于rss的,这意味着node中的内存使用并非都是通过V8进行分配的。这些不通过V8分配的内存称为堆外内存。
改造useMem()方法:
const useMen = function(){
const size = 20*1024*1024;
const buffer = new Buffer(size);
for (let i=0;i<size;i++){
buffer[i] = 0;
}
return buffer;
};
执行之后会发现heapTotal和heapUsed变化很小,反而rss变化较大。原因就是Buffer不同于其它对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制。
内存泄漏的实质只有一个:应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。
通常,造成内存泄漏的原因有以下3个:
在node中,一旦一个对象被当作缓存,那就意味着它将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。
利用对象做数据缓存,但严格意义将和缓存有着区别,严格意义的缓存有完善的过期策略,而普通对象键值并没有。
const cache = {};
const get=function(key){
if(cache[key]){
return cache[key];
}else{
//get otherwise
}
};
const set=function(key,value){
cache[key] = value;
};
在node中,任何试图拿内存当作缓存的行为都应该被限制,不是不可以,而是应该小心为之。
对于大量缓存,普遍的做法采用Redis等非关系数据库。这些有着良好的缓存策略和自身的内存管理,不影响node的进程。
https://github.com/NodeRedis/node_redis
队列在消费者-生产者模型中经常充当中间产物,绝大部分情况下消费的速度都是大于生成的速度的,但如果消费的速度小于生产的速度,就会产生堆积,导致内存泄漏。
一个实际的例子就是——收集日志。如果用数据库来记录日志就会存在问题,日志通常时海量的,而数据库是构建在文件系统之上的,写入的效率是远远低于文件直接写入的,于是会形成数据库写入操作的堆积,而JavaScript中相关的应用作用域也不会得到释放,内存占用不会回落,从而出现内存泄漏。
这种情况表层的解决方案是换用消费更高的技术。深度的解决方案应该是控制队列的长度,并通过监控系统产生报警并通知相关开发者。另一个方案是任意异步调用都应该有超时机制,控制每个异步操作都在可控的时间范围之内。
这俩个工具先列出来,以后再详细研究。
node中,不可避免的会出现读写大文件的场景,但由于内存的限制,对于大文件应该避免使用fs.readFile()和fs.writeFile(),而应该使用stream模块。
stream模块继承自EventEmitter,具备基本的自定义事件功能,同时抽象出标准的事件和方法。fs模块的createReadStream()和createWriteStream()方法可用于创建文件的可读流和可写流。
stream模块和管道机制将会在后续详细结束。
本章End~
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。