浅谈nodejs内存控制

161 阅读6分钟

1、 V8的垃圾回收机制和内存限制

image.png

image.png

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线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。这样的情况不仅仅后端服务无法接受,前端浏览器也无法接受。

image.png 分别为设置老生代内存空间和新生代内存空间

  • 对于v8的垃圾回收策略: 分代式垃圾回收

image.png

默认情况下:

在64位操作系统中,新生代内存空间为32M, 老生代内存空间是1400M;

在32位操作系统中,新生代内存空间16M, 老生代内存空间700M;

1.1 新生代垃圾回收策略: Scavenge算法

它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制

  • 特点:

1、只能使用堆内存中的一半,典型的空间换时间

2、Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短

image.png

image.png

对象晋升条件(从新生代晋升到老生代):

1、一个是对象是否经历过Scavenge回收,经历过回收就会从from空间复制到老生代空间

2、一个是To空间的内存占用比超过限制,超过To空间本身的25%,则自动复制对象到老生代空间

1.2 老生代垃圾回收策略

Mark-Sweep & Mark-Compact

Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象

Mark-Compact是标记整理的意思,对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存

image.png

现在引入增量标记,延迟清理和增量式整理,垃圾回收与应用逻辑交替执行,直到最后阶段完成

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 会在当前目录下产生两个文件

可以在控制台 memoryload 这两个文件,对比即可看到占用内存变化大的内容

image.png

4、大内存应用

Node提供了stream模块用于处理大文件,流式处理大的文本文件。

Buffer所占用的内存不是通过V8分配的,属于堆外内存,因此不受v8内存限制