美文网首页IT共论NodeJS技术栈我爱编程
深入理解Node.js垃圾回收与内存管理

深入理解Node.js垃圾回收与内存管理

作者: 写Blog不取名 | 来源:发表于2016-12-08 11:23 被阅读2814次

使用JavaScript进行前端开发时几乎完全不需要关心内存管理问题,对于前端编程来说,V8限制的内存几乎不会出现用完的情况,但是由于后端程序往往进行的操作更加复杂,并且长期运行在服务器不重启,如果不关注内存管理,导致内存泄漏,就算1TB,也会很快用尽。
Node.js构建于V8引擎之上,因此本文首先讲解V8引擎的内存管理机制,了解底层原理后,再讲解Node开发中的内存管理与优化。

一、V8的内存管理机制

1.1 内存管理模型

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

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


      V8内存模型

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

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

通过process.memoryUsage()可以查看此Node进程的内存使用状况:

内存使用状况
rss是Resident Set Size的缩写,为常驻内存的总大小,heapTotal是V8为堆分配的总大小,heapUsed是已使用的堆大小。可以看到,rss是大于heapTotal的,因为rss包括且不限于堆。

1.2 堆内存限制

默认情况下,V8为堆分配的内存不超过1.4G:64位系统1.4G,32位则仅分配0.7G。也就是说,如果你想使用Node程序读一个2G的文件到内存,在默认的V8配置下,是无法实现的。不过我们可以通过Node的启动命令更改V8为堆设置的内存上限:

//更改老年代堆内存
--max-old-space-size=3000 // 单位为MB
// 更改新生代堆内存
--max-new-space-size=1024 // 单位为KB

堆的内存上限在启动时就已经决定,无法动态更改,想要更改,唯一的方法是关闭进程,使用新的配置重新启动。

1.3 V8的垃圾回收机制

垃圾回收机制演变至今,已经出现了数种垃圾回收算法,各有千秋,适用于不同场景,没有一种垃圾回收算法能够效率最优于所有场景。因此研发者们按照存活时间长短,将对象分类,为每一类特定的对象,制定其最适合的垃圾回收算法,以提高垃圾回收总效率。

  • 1.3.1 V8的内存分代

    • V8将堆中的对象分为两类:
      1. 新生代:年轻的新对象,未经历垃圾回收或仅经历过一次
      2. 老年代:存活时间长的老对象,经历过一次或更多次垃圾回收的对象

    默认情况下,V8为老年代分配的空间,大概是新生代的40多倍。
    新对象都会被分配到新生代中,当新生代空间不足以分配新对象时,将触发新生代的垃圾回收。
  • 1.3.2 新生代的垃圾回收
    新生代中的对象主要通过Scavenge算法进行垃圾回收,这是一种采用复制的方式实现内存回收的算法。
    Scavenge算法将新生代的总空间一分为二,只使用其中一个,另一个处于闲置,等待垃圾回收时使用。使用中的那块空间称为From,闲置的空间称为To

    From与To各占一半
    当新生代触发垃圾回收时,V8将From空间中所有应该存活下来的对象依次复制到To空间。
    • 有两种情况不会将对象复制到To空间,而是晋升至老年代:
      1. 对象此前已经经历过一次新生代垃圾回收,这次依旧应该存活,则晋升至老年代。
      2. To空间已经使用了25%,则将此对象直接晋升至老年代。

    From空间所有应该存活的对象都复制完成后,原本的From空间将被释放,成为闲置空间,原本To空间则成为使用中空间,两个空间进行角色翻转
    为何To空间使用超过25%时,就需要直接将对象复制到老年代呢?因为To空间完成垃圾回收后将会翻转为From空间,新的对象分配都在此处进行,如果没有足够的空闲空间,将会影响程序的新对象分配。
    因为Scavenge只复制活着的对象,而根据统计学指导,新生代中大多数对象寿命都不长,长期存活对象少,则需要复制的对象相对来说很少,因此总体来说,新生代使用Scavenge算法的效率非常高。且由于Scavenge是依次连续复制,所以To空间永远不会存在内存碎片。
    不过由于Scavenge会将空间对半划分,所以此算法的空间利用率较低。
  • 1.3.3 老年代的垃圾回收
    在老年代中的对象,至少都已经历过一次甚至更多次垃圾回收,相对于新生代中的对象,它们有更大的概率继续存活,只有相对少数的对象面临死亡,且由于老年代的堆内存是新生代的几十倍,其中生活着大量对象,因此如果使用Scavenge算法回收老年代,将会面临大量的存活对象需要复制的情况,将老年代空间对半划分,也会浪费相当大的空间,效率低下。因此老年代垃圾回收主要采用标记清除(Mark-Sweep)标记整理(Mark-Compact)
    这两种方式并非互相替代关系,而是配合关系,在不同情况下,选择不同方式,交替配合以提高回收效率。
    新生代中死亡对象占多数,因此采用Scavenge算法只处理存活对象,提高效率。老年代中存活对象占多数,于是采用标记清除算法只处理死亡对象,提高效率。
    当老年代的垃圾回收被触发时,V8会将需要存活对象打上标记,然后将没有标记的对象,也就是需要死亡的对象,全部擦除,一次标记清除式回收就完成了:

    灰色为存活对象,白色为清除后的闲置空间
    一切看起来都完美了,可是随着程序的继续运行,却会出现一个问题:被清除的对象遍布各个内存地址,空间有大有小,其闲置空间不连续,产生了很多内存碎片。当需要将一个足够大的对象晋升至老年代时,无法找到一个足够大的连续空间安置这个对象。
    为了解决这种空间碎片的问题,就出现了标记整理算法。它是在标记清除的基础上演变而来,当清理了死亡对象后,它会将所有存活对象往一端移动,使其内存空间紧挨,另一端就成为了连续内存:

    虽然标记整理算法可以避免空间碎片,但是却需要依次移动对象,效率比标记清除算法更低,因此大多数情况下V8会使用标记清理算法,当空间碎片不足以安放新晋升对象时,才会触发标记整理算法。
  • 1.3.4 增量标记(Incremental Marking)
    早期V8在垃圾回收阶段,采用全停顿(stop the world),也就是垃圾回收时程序运行会被暂停。这在JavaScript还仅被用于浏览器端开发时,并没有什么明显的缺点,前端开发使用的内存少,大多数时候仅触发新生代垃圾回收,速度快,卡顿几乎感觉不到。但是对于Node程序,使用内存更多,在老年代垃圾回收时,全停顿很容易带来明显的程序迟滞,标记阶段很容易就会超过100ms,因此V8引入了增量标记,将标记阶段分为若干小步骤,每个步骤控制在5ms内,每运行一段时间标记动作,就让JavaScript程序执行一会儿,如此交替,明显地提高了程序流畅性,一定程度上避免了长时间卡顿。

二、Node开发中的内存管理与优化

2.1 手动变量销毁

当任一作用域存活于作用域栈(作用域链)时,其中的变量都不会被销毁,其引用的数据也会一直被变量关联,得不到GC。有的作用域存活时间非常长(越是栈底,存活时间越长,最长的是全局作用域),但是其中的某些变量也许在某一时刻后就没有用处了,因此建议手动设置为null,断开引用链接,使得V8可以及时GC释放内存。
注意,不使用var声明的变量,都会成为全局对象的属性。前端开发中全局对象为window,Node中全局对象为global,如果global中有属性已经没有用处了,一定要设置为null,因为全局作用域只有等到程序停止运行,才会销毁。
Node中,当一个模块被引入,这个模块就会被缓存在内存中,提高下次被引用的速度。也就是说,一般情况下,整个Node程序中对同一个模块的引用,都是同一个实例(instance),这个实例一直存活在内存中。所以,如果任意模块中有变量已经不再需要,最好手动设置为null,不然会白白占用内存,成为“活着的死对象”。

2.2 慎用闭包

  • 2.2.1 V8的闭包实现
    先来看一段例子:
function outer(){
    var x = 1; // 真正的局部变量:outer执行完后立即死亡
    var y = 2; // 上下文变量:闭包死亡后才会死亡
    // 返回一个闭包
    return function(){
      console.log(y); // 使用了外层函数的变量 y
    }
}
var inner = outer(); // 通过inner变量持有闭包

有不少开发者认为,如果闭包被引用,那么闭包的外部函数也不会被释放,其中的所有变量都不会被销毁,比如我通过inner变量持有了闭包,此时outer中的 x、y 均活在内存中,不会被销毁。事实真是这样吗?
答案是:在V8的实现中,当outer执行完毕,x 立即死亡,仅有 y 存活
V8是这么做的:
当程序进入一个函数时,将会为这个函数创建一个上下文(Context),初始状态这个Context是空的,当读到这个函数(outer)中的闭包声明时,将会把此闭包(inner)中使用的外部变量,加入Context。在上面的例子中,由于inner函数使用了变量 y ,因此会将 y 加入Context。outer内部所有的闭包,都会持有这个Context


每一个闭包都会引用其外部函数的Context,以此访问需要读取的外部变量。被闭包捕捉,加入Context中的变量,我们称为Context变量,分配在堆。而真正的 局部变量(local variable)是 x ,保存在栈,当outer执行完毕后,其信息出栈,变量 x 自然销毁,而Context被闭包引用,如果有任何一个闭包存活,Context都将存活,y 将不会被销毁。
举一反三,再来看一个更复杂的例子:
function outer () { 
    var x; // 真正的局部变量
    var y; // context variable, 被inner1使用
    var z; // context variable, 被inner2使用
    function inner1 () { 
      use(y); 
    } 
    function inner2 () { 
      use(z); 
    } 
    function inner3 () { 
      /* 虽然函数体为空,但是作为闭包,依旧引用outer的Context */
    } 
    return [inner1, inner2, inner3];
}

x、y、z 三个变量何时死亡?
x 在outer执行完后立即死亡, y、z 需要等到inner1、inner2、inner3三个闭包都死亡后,才会死亡。
x 未被任何闭包使用,因此是一个真正的局部变量,保存在栈,函数执行完即被出栈死亡。由于 y、z 两个变量分别被inner1、inner2使用,则它们会被加入outer的Context。所有闭包都会引用外部函数的Context,即使inner3为空,不使用任何外部函数的变量,也会引用Context,所以需要等到三个闭包都死亡后,y、z 才会死亡。


因此:如果较大的对象成为了Context变量,建议严格控制引用此Context的闭包生命周期以及闭包数量,或在不需要时,设置为null,以免引起较多内存的长期占用。
  • 2.2.2 避免深层闭包嵌套
function outer() { 
    var x = HUGE; // 超大对象
    function inner() { 
      var y = GIANT; // 大对象
      use(x); // x 需要使用,需要成为Context变量
      function innerF() { 
        use(y); // y 需要使用,需要成为Context变量
      } 
      function innerG() { 
        /* 空函数体 */
      } 
      return innerG; 
    } 
    return inner();
}
var o = outer(); // HUGE and GIANT 均得不到释放

变量 o 持有的是innerG闭包,innerG持有着inner的Context,且内部闭包的Context会持有外部闭包的Context,产生Context链

上下文链
为了减轻GC压力,建议避免过深嵌套函数/闭包,或及早手动断开Context变量所引用的大对象。

2.3 大内存使用

  • 2.3.1 使用stream
    当我们需要操作大文件,应该利用Node提供的stream以及其管道方法,防止一次性读入过多数据,占用堆空间,增大堆内存压力。

  • 2.3.2 使用Buffer
    Buffer是操作二进制数据的对象,不论是字符串还是图片,底层都是二进制数据,因此Buffer可以适用于任何类型的文件操作。
    Buffer对象本身属于普通对象,保存在堆,由V8管理,但是其储存的数据,则是保存在堆外内存,是有C++申请分配的,因此不受V8管理,也不需要被V8垃圾回收,一定程度上节省了V8资源,也不必在意堆内存限制。

参考资料:

**
本人技术有限,且技术更新很快,如果文中存在错误或者不足,欢迎大家指正,相互交流。
邮箱:hjaurum@gmail.com
**

相关文章

  • 安卓技能点

    技术方面: 掌握Java开发,以及对JVM以及Java内存管理,java内存区域与溢出、垃圾回收有较深入的理解; ...

  • 深入理解Node.js垃圾回收与内存管理

    使用JavaScript进行前端开发时几乎完全不需要关心内存管理问题,对于前端编程来说,V8限制的内存几乎不会出现...

  • 深入理解Node.js垃圾回收与内存管理

    使用JavaScript进行前端开发时几乎完全不需要关心内存管理问题,对于前端编程来说,V8限制的内存几乎不会出现...

  • Android性能调优(4) — 内存泄漏与内存抖动

    在上一遍《Android性能调优(3)—内存管理与垃圾回收》我们对Android内存管理与垃圾回收有了一定的认识。...

  • 常见GC算法与V8引擎

    内存管理 垃圾回收与常见GC算法 V8引擎的垃圾回收 Performance工具 代码优化实例 内存管理 为什么要...

  • Java垃圾回收

    本文主要摘自《深入理解Java虚拟机》,内容较多,尽量全面概括了 Java 垃圾回收机制、垃圾回收器以及内存分配策...

  • JavaScript的垃圾回收机制

    大纲 1、认识垃圾回收机制2、垃圾回收机制的原理3、垃圾回收机制的标记策略4、垃圾回收机制与内存管理 1、认识垃圾...

  • Java GC

    概述 GC => 垃圾回收 = 回收可用空间 + 压缩内存 内存管理 手动内存管理 => C | C++ 自动内存...

  • Java垃圾回收详解

    深入理解 Java 垃圾回收机制 深入理解 Java 垃圾回收机制 一:垃圾回收机制的意义 java 语言中一个...

  • 要点提炼| 理解JVM之GC

    有内存分配就会有内存回收,上篇也了解到Java堆是垃圾收集器管理的主要区域,本篇将理解这部分内存的垃圾回收机制。 ...

网友评论

  • M笔迹:这篇文章写得很好,但有个点说的不太懂。2.2.1的例子中,说是函数跑完,局部变量就回收了。但是前面又说,新生代的垃圾回收再他的from内存不足时候,才会触发一次垃圾回收。那究竟是什么时候触发的呀
  • 萧哈哈:有几个问题想和作者交流下。
    你对于 Javascript 红书中所讲的 基本数据类型保存在栈中, 引用数据类型保存在堆中是怎么看的?
    执行上下文所关联的变量对象(ES3), 到底是存在堆还是栈上?
    萧哈哈:@Erum 感谢分享
    写Blog不取名:问题2: 如果您说的是闭包的上下文,是保存在堆的。上下文中涉及的对象也是保存在堆的,只有引用会保存在栈。
    写Blog不取名:@萧哈哈 问题1:不知道您说的怎么看的,是指哪方面?目前我所了解的有限的语言中,基本数据类型都是保存在栈,引用数据类型都是保存在堆。先抛开js来说这个问题吧:栈是函数执行时开辟的,随着函数的退出,会随之销毁,因此可以发现这些语言中基本数据类型在传递的过程中都是复制的,如果不复制,随着创建他的函数退出,这个值就不存在了。引用数据类型用保存在堆我觉得这个陈述不是特别恰当。引用数据类型包括引用+实体。引用(也就是地址),是和基础类型一样保存在栈,传递时也是复制。而实体保存在堆,猜测当时这种设计的原因是引用数据类型的实体可以非常大,而栈的空间却是非常有限的,一是为了不让栈溢出,二是如果保存在栈中,每次传递都必须复制值,那么将是非常庞大的工作和数据量,所以只将指针保存在栈,而实体保存在堆。如果对这种问题感兴趣,推荐你可以了解一下汇编语言,《汇编程序设计》(王爽),这本书非常好,适合学习。
  • 萧哈哈:感谢分享, 解决了几个疑惑。
  • shengbeiniao:buffer虽然对Node来说是堆外内存,但如果不及时清除的话,很容易产生缓冲区溢出
  • d526c8be9879:找到这篇文章真是太幸运了,终于弄明白困惑了好久的问题
    写Blog不取名:能对你有帮助,很开心:grin:

本文标题:深入理解Node.js垃圾回收与内存管理

本文链接:https://www.haomeiwen.com/subject/nwndmttx.html