为了账号安全,请及时绑定邮箱和手机立即绑定

热点面试题:Node.js 中的垃圾回收机制?

前言

极度投入,深度沉浸,边界清晰
前端小菜鸡一枚,分享的文章纯属个人见解,若有不正确或可待讨论点可随意评论,与各位同学一起学习~

欢迎关注 『前端进阶圈』 公众号 ,一起探索学习前端技术…
公号回复 加群扫码, 即可加入前端交流学习群,长期交流学习…
公号回复 加好友,即可添加为好友

文章分为最后总结和步骤讲解,可自需查看。废话不多数,先上总结。


  • 总结:
    • Node.js 所运行的内存属于 Chrome V8 所管理的内存,在 Javascirpt 中所创建的对象都是保存在堆内存中,V8 的垃圾回收机制按照对象的存活时间将内存主要分为:新生代和老生代。
      • 新生代:简单来说,就是存活时间较短的对象所存储的地方。
        • 主要采用的是 semispace,它将内存分为了两个空间:From spaceTo space, 例如我们声明一个新对象,这个新对象会被放入 From space 中,当 From space 快满的时候,会遍历所有的对象,将活跃对象从 From space copy 到 To space 中。在这个过程中,如果一个对象被 copy 了很多次,就会被认为是存活时间较长的对象,将会被放入老生代中。
      • 老生代:相反,就是存活时间较长的对象所存储的地方。
        • 主要采用的是 Mark-Sweep (标记清除)算法,它会从执行栈和全局对象上找所有能访问到的对象,将他们标记为活跃对象,标记完成之后,进入清除阶段,将没有标记的对象清除(这个过程也就是将清除了对象的内存标记为空闲状态),最后,将空闲状态的内存进行释放。

  • 内存机制

    • 整体来讲,Node 的内存分为两部分:Chrome V8管理的部分(Javascript使用的部分)系统底层管理的部分(C++/C使用的部分)
      • Chrome V8 的内存管理机制

        • Node 程序运行所占用的所有内存称为常驻内存, 常驻内存 由以下几部分组成:
          1. 代码区: 存放即将执行的代码片段
          2. 栈:存放局部变量
          3. 堆:存放对象和闭包上下文,V8 使用的垃圾回收机制管理堆内存
          4. 堆外内存:不通过 V8 分配,也不受 V8 管理。是 Buffer 对象的数据存储的地方
        • A:除堆外内存,其余部分均由 V8 管理
          1. 栈的分配与回收非常直接,当程序离开某作用域后,其栈指针下移(也就是回退),整个作用域的局部变量都会出栈,内存将被收回
          2. 最复杂的部分是堆的管理,V8 使用垃圾回收机制进行堆的内存管理,也是开发中可能造成内存泄漏的部分,是开发者的关注点
      • 内存C/C++的部分

        • 这是 Node 的原生部分,也是从根本上区别与前端 js 的部分,包括核心运行库,在一些核心模块的加载过程中,Node 会调用一个名为 js2c 的工具。这个工具会将核心的 js 模块代码以C数组的方式存储在内存中,以此来提升运行效率。
        • 在这个部分,我们也不会有内存的使用限制,但是作为 C/C++ 扩展来使用大量内存的过程中,风险也是显而易见的。
        • C/C++ 没有内存回收机制。作为没有 C/C++ 功底的纯前端程序员,不建议去使用这部分,因为 C/C++ 模块非常强大,如果对于对象生命周期的理解不够到位,而在使用大内存对象的情境中,很容易就造成内存溢出,导致整个 Node 的崩溃甚至是系统的崩溃。安全的使用大内存的方法就是使用 Buffer 对象。
  • Node 中的 js 引擎也是 chrome 的 V8 引擎,所以垃圾回收机制也属于 V8 中的内部垃圾回收机制。

  • js 中的对象都是保存在堆内存中,在创建进程时,会分配一个默认的堆内存,当对象越来越大时,堆内存会动态的扩大,如果达到最大限制,堆内存就会溢出抛出错误,然后终止 node.js 进程。

  • V8 的垃圾回收机制根据对象的存活时间采用了不同的算法,内存主要分为新生代和老生代。

    • 新生代(存活时间较短的对象):

      • 新生代内存采取的是将内存分为两个空间(每一部分空间称为 semispace)
        1. From space: 新生命的对象会存放在此
        2. To space: 当做搬移的空间
      • 新声明的对象会被放入 From space,From space 的对象紧密排布,通过指针,上一个对象紧贴着下一个对象,所以内存是连续的,我们不用担心内存碎片问题。
        • Q:什么是内存碎片?
          • 内存碎片分为:内部碎片和外部碎片两类
            • 内部碎片:系统为某个功能分配了一定的内存,但是该功能最终的实现没有用完系统分配的内存,剩余的部分内存就被称为内存碎片中的内部碎片。
            • 外部碎片:有一些连续性内存太小,无法被系统分配到某个功能所导致的浪费。
      • 当 From space 快满了,就会遍历出活跃对象,将他们从 From space 复制到 To space, 此时,From space 就空了,然后会将 From 与 To 互换身份。如果一个对象被 copy 了很多次,就会被认为是存活时间较长的,将会被移入老生代中。
      • A:这种基于 copy 的算法,优点是可以很好地处理内存碎片的问题,缺点是会浪费一些空间作为搬移的空间位置,此外因为拷贝比较耗费时间,所以不适合分配太大的内存空间,更多是做一种辅助垃圾回收。
      • 将存活的对象从一个区复制(Scavenge 算法:是一种基于 copy 的算法)到另一个区,对原来的区进行内存释放,反复如此。当一个对象经过多次复制依然存活时,这个对象就会被移入老生代当中。
      • Scavenge 算法(具体实现采用Cheney算法)原理:在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。
        • 优点:时间短。
        • 缺点:只能使用一半堆内存。新生代对象生命周期短,适合此算法。
    • 老生代(存活时间较长的对象):

      • 老生代的空间就比新生代要大得多了,放的是一些存活时间长的对象,用的是 Mark-Sweep (标记清除)算法。

      • 标记清除的过程:

        1. 从根集 Root Set(执行栈和全局对象)往上找到所有能访问到的对象,给它们标记为活跃对象。
        2. 标记完后,就是清除阶段,将没有标记的对象清除,其实就是标记一下这个内存地址为空闲。
        3. 这种做法会导致 空闲内存空间碎片化,当我们创建了一个大的连续对象,就会找不到地方放下。这时候,就要用 Mark-Compact(标记整理)来将碎片的活跃对象做一个整合。
        4. Mark-Compact 会将所有活跃对象拷贝移动到一端,然后边界的另一边就是一整块的连续可用内存了。
        5. 考虑到 Mark-Sweep 和 Mark-Compact 花费的时间很长,且会阻塞 JavaScript 的线程,所以通常我们不会一次性做完,而是用 增量标记 (Incremental Marking)的方式。也就是做断断续续地标记,小步走的策略,垃圾回收和应用逻辑交替进行。
        6. 另外,V8 还做了并行标记和并行清理,以此来提高执行效率。
      • S: 老生代采取的是标记清除算法,遍历所有对象并标记仍然存活的对象,然后再清除阶段将没有标记的对象进行清除,最后将清除后的空间进行释放。

        老生代 新生代(默认) 新生代(最大)
        64位系统 1400MB 32MB 64MB
        32位系统 700MB 16MB 32MB
    • 注:垃圾回收是影响性能的因素之一,要尽量减少垃圾回收,尤其是全堆垃圾回收

      对象存活时间 内存空间
      老生代 存活时间较长或常驻内存的对象 –max-old-space-size命令设置老生代内存空间的最大值
      新生代 存活时间较短的对象 –max-new-space-size命令设置新生代内存空间的大小
  • Q:V8 引擎为什么要将内存分为新老生代呢?

    • R:垃圾回收机制(GC)有很多种,但没有一种能胜任所有场景,在实际应用中,需要根据对象的生存周期的长短时间使用不同的算法,以此来达到最好的效果。在 V8 中,按对象的存活时间将内存的垃圾回收机制进行不同的分代,然后分别对不同的内存使用不同的高效算法。所以有了新老生代之分。
  • Q: V8 为什么要限制堆内存的大小?

    • R:因为 V8 垃圾回收机制的限制。垃圾回收会引起 js 线程暂停执行;内存太大,垃圾回收时间太长,在这个考虑下,直接限制了堆内存的大小。
  • Q: 如何让内存不受限制?

    • R: 在 Node 中,使用 Buffer 可以读取超过V8内存限制的大文件。原因是Buffer对象不同于其他对象,它不经过 V8 的内存分配机制。这在于 Node 并不同于浏览器的应用场景。在浏览器中,JavaScript 直接处理字符串即可满足绝大多数的业务需求,而 Node 则需要处理网络流和文件I/O流,操作字符串远远不能满足传输的性能需求。
    • R: 在不需要进行字符串操作时,可以不借助v8,使用 Buffer 操作,这样就不会受到v8的内存限制
  • Q:如何查看内存信息?

    • 可以通过 process.memoryUsage() 方法拿到内存相关信息
      process.memoryUsage();
      
      // output:
      {
          rss: 35454976,
          heapTotal: 7127040,
          heapUsed: 5287088,
          external: 958852,
          arrayBuffers: 11314
      }
      
      /**
       * unit(单位):byte(字节)
          rss:常驻内存大小(resident set size),包括代码片段、堆内存、栈等部分。
          heapTotal:V8 的堆内存总大小;
          heapUsed:占用的堆内存;
          external:V8 之外的的内存大小,指的是 C++ 对象占用的内存,比如 Buffer 数据。
          arrayBuffers:ArrayBuffer 和 SharedArrayBuffer 相关的内存大小,属于 external 的一部分
      */
      
  • Q: 如何测试最大内存限制?

    • 写一个脚本,用一个定时器,让一个数组不停地变大,并打印堆内存使用情况,直到内存溢出, 抛出错误
      const format = function (bytes) {
          return (bytes / 1024 / 1024).toFixed(2) + "MB";
      };
      const printMemoryUsage = function () {
      const memoryUsage = process.memoryUsage();
          console.log(
              `heapTotal: ${format(memoryUsage.heapTotal)}, heapUsed: ${forma(
              memoryUsage.heapUsed
              )}`
          );
      };
      const bigArray = [];
      setInterval(function () {
          bigArray.push(new Array(20 * 1024 * 1024));
          printMemoryUsage();
      }, 500);
      
    • A: 不要用 Buffer 做测试。因为 Buffer 是 Node.js 特有的处理二进制的对象,它不是在 V8 中的实现的,是 Node.js 用 C++ 另外实现的,不通过 V8 分配内存,属于堆外内存
    • 测试说明:使用电脑是 macbook M1 Pro,Node.js 版本为 v16.17.0,使用的 V8 版本是 9.4.146.26-node.22(通过 process.versions.v8 得到)
      // result:
      
      heapTotal: 164.81 MB, heapUsed: 163.93 MB
      
      heapTotal: 325.83 MB, heapUsed: 323.79 MB
      
      heapTotal: 488.59 MB, heapUsed: 483.84 MB
      
      ...
      
      heapTotal: 4036.44 MB, heapUsed: 4003.37 MB
      
      heapTotal: 4196.45 MB, heapUsed: 4163.29 MB
      
      <--- Last few GCs --->
      
      [28033:0x140008000]    17968 ms: Mark-sweep 4003.2 (4036.4) -> 4003.1 (4036.4) MB, 2233.8 / 0.0 ms  (average mu = 0.565, current mu = 0.310) allocation failure scavenge might not succeed
      
      [28033:0x140008000]    19815 ms: Mark-sweep 4163.3 (4196.5) -> 4163.1 (4196.5) MB, 1780.3 / 0.0 ms  (average mu = 0.413, current mu = 0.036) allocation failure scavenge might not succeed
      
      <--- JS stacktrace --->
      FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
      ...
      
    • 可以看到,是在 4000 MB 之后超出了内存上限,发生堆溢出,然后退出了进程。说明在我的机器上,默认的最大内存为 4G。
    • 实际最大内存和它运行所在的机器有关,如果你的机器的内存大小为 2G,最大内存将设置为 1.5G。
  • Q: Javascript 的部分是由 ChromeV8 接管的吗?那为什么仍然可以使用大量内存创建缓存呢?

    • R: 是的,Chrome 和 Node 都采用 ChromeV8 作为 JS 的引擎,但是实际上他们所面对的对象是不同的,Node 面对的是数据提供,逻辑和I/O,而 Chrome 面对的是界面的渲染,数据的呈现。因此在 Chrome 上,几乎不会遇到大内存的情况,作为为 Chrome 的而生的 V8 引擎自然也不会考虑这种情况,因此才会出现内存限制。而现在,Node 面对这样的情况是不可以接受的,所以 Buffer 对象,是一个特殊的对象,它由更低层的模块创建,存储在 V8 引擎以外的内存空间上。
    • R: 在内存的层面上讲 Buffer 和 V8 是平级的。
  • Q: 如何高效使用内存?

    1. 手动销毁变量
      • js中能形成作用域的有函数调用、with和全局作用域
      • 例如,在函数调用时,会创建对应的作用域,在执行结束后销毁,并且在该作用域申明的局部变量也会被销毁
        1. 标识符查找(即变量名) 先查找当前作用域,再向上级作用域,一直到全局作用域
        2. 变量主动释放 全局变量要直到进程退出才释放,导致引用对象常驻老生代,可以用delete删除或者赋undefined、null(delete删除对象的属性可能干扰v8,所以赋值更好)
    2. 慎用闭包
      • 闭包是外部作用域访问内部作用域的方法,得益于高阶函数特性
      var foo = function() {
      var bar = function() {
          var local = "内部变量";
          return function() {
              return local;
          };
      };
      var baz = bar();
          console.log(baz());
      };
      
      // 从上面代码知bar()返回一个匿名函数,一旦 有变量引用它,它的作用域将不会释放,直到没有引用。
      // 注:把闭包赋值给一个不可控的对象时,会导致内存泄漏。使用完,将变量赋其他值或置空
      
    3. 大内存使用
      1. 使用stream,当我们需要操作大文件,应该利用Node提供的stream以及其管道方法,防止一次性读入过多数据,占用堆空间,增大堆内存压力。
      2. 使用Buffer,Buffer是操作二进制数据的对象,不论是字符串还是图片,底层都是二进制数据,因此Buffer可以适用于任何类型的文件操作。
        Buffer对象本身属于普通对象,保存在堆,由V8管理,但是其储存的数据,则是保存在堆外内存,是有C++申请分配的,因此不受V8管理,也不需要被V8垃圾回收,一定程度上节省了V8资源,也不必在意堆内存限制。
  • Q: 内存泄露?

    • 原因:缓存,队列消耗不及时,作用域未释放等
    • 缓存:
      • 限制内存当缓存,要限制好大小,做好释放
      • 进程之间不能共享内存,所以用内存做缓存也是
    • 为了加速模块引入,模块会在编译后缓存,由于通过exports导出(闭包),作用域不会释放,常驻老生代。要注意内存泄漏。
      var arr = [];
      exports.hello = function() {
          arr.push("hello" + Math.random());
      };
      //局部变量arr不停增加内存占用,且不会释放,如果必须如此设计,要提供释放接口
      
    • 队列状态
      • 在生产者和消费者中间
      • 监控队列的长度,超过长度就拒绝
      • 任意的异步调用应该包含超时机制
    • 内存泄露排查的工具
      • node-heapdump
        1. 安装 npm install heapdump
        2. 引入 var heapdump = require(‘heapdump’);
        3. 发送命令kill -USR2 ,heapdump会抓拍一份堆内存快照,文件为heapdump-…heapsnapshot格式,是json文件
      • node-memwatch
        var memwatch = require('memwatch');
        memwatch.on('leak', function(info) {
            console.log('leak:');
            console.log(info);
        });
        memwatch.on('stats', function(stats) {
            console.log('stats:') console.log(stats);
        });
        
      • 在进程使用node-memwatch后,每次全堆垃圾回收,会触发stats事件,该事件会传递内存的统计信息
        stats: {
            num_full_gc: 4, //   第几次全堆垃圾回收
            num_inc_gc: 23, //    第几次增量垃圾回收
            heap_compactions: 4, //  第几次对老生代整理
            usage_trend: 0, // 使用趋势
            estimated_base: 7152944, // 预估基数
            current_base: 7152944, // 当前基数
            min: 6720776, //  最小
            max: 7152944  //最大
        }
        
    • 如果经过连续的5次垃圾回收后,内存仍没有被释放,意味有内存泄漏,node-memwatch会触发leak事件。

文章特殊字符描述:

  • 问题标注 Q:(question)
  • 答案标注 R:(result)
  • 注意事项标准:A:(attention matters)
  • 详情描述标注:D:(detail info)
  • 总结标注:S:(summary)
点击查看更多内容
1人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消