1、 V8的垃圾回收机制和内存限制
Node.js是一个基于V8 JavaScript引擎的JavaScript运行时环境。
v8在64位系统下可使用内存约为1.4 GB,32位系统下约为0.7 GB
至于V8为何要限制堆的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。对于网页来说,V8的限制值已经绰绰有余。深层原因是V8的垃圾回收机制的限制。按官方的说法,以1.5 GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。这样的情况不仅仅后端服务无法接受,前端浏览器也无法接受。
分别为设置老生代内存空间和新生代内存空间
- 对于v8的垃圾回收策略: 分代式垃圾回收
默认情况下:
在64位操作系统中,新生代内存空间为32M, 老生代内存空间是1400M;
在32位操作系统中,新生代内存空间16M, 老生代内存空间700M;
1.1 新生代垃圾回收策略: Scavenge算法
它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制
- 特点:
1、只能使用堆内存中的一半,典型的空间换时间
2、Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短
对象晋升条件(从新生代晋升到老生代):
1、一个是对象是否经历过Scavenge回收,经历过回收就会从from空间复制到老生代空间
2、一个是To空间的内存占用比超过限制,超过To空间本身的25%,则自动复制对象到老生代空间
1.2 老生代垃圾回收策略
Mark-Sweep & Mark-Compact
Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象
Mark-Compact是标记整理的意思,对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存
现在引入增量标记,延迟清理和增量式整理,垃圾回收与应用逻辑交替执行,直到最后阶段完成
2、 内存泄露
通常造成内存泄露的原因主要有: 缓存;队列消费不及时;作用域未释放
2.1 慎将内存当做缓存
缓存可以节省资源,命中缓存可以节约一次I/O 时间,
但是,一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多
var cache = {};
var get = function (key) {
if (cache[key]) {
return cache[key];
} else {
// get from otherwise
}
};
var set = function (key, value) {
cache[key] = value;
};
上面例子会导致cache对象常驻在老生代中,无法被回收,而且会越来越大,最终在某个时间超过老生代内存大小限制导致内存泄露
基于上述原因,需要将cache对象大小做限制。
class LRUCache {
maxSize: number;
cacheMap: Map<string, any>;
constructor(size: number) {
this.maxSize = size;
this.cacheMap = new Map();
}
set(key: string, value: any) {
if (this.cacheMap.get(key)) {
this.cacheMap.delete(key);
}
if (this.cacheMap.size >= this.maxSize) {
this.cacheMap.delete(this.cacheMap.keys().next().value);
}
this.cacheMap.set(key, value);
}
get(key: string) {
if (this.cacheMap.has(key)) {
const val = this.cacheMap.get(key);
this.cacheMap.delete(key);
this.cacheMap.set(key, val);
return val;
}
return false;
}
}
const LRUCacheMap = new LRUCache(1000);
export default LRUCacheMap;
由于进程之间无法共用缓存,因此最好的方式是将大量缓存放在外部,比如使用缓存有Redis和Memcached
2.2 关注队列状态
举个例子:在日志收集过程中,如果欠缺考虑,也许会采用数据库来记录日志。日志通常会是海量的,数据库构建在文件系统之上,写入效率远远低于文件直接写入,于是会形成数据库写入操作的堆积,而JavaScript中相关的作用域也不会得到释放,内存占用不会回落,从而出现内存泄漏。
遇到这种场景,表层的解决方案是换用消费速度更高的技术。在日志收集的案例中,换用文件写入日志的方式会更高效;
深度的解决方案应该是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应,通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。
2.3 作用域释放
闭包或者未释放全局变量,比如定时器等。
global.foo = {key: "I am global object"};
console.log(JSON.stringify(global.foo)); // => "I am global object"
delete global.foo;
// 删除或者重新赋值都可以释放引用对象
global.foo = undefined; // or null
console.log(global.foo); // => undefined
3、 内存泄露排查
在js文件中使用heapdump试下打印内存快照
const {EventEmitter} = require('events');
const heapdump = require('heapdump');
global.test = new EventEmitter();
heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');
let arr = []
function run3() {
arr.push({ 'key': 'jsfaksfsdfsfsdfsdf' });
}
for (let i = 0; i < 10000; i++) {
run3();
}
// eslint-disable-next-line no-undef
gc();
heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');
使用 node --expose-gc test.js 会在当前目录下产生两个文件
可以在控制台 memory 中 load 这两个文件,对比即可看到占用内存变化大的内容
4、大内存应用
Node提供了stream模块用于处理大文件,流式处理大的文本文件。
Buffer所占用的内存不是通过V8分配的,属于堆外内存,因此不受v8内存限制