Skip to content

V8 内存管理

1. 为什么要了解掌握内存管理

js的执行依赖于V8引擎,而Nodejs是基于V8的能让js运行在服务器端的运行环境,nodejs的出现,js的应用场景已经不在局限于浏览器。

对于那些执行时间短的场景,比如网页应用、命令行工具、前端构建根据等等,均是运行在用户的机器上,即使内存使用过多或者泄漏也只会影响到终端用户,由于运行时间短,随着进程的退出,内存会得到释放,几乎没有内存管理的必要,但是Nodejs用来作为服务器端应用时,服务器的资源本来就是寸土寸金,要为海量用户提供服务,就得使一切资源都要高效循环的利用,这就是为什么需要了解内存管理的原因。

2. Node与V8

2.1 垃圾回收机制

在浏览器中进行开发时,几乎很少有人能遇到垃圾回收机制对应用程序构成性能影响,那什么是垃圾回收机制呢?

垃圾回收机制:程序员只需要申请内存,而不需要关注内存的释放。垃圾回收器(GC)会在适当的时候将已经终止生命周期的变量的内存给释放掉。

js也有垃圾回收机制,同java一样,我们不用太关注内存的分配和释放问题。

对于对性能比较敏感的服务器端程序,内存管理的好坏,垃圾回收状况是否优良,都会对服务构成影响

Node中,这一些都与js执行引擎V8息息相关。

2.2 Node

Node创始人:Ryan Dahl 2009年, Ryan Dahl 选择了V8作为Node的js执行引擎

V8创始人:Lars Bak Lars Bak,绝大部分工作履历都是与虚拟机相关的工作,开发V8之前,在sun公司,担任HotSpot团队技术领导,主要致力于开发高性能java虚拟机,并且之前,他还开发过Self、Smalltalk语言高性能虚拟机。因此,V8也就如此优秀。

google的Chrome的成功,背后离不开V8,V8的性能优势使得js编写高性能后台服务程序成为可能。

V8是虚拟机,性能优异

Node在js的执行上直接受益于V8,可以随着V8的升级就能享受到更好的性能或者新的从语言特性(如ES5/6)等,同时也收到V8的一些限制,尤其是内存限制。

2.3 V8的内存限制

V8对运行在其之上的程序有着内存的限制:

  • 64位系统下约为1.4GB
  • 32位系统下约为0.7GB

这个限制导致Node无法直接操作大内存对象,

比如:无法将一个2GB的文件读入内存中进行字符串分析处理,即使物理内存有32GB。这样在单个Node进程的情况下,计算机的内存得不到充分利用。

  • 在Chrome里面,一个tab选项卡里面就有一个V8实例,因此,每个选项卡里内存的使用是绰绰有余的。
  • 但在Node中,这个缺点就限制了开发者随心所欲使用大内存的想法。

因此,若我们开发中,不小心触碰到这个内存界限,就会造成程序退出。

2.3.1 为什么V8要限制内存的大小?

  • 表层原因: V8最初为浏览器设计,不太可能遇到大内存的场景,对于网页来说,V8的内存限制值绰绰有余。
  • 深层原因: 由于V8的垃圾回收机制的限制 按照官方的说法,以1.5GB的垃圾内存回收为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上,这是垃圾回收中引起线程暂停执行的时间,这样的时间花销会使得应用的性能直线下降。这不仅仅在服务端无法接受,在浏览器端也无法接受。

综上所述,考虑直接限制堆内存是一个好的选择。

2.3.2 调整V8默认内存限制大小

V8提供我们选项用于让我们使用更多内存

在node启动时候, 可以传递参数--max-old-space-size--max-new-space-size来调整内存限制的大小。

V8内存分为新生代老生代两代,新生代中的对象为存活时间较少的对象。老生代中的对象为存活时间较长的对象(常驻内存的对象)

--max-old-space-size:用于设置老生代内存空间的最大值; --max-new-space-size:用于设置新生代内存空间的最大值;

无论是新生代还是老生代,都只能在启动时候分配好V8最大内存空间,无法按需扩充。当内存分配过程中超过这个最大值,就会引起进程出错。

sh
node --max-old-space-size=1700 test.js ## 单位为MB

## 或者

node --max-new-space-size=1024 test.js ## 单位为KB

上述参数在V8初始化时生效,一旦生效就不能再动态改变

有关新生代和老生代具体说明,请看下文第三节【Node垃圾回收机制】

2.3.3 V8的对象分配

V8中,对象都是通过来进行分配的。

Node进程内存使用量的查看方式:

js
process.memoryUsage()
/* 
output:
{
  rss: 25108480,  // 进程常驻内存部分
  heapTotal: 5484544,   // V8堆内存使用情况(已申请到的堆内存)单位为字节
  heapUsed: 2978456,    // 当前使用量 单位字节
  external: 1562219,
  arrayBuffers: 148659
}
*/

3. node 垃圾回收机制

3.1 内存管理模型

Node程序运行中,此进程占用的所有内存称为常驻内存(Resident Set)。

常驻内存由以下部分组成:

  • 代码区(Code Segment):存放即将执行的代码片段
  • 栈(Stack):存放局部变量
  • 堆(Heap):存放对象、闭包上下文
  • 堆外内存:不通过V8分配,也不受V8管理。Buffer对象的数据就存放于此。

下面是V8内存模型示意图

image.png

除堆外内存,其余部分均由V8管理。

  • 栈(Stack)的分配与回收非常直接,当程序离开某作用域后,其栈指针下移(回退),整个作用域的局部变量都会出栈,内存收回。
  • 最复杂的部分是堆(Heap)的管理,V8使用垃圾回收机制进行堆的内存管理,也是开发中可能造成内存泄漏的部分

3.2 新生代与老生代

上面说到,V8内存限制根据系统位数不同,限制也不同, 但是最大的内存使用量是64位系统约1.4GB,要修改这个最大值,在进程启动时候,可以手动修改,但是这个修改分为新生代与老生代,分别为--max-new-space-size--max-old-space-size,他们之间到底有什么不同呢?

image.png

3.2.1 新生代

顾名思义,新生代中的对象一般存活时间较短,它采用的是Cheney算法

如上图(V8堆内存示意图),新生代的内存被一分为二,这两块小地盘都叫semispace空间,这俩小地盘只有一个处于使用中,另一个处于闲置状态,而处于使用中的叫From空间, 闲置的叫To空间;

当我们在代码里声明对象的时候,这个对象就会被安置到From空间;那个To空间就是闲置的; 但当开始垃圾回收时,算法会检查From空间“还活着”的对象,把还活着的对象复制到To空间,然后把From空间内容清除掉成为To空间, 在复制过程中会检查对象的内存地址来判断这个对象是否已经经历过一次Scavenge回收如果这个对象已经经历过一次了,这个对象就会被放到老生代里或者如果To空间的使用量已经到达25%,这个对象也会直接升入老生代;这之后To空间里存放该轮垃圾回收后存活的对象,然后这个To空间就会改名From空间,后续如果又有新变量声明了就继续存放在这个From空间,之前那个From空间则会成为``To空间;新生代的垃圾回收就是这样复制来复制去;

也许有人会觉得复制过程很费时间,统计学指导,新生代中大多数对象寿命都不长,长期存活对象少,则需要复制的对象相对来说很少,因此总体来说,新生代使用Scavenge算法的效率非常高。且由于Scavenge是依次连续复制,所以To空间永远不会存在内存碎片。

上面说到新生代升入老生代的两种情况:

  • 经历过一次Scavenge回收的;
  • 在复制过程中,To空间已经使用超过25%;

至于为什么是25%,在复制结束后,To空间变为From空间,这个空间要继续承担内存分配,如果占比过高会影响后续的内存使用;

3.2.2 老生代

老生代的中存放的数据垃圾回收主要采用标记清除(Mark-Sweep)标记整理(Mark-Compact)。这两种方式并非互相替代关系,而是配合关系,在不同情况下,选择不同方式,交替配合以提高回收效率。

当老生代的垃圾回收被触发的时候,v8会给还存活的对象打上标记,然后把没有标记的对象全部清除,这就是一次标记清除;

可是随着程序的继续运行,却会出现一个问题: 被清除的对象遍布各个内存地址,空间有大有小,其闲置空间不连续,产生了很多内存碎片。当需要将一个足够大的对象晋升至老生代时,无法找到一个足够大的连续空间安置这个对象。

为了解决这种空间碎片的问题,就出现了标记整理算法。它是在标记清除的基础上演变而来,当清理了死亡对象后,它会将所有存活对象往一端移动,使其内存空间紧挨,另一端就成为了连续内存;

3.2.3 增量标记

早期V8在垃圾回收阶段,采用全停顿,也就是垃圾回收时程序运行会暂停;这在前端使用js时还没有缺点显现,但是在node中,内存使用高,在老生代的垃圾回收中,标记时间很容易超过100ms,全停顿导致程序卡滞很明显,于是v8引入了增量标记,将标记动作分成若干个步骤,每运行一段时间标记动作,就停下运行一段时间程序,如此交替,程序运行流畅了很多

4. 内存分配

一般而言,应用存在一些全局性的对象是正常的,而且在正常的使用中,变量都会自动释放回收,但是也会存在一些我们认为会回收但是却没有被回收的对象,这回导致内存占用无线

4.1 查看内存占用情况

前面提到可以使用process.memoryUsage()来查看Node进程内存的使用情况,另外,os模块中的totalmem()freemem()方法也可以查看内存使用情况,但是他们查看的是系统内存占用情况

4.1.1. 查看进程内存占用

js
process.memoryUsage();
// 输出 (单位均是字节)
{
  rss: 24989696, // 进程常驻内存部分
  heapTotal: 5222400, // 堆中总共申请的内存量
  heapUsed: 2995624, // 目前堆中使用中的内存量
  external: 1562219, 
  arrayBuffers: 140467 
}

进程中的内存总共有几部分:

  • 一部分是rss,也就是进程中常驻内存部分
  • 其余部分在交换区或者文件系统中

4.1.2. 查看系统中的内存占用

js
$ node
> os.totalmem() // 返回系统的总内存
34192924672
> os.freemem() // 返回系统的闲置内存
29541670912
>

4.2 堆外内存

通过上面的process.memoryUsage()可以看出,堆中内存用量总是小于进程的常驻内存用量,这意味着Node中的内存使用并非都是通过V8分配的, 我们将那些不是通过V8分配的内存成为堆外内存。

堆外内存可以突破内存限制的问题

Bufferd对象不同于其他对象,它不经过V8内存分配机制,所以也不会有堆内存的大小限制。

4.2.1 为何Buffer对象并非通过V8分配?

这在于Node并不同于浏览器应用场景。在浏览器中,js直接处理字符串就能满足绝大多数的需求,但是Nodejs中需要处理网络流和文件I/O,字符串远不能满足。

Buffer对象内存分配不是在V8的堆内存中,而是在Node的C++层面实现内存申请(提供)的,因为处理大量的字节数据不能采用需要一点内存就向操作系统申请的方式,这可能造成大量申请的系统调用,对系统有一定压力,所以Node采用的内存的使用上应用的是在C++层面申请内存,在js中分配内存的策略。

总结:真正的堆外内存在C++层面提供,js层面只是使用,当进行小(小于8kb)而频繁的buffer操作时,采用slab的机制进行预先申请和时候分配,减小系统压力,对于大块(大于8kb)buffer而言,则直接使用c++层面提供的内存,而无需细腻的分配操作。

Node中若要操作大文件,使用大内存,首选用stream的形式来实现,减少内存使用量,且性能会更好

5. Node开发中的内存管理与优化

5.1 内存泄漏

内存泄漏的本质,就是应当被回收的对象出现意外而没被回收,变成了常驻在老生代中的对象。

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

通常造成内存泄漏的原因有:

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

5.2 慎将内存当`缓存

缓存在应用中的作用举足轻重,可以十分有效的节省资源。因为它的党文效率要比I/O效率高太多。

但是,缓存不代表内存。

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

若不得已要使用内存作为缓存,最好设置对象的键的数量,或者提供一些方法,在不用这个对象的时候将其销毁。

例如: 由于commonjs模块的加载机制,模块内部会有一个缓存机制,可以加快模块的引入。若是在模块中设置了私有变量,灾后通过exports导出模块,模块外面可以访问模块内部的变量, 这种很容易导致模块内变量得不到释放,在设计这种模块代码时候,可以提供一个方法,用于销毁内部变量,从而释放内存。

5.3 缓存解决方案

若大量使用缓存,目前比较好的解决方案是采用进程外的缓存,进程自身不存储状态,外部的缓存软件有着良好的缓存国企淘汰策略以及自由的内存管理。不影响Node进程性能。好处多多,在Node中主要可以解决以下两个问题:

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

解决方案: Redis,Memcached

5.4 内存泄漏排查

如今有很多工具用于排查内存泄漏:

  • v8-profiler
    • 由Danny Coates提供,它可以用于V8堆内存抓取快照和对CPU进行分析,但很久没维护了
  • node-heapdump:
    • 由Node核心贡献者Ben Noordhuis编写的模块,它允许V8堆内存抓取快照,用于时候分析
  • node-mtrace:
    • 它使用了GCCmtrace工具来分析堆的使用
  • dtrace
  • node-memwatch