《深入浅出Node.js》学习笔记(五)

640 阅读4分钟

内存控制

基于无阻塞、事件驱动建立的Node服务,具有内存消耗低的优点,非常适合处理海量的网络请求。

1. V8的垃圾回收机制与内存限制

对于性能敏感的服务器程序,内存管理的好坏、垃圾回收状况是否优良,都会对服务构成影响。而在Node中,这一切都与Node的JavaScript执行引擎V8息息相关。

1.1 Node与V8

Node是一个构建在Chrome的JavaScript运行时上的平台。

V8的性能优势使得用JavaScript写高性能后台服务程序成为可能。

Node在JavaScript的执行上直接受益于V8,可以随着V8的升级就能享受到更好的性能或新的语言特性,同时也受到V8的一些限制,例如内存限制。

1.2 V8的内存限制

在Node中通过JavaScript对使用内存就会发现只能使用部分内存(64位约1.4GB,32位约0.7G)。

V8 的内存限制会导致Node无法直接操作大内存对象。

造成这个问题的主要原因在于Node基于V8构建,所以在Node中使用的JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。

1.3 V8的对象分配

在V8中,所有的JavaScript对象都是通过堆来进行分配的。

Node提供了V8中内存使用量的查看方式。

$ node
> process.memoryUsage()
{ rss: 22044672,
  heapTotal: 9682944,
  heapUsed: 5296232, //已申请的堆内存
  external: 8860 }   //已使用的堆内存

在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,知道堆的大小超过V8的限制为止。

V8为何要限制堆的大小?

  • 表层原因为V8最初为浏览器设计,不太可能遇到用大量内存的场景;
  • 深层原因是V8的垃圾回收机制的限制;

打开限制使用更多的内存:

node --max-old-space-size=1700 test.js //单位为MB
//或者
node --max-new-space-size=1024 test.js //单位为KB

1.4 V8的垃圾回收机制

V8用到的各种垃圾回收算法:

  1. V8主要的垃圾回收算法

    V8的垃圾回收策略主要基于分代式垃圾回收机制。

    现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,再分别对不同分代的内存进行更高效的算法。

    • V8的内存分代

      在V8中,主要将内存分为新生代和老生代两代。

      新生代中的对象存活时间较短;

      老生代中的对象存活时间较长或常驻内存的对象;

      V8堆的整体大小就是新生代所用内存加上老生代的内存空间。

    • Scavenge算法

      在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收。Scavenge的具体实现中,主要采用Cheney算法。

      Cheney算法是一种采用复制的方式实现的垃圾回收算法。

      在垃圾回收的过程中,就是通过将存活对象在两个semisoace空间进行复制。

      Scavenge的缺点:只能使用堆内存的一半。

      Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。

      当一个对象经过多次复制依然存活时,它将被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。

      对象从新生代中移动到老生代中的过程称为晋升

    • Mark-Sweep & Mark-Compact

      Mark-Sweep是标记清除的意思,分为标记和清除两个阶段。

      Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。

      Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来;

    • Incremental Marking

      为了避免出现JavaScript应用逻辑与垃圾回收器看到不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收再回复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。

      为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(Incremental Marking)。

1.5 查看垃圾回收日志

查看垃圾回收日志的方式主要是在启动时添加--trace_gc参数。在进行垃圾回收时,将会从标准输出中打印垃圾回收的日志信息。

通过分析垃圾回收日志,可以了解垃圾回收的运行状况,找出垃圾回收的哪些阶段比较耗时,触发的原因是什么。

2. 高效使用内存

在V8面前,开发者所要具备的责任是如何让垃圾回收机制更高效地工作。

2.1 作用域

在JavaScript中能形成作用域的有函数调用、with以及全局作用域。

  1. 标识符查找

  2. 作用域链

  3. 变量的主动释放

    如果需要释放常驻内存的对象,可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。

    虽然delete操作和重新赋值具有相同的效果,但是在V8中通过delete删除对象的属性可能干扰V8的优化,所有通过赋值方式解除引用更好。

2.2 闭包

在JavaScript中,实现外部作用域访问内部作用域中的变量的方法叫做闭包(closure)。这得益于高阶函数的特性:函数可以作为参数或者返回值。

闭包是JavaScript的高级特性,利用它可以产生很多的巧妙的效果。一旦有变量引用这个中间函数,这个中间函数将不会释放,同样也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。除非不再引用,才会逐步释放。

3. 内存指标

3.1 查看内存的使用情况

  1. 查看进程的内存占用

    调用process.memoryUsage()可以看到Node进程的内存占用情况。

    rss是resident set size的缩写,即进程的常驻内存部分。进程的内存总共有几部分,一部分是rss,其余部分在交换区(swap)或者文件系统(filesystem)中。

  2. 查看系统的内存占用

    OS模块中的totalmem()和freemem()用于查看操作系统的内存使用情况。

    分别返回系统的总内存和闲置内存,单位是字节。

    $ node
    > os.totalmem()
    8446971904
    > os.freemem()
    2469531648
    >
    

3.2 堆外内存

堆中的内存用量总是小于进程的常驻内存量,这意味着Node中的内存使用并非都是通过V8进行分配的。将不是通过V8分配的内存称为堆外内存

堆外内存可以突破内存限制。

4. 内存泄露

Node对内存泄露十分敏感,一旦线上应用有成千上万的流量,哪怕是一个字节的内存泄露也会造成堆积,垃圾回收过程将会耗费更多时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。

内存泄露的原因:

  • 缓存;
  • 队列消费不及时;
  • 作用域未释放;

4.1 慎将内存当做缓存

缓存在应用的作用举足轻重,可以十分有效地节省资源。缓存的访问效率要比I/O的效率高,一旦命中缓存,就可以节省一次I/O的时间。

一旦一个对象被当做缓存来使用,就意味着它将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。

JavaScript开发者通常喜欢用对象的键值对来缓存东西,但这与严格意义上的缓存有区别,严格意义上的缓存有着完善的过期策略,而普通对象的键值对并没有。

一个无意识造成的内存泄露的场景:memoize

memoize的原理是以参数作为键进行缓存,以内存空间换CPU执行时间。

  1. 缓存限制策略

    为了解决缓存中的对象永远无法释放的问题,需要加入一种策略来限制缓存的无限增长。

  2. 缓存的解决方案

    如何使用大量缓存,目前比较好的解决方案是采用进程外的缓存,进程自身不存储状态。

    外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能。在Node中主要解决两个问题:

    • 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更加的高效;
    • 进程之间可以共享缓存;

    目前,市场上较好的缓存有Redis和Memcached。

4.2 关注队列状态

在大多数应用场景下,消费的速度远远大于生产的速度,内存泄露不易产生。但是一旦消费速度低于生产速度,将会形成堆积。

表层的解决方案是换用消费速度更高的技术。

深度的解决方案是监控队列的长度。

5. 内存泄露排查

1.node-heapunp

2.node-memwatch

6. 大内存应用

Node提供了stream模块用于处理大文件。