首頁  >  文章  >  web前端  >  一文聊聊Node中的記憶體控制

一文聊聊Node中的記憶體控制

青灯夜游
青灯夜游轉載
2023-04-26 17:37:052104瀏覽

基於無阻塞、事件驅動建立的Node服務,具有記憶體消耗低的優點,非常適合處理海量的網路請求。在海量請求的前提下,就需要考慮「記憶體控制」的相關問題了。

一文聊聊Node中的記憶體控制

1. V8的垃圾回收機制與記憶體限制

Js由垃圾回收機制來進行自動記憶體管理,開發者不需要像其它語言( c/c )那樣在編寫程式碼的過程中時刻關注記憶體的分配和釋放問題。在瀏覽器中,垃圾回收機制對應用程式構成效能影響較小,但對於效能敏感的伺服器端程序,記憶體管理的好壞、垃圾回收狀況是否優良,都會對服務構成影響。 【相關教學推薦:nodejs影片教學程式設計教學

#1.1 Node 與V8

Node是一個建構在Chrome的Js運行時上的平台,V8為Node的Js腳本引擎

1.2 V8的記憶體限制

在一般的後端語言中,對基本的記憶體使用沒有什麼限制,但是在Node中通過Js使用記憶體時,只能使用部分記憶體。在這樣的限制下,導致Node無法直接操作大記憶體物件。

造成問題的主要原因在於Node基於V8構建,在Node中使用的Js物件基本上都是透過V8自己的方式來進行分配和管理的。

1.3 V8的物件分配

在V8中,所有的Js物件都是透過堆來進行分配的。

查看v8中內存使用情況

截屏2023-01-21 下午3.35.30.png

heapTotal 和heapUsed是V8的堆內存使用情況, 前者是已申請到的堆內存,後者是目前使用的量。

在程式碼中宣告變數並賦值時,所使用物件的記憶體就會被分配在堆中。如果已申請的堆空閒內存不夠分配新的對象,將繼續申請堆內存,直到堆的大小超過V8的限制為止

V8為何要限制堆的大小,表層原因為V8最初為瀏覽器而設計,不太可能遇到用大量記憶體的場景。對於網頁來說,V8的限制值已經綽綽有餘。深層原因是V8的垃圾回收機制的限制。依照官方的說法,以1.5G的垃圾回收堆記憶體為例,v8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起Js線程暫停執行的時間,應用的效能和反應能力都會直線下降。在當時的考慮下直接限制堆記憶體是一個好的選擇。

這個限制是可以放開的,Node在啟動的時候,可以傳遞--max-old-space-size--max-new-space-size 來調整記憶體限制的大小,一旦生效就不能再動態改變。如:

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

1.4 V8的垃圾回收機制

v8用到的各種垃圾回收演算法

1.4.1 V8主要的垃圾回收演算法

#v8的垃圾回收策略主要基於分離式垃圾回收機制。

在實際的應用中,物件的生存週期長短不一。現在的垃圾回收演算法中按物件的存活時間將記憶體的垃圾回收進行不同的分代,對不同分代的記憶體施以更有效率的演算法。

  • v8的記憶體分代

    在v8中,主要將記憶體分為新生代和老生代兩代。新生代中的對象為存活時間較短的對象,老生代中的對象為存活時間較長或常駐記憶體的對象。

    新生代的記憶體空間 老生代的記憶體空間

    v8堆的整體大小=新生代所用記憶體空間老生代的記憶體空間

    v8堆記憶體的最大值在64位元系統下只能使用約1.4GB記憶體和在32位元系統下只能使用約0.7GB記憶體

  • Scavenge演算法

    在分代的基礎上,新生代中的物件主要透過Scavenge演算法進行垃圾回收。 Scavenge具體實作主要採用了Cheney演算法。 Cheney演算法是一種採用複製的方式實現的垃圾回收演算法。將堆內存一分為二,每個空間稱為semispace。在這兩個semispace空間中,只有一個處於使用中,另一個處於閒置狀態。處於使用狀態的semispace空間稱為From空間,閒置狀態的空間稱為To空間。分配物件時,先在From空間進行分配。當開始進行垃圾回收時,會檢查From空間中的存活對象,這些存活對象將複製到To空間中,而非存活對象所佔用的空間將會被釋放。完成複製後,From空間和To空間的角色發生對換。簡言之,在垃圾回收過程中,就是透過將存活物件在兩個semispace空間之間進行複製。 Scavenge的缺點是只能使用堆疊記憶體中的一半,這是由劃分空間和複製機制決定的。但Scavenge由於只複製存活的對象,且對於生命週期短的場景存活對像只佔少部分,所以它在時間效率上有優異的表現。 由於Scavenge是典型的犧牲空間換取時間的演算法,所以無法大規模地應用到所有的垃圾回收。但Scavenge非常適合應用在新生代中,因為新生代中物件的生命週期較短,適合這個演算法。 一文聊聊Node中的記憶體控制實際使用的堆內存是新生代中的兩個semispace空間大小和老生代所用內存大小之和。

當一個物件經過多次複製依然存活時,會被認為是生命週期較長的對象,會被移到老生代中,採用新的演算法來管理。物件從新生代中移動到老生代的過程稱為晉升。

在單純的Scavenge過程中,From空間中的存活物件會被複製到To空間中去,然後對From空間和To空間進行角色對換(翻轉)。但在分代式垃圾回收的前提下,From空間中的存活物件在複製到To空間之前需要進行檢查。在一定條件下,需要將存活週期長的物件移到老生代中,也就是完成物件晉升。

物件晉升的條件主要有兩個,一個是物件是否經歷過Scavenge回收,一個是To空間的記憶體佔用比超過限制。

在預設情況下,V8的物件分配主要集中在From空間。當物件從From空間複製到To空間時,會檢查它的記憶體位址來判斷這個物件是否已經經歷過一次Scavenge回收。如果已經經歷過了,會將該物件從From空間複製到老生代空間中,如果沒有,則複製到To空間中。晉升流程圖如下:

未命名文件 (1).png

另一個判斷條件是To空間的記憶體佔用比。從From空間複製一個物件到To空間時,如果To空間已經使用了25%,則這個物件直接晉升到老生代空間中,晉升流程圖如下:

未命名文件 (2).png

# #設定25%限制的原因:當這次Scavenge回收完成後,這個To空間將變成From空間,接下來的記憶體分配將在這個空間中進行。如果佔比過高,會影響後續的記憶體分配。

物件晉升後,將會在老生代空間中作為存活週期較長的物件來對待,接受新的回收演算法處理。

  • Mark-Sweep & Mark-Compact

    對於老生代中的對象,由於存活對象佔較大比重,如果採用Scavenge的方式有兩個問題:一個是存活對象較多,複製存活對象的效率將會很低;另一個問題是浪費一半的空間。為此,v8在老生代中主要採用了Mark-Sweep和Mark-Compact相結合的方式進行垃圾回收。

    Mark-Sweep是標記清除的意思,分成標記和清除兩個階段。與Scavenge相比,Mark-Sweep不會將記憶體空間分成兩半,所以不存在浪費一半空間的行為。與Scavenge複製活著的對像不同,Mark-Sweep在標記階段遍歷堆中的所有對象,並標記活著的對象,在隨後的清除階段中,只清除沒有被標記的對象。可以發現,Scavenge中只複製活著的對象,而Mark-Sweep只清理死亡的對象。活對像在新生代中只佔較小部分,死對像在老生代中只佔較小部分,這是兩種回收方式能高效處理的原因。 Mark-Sweep在老生代空間中標記後的示意圖如下,黑色部分標記為死亡的物體

未命名文件 (4).png

Mark-Sweep最大的問題是在進行一次標記清除空間後,記憶體空間會出現不連續的狀態。這種記憶體碎片會對後續的記憶體分配造成問題,需要分配一個大物件時,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。

Mark-Compact就是為了解決Mark-Sweep的記憶體碎片問題。 Mark-Compact是標記整理的意思,是在Mark-Sweep的基礎上演變而來的。差別在於物件在標記為死亡後,在整理的過程中,將活著的物件往一端移動,移動完成後,直接清除邊界外的記憶體。完成標記並移動存活對象後的示意圖,白色格子為存活對象,深色格子為死亡對象,淺色格子為存活對象移動後留下的空洞。

未命名文件 (5).png

完成移動後,就可以直接清除最右邊的存活物件後面的記憶體區域完成回收。

在V8的回收策略中,Mark-Sweep 和 Mark-Compact 兩者都是結合使用的。

3種主要垃圾回收演算法的簡單比較

回收演算法 Mark-Sweep Mark-Compact Scavenge
速度 #中 最慢 #最快
空間開銷 少(有碎片) #少(無碎片) 雙倍空間(無碎片)
是否移動物件 #是
#############是############

由于Mark-Compact需要移动对象,所以它的执行速度不可能很快,所以在取舍上,v8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact

  • Incremental Marking

    为了避免出现Js应用逻辑与垃圾回收器看到的不一致情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为称为“全停顿”(stop-the-world).在v8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但v8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善

    为了降低全堆垃圾回收带来的停顿时间,v8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”,就让Js应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。下图为:增量标记示意图

一文聊聊Node中的記憶體控制

v8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。 v8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入标记与并行清理,进一步利用多核性能降低每次停顿的时间。

1.5 查看垃圾回收的日志

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

node --trace_gc -e "var a = [];for (var  i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log

截屏2023-01-22 下午7.35.39.png

在Node启动时使用--prof参数,可以得到v8执行时的性能分析数据,包含了垃圾回收执行时占用的时间。以下面的代码为例

// test.js
for (var i = 0; i < 1000000; i++) {
    var a = {};
}

node --prof test.js

会生成一个v8.log日志文件

2. 高效使用内存

如何让垃圾回收机制更高效地工作

2.1 作用域(scope)

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

如下代码:

var foo = function(){
    var local = {};
}

foo()函数在每次被调用时会创建对应的作用域,函数执行结束后,该作用域会被销毁。同时作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁。只被局部变量引用的对象存活周期较短。在这个示例中,由于对象非常小,将会被分配在新生代中的From空间中。在作用域释放后,局部变量local失效,其引用的对象将会在下次垃圾回收时被释放

2.1.1 标识符查找

标识符,可以理解为变量名。下面的代码,执行bar()函数时,将会遇到local变量

var bar = function(){
    console.log(local);
}

js执行时会查找该变量定义在哪里。先查找的是当前作用域,如果在当前作用域无法找到该变量的声明,会向上级的作用域里查找,直到查到为止。

2.1.2作用域链

在下面的代码中

var foo = function(){
    var local = &#39;local var&#39;;
    var bar = function(){
        var local = &#39;another var&#39;;
        var baz = function(){
            console.log(local)
        };
        baz()
    }
    bar()
}
foo()

baz()函数中访问local变量时,由于作用域中的变量列表中没有local,所以会向上一个作用域中查找,接着会在bar()函数执行得到的变量列表中找到了一个local变量的定义,于是使用它。尽管在再上一层的作用域中也存在local的定义,但是不会继续查找了。如果查找一个不存在的变量,将会一直沿着作用域链查找到全局作用域,最后抛出未定义错误。

2.1.3 变量的主动释放

如果变量是全局变量(不通过var声明或定义在global变量上),由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。如果需要释放常驻内存的对象,可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清除和整理的过程中,会被回收释放。示例代码如下:

global.foo = "I am global object"
console.log(global.foo);// => "I am global object"
delete global.foo;
// 或者重新赋值
global.foo = undefined;
console.log(global.foo); // => undefined

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

2.2 闭包

作用域链上的对象访问只能向上,外部无法向内部访问。

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

var foo = function(){
   var bar = function(){
       var local = "局部变量";
       return function(){
           return local;
       }
   }
   var baz = bar()
   console.log(baz())
}

在bar()函数执行完成后,局部变量local将会随着作用域的销毁而被回收。但是这里返回值是一个匿名函数,且这个函数中具备了访问local的条件。虽然在后续的执行中,在外部作用域中还是无法直接访问local,但是若要访问它,只要通过这个中间函数稍作周转即可。

闭包是js的高级特性,利用它可以产生很多巧妙的效果。它的问题在于,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。

无法立即回收的内存有闭包和全局变量引用这两种情况。由于v8的内存限制,要注意此变量是否无限制地增加,会导致老生代中的对象增多。

3.内存指标

会存在一些认为会回收但是却没有被回收的对象,会导致内存占用无限增长。一旦增长达到v8的内存限制,将会得到内存溢出错误,进而导致进程退出。

3.1 查看内存使用情况

process.memoryUsage()可以查看内存使用情况。除此之外,os模块中的totalmem()和freemem()方法也可以查看内存使用情况

3.1.1 查看进程的内存使用

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

截屏2023-01-21 下午3.35.30.png

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

除了rss外,heapTotal和heapUsed对应的是v8的堆内存信息。heapTotal是堆中总共申请的内存量,heapUsed表示目前堆中使用中的内存量。单位都是字节。示例如下:

var showMem = function () {
  var mem = process.memoryUsage()
  var format = function (bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + &#39;MB&#39;;
  }
  console.log(&#39;Process: heapTotal &#39; + format(mem.heapTotal) +
    &#39; heapUsed &#39; + format(mem.heapUsed) + &#39; rss &#39; + format(mem.rss))
  console.log(&#39;---------------------&#39;)
}

var useMem = function () {
  var size = 50 * 1024 * 1024;
  var arr = new Array(size);
  for (var i = 0; i < size; i++) {
    arr[i] = 0
  }
  return arr
}

var total = []

for (var j = 0; j < 15; j++) {
  showMem();
  total.push(useMem())
}
showMem();

截屏2023-01-23 下午12.52.43.png

在内存达到最大限制值的时候,无法继续分配内存,然后进程内存溢出了。

3.1.2 查看系统的内存占用

os模块中的totalmem()和freemem()这两个方法用于查看操作系统的内存使用情况,分别返回系统的总内存和闲置内存,以字节为单位

截屏2023-01-23 下午1.10.54.png

3.2 堆外内存

通过process.memoryUsage()的结果可以看到,堆中的内存用量总是小于进程的常驻内存用量,意味着Node中的内存使用并非都是通过v8进行分配的。将那些不是通过v8分配的内存称为堆外内存

将上面的代码里的Array变为Buffer,将size变大

var useMem = function () {
  var size = 200 * 1024 * 1024;
  var buffer = Buffer.alloc(size); // new Buffer(size)是旧语法
  for (var i = 0; i < size; i++) {
    buffer[i] = 0
  }
  return buffer
}

输出结果如下:

截屏2023-01-23 下午1.29.00.png

内存没有溢出,改造后的输出结果中,heapTotal与heapUsed的变化极小,唯一变化的是rss的值,并且该值已经远远超过v8的限制值。原因是Buffer对象不同于其它对象,它不经过v8的内存分配机制,所以也不会有堆内存的大小限制。意味着利用堆外内存可以突破内存限制的问题

Node的内存主要由通过v8进行分配的部分和Node自行分配的部分构成。受v8的垃圾回收限制的只要是v8的堆内存。

4. 内存泄漏

Node对内存泄漏十分敏感,内存泄漏造成的堆积,垃圾回收过程中会耗费更多的时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。

在v8的垃圾回收机制下,大部分情况是不会出现内存泄漏的,但是内存泄漏通常产生于无意间,排查困难。内存泄漏的情况不尽相同,但本质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。通常原因有如下几个:

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

4.1 慎将内存当缓存用

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

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

Js开发者喜欢用对象的键值对来缓存东西,但这与严格意义上的缓存又有着区别,严格意义的缓存有着完善的过期策略,而普通对象的键值对并没有。是一种以内存空间换CPU执行时间。示例代码如下:

var cache = {}; 
var get = function (key) { 
 if (cache[key]) { 
     return cache[key]; 
 } else { 
 // get from otherwise 
 } 
}; 
var set = function (key, value) { 
 cache[key] = value; 
};

所以在Node中,拿内存当缓存的行为应当被限制。当然,这种限制并不是不允许使用,而是要小心为之。

4.1.1 缓存限制策略

为了解决缓存中的对象永远无法释放的问题,需要加入一种策略来限制缓存的无限增长。可以实现对键值数量的限制。下面是其实现:

var LimitableMap = function (limit) {
  this.limit = limit || 10;
  this.map = {};
  this.keys = [];
};
var hasOwnProperty = Object.prototype.hasOwnProperty;
LimitableMap.prototype.set = function (key, value) {
  var map = this.map;
  var keys = this.keys;

  if (!hasOwnProperty.call(map, key)) {
    if (keys.length === this.limit) {
      var firstKey = keys.shift();
      delete map[firstKey];
    }
    keys.push(key);
  }
  map[key] = value;
};
LimitableMap.prototype.get = function (key) {
  return this.map[key];
};
module.exports = LimitableMap;

记录键在数组中,一旦超过数量,就以先进先出的方式进行淘汰。

4.1.2 缓存的解决方案

直接将内存作为缓存的方案要十分慎重。除了限制缓存的大小外,另外要考虑的事情是,进程之间无法共享内存。如果在进程内使用缓存,这些缓存不可避免地有重复,对物理内存的使用是一种浪费。

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

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

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

4.2 关注队列状态

队列在消费者-生产者模型中经常充当中间产物。这是一个容易忽略的情况,因为在大多数应用场景下,消费的速度远远大于生产的速度,内存泄漏不易产生。但是一旦消费速度低于生产速度,将会形成堆积, 导致Js中相关的作用域不会得到释放,内存占用不会回落,从而出现内存泄漏。

解决方案应该是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应,通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。

5. 内存泄漏排查

常见的工具

  • v8-profiler: 可以用于对V8堆内存抓取快照和对CPU进行分析
  • node-heapdump: 允许对V8堆内存抓取快照,用于事后分析
  • node-mtrace: 使用了GCC的mtrace工具来分析堆的使用
  • dtrace:有完善的dtrace工具用来分析内存泄漏
  • node-memwatch

6. 大内存应用

由于Node的内存限制,操作大文件也需要小心,好在Node提供了stream模块用于处理大文件。

stream模块是Node的原生模块,直接引用即可。stream继承自EventEmitter,具备基本的自定义事件功能,同时抽象出标准的事件和方法。它分可读和可写两种。Node中的大多数模块都有stream的应用,比如fs的createReadStream()和createWriteStream()方法可以分别用于创建文件的可读流和可写流,process模块中的stdin和stdout则分别是可读流和可写流的示例。

由于V8的内存限制,我们无法通过fs.readFile()和fs.writeFile()直接进行大文件的操作,而改用fs.createReadStream()和fs.createWriteStream()方法通过流的方式实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入到另一个文件的过程:

var reader = fs.createReadStream(&#39;in.txt&#39;);
var writer = fs.createWriteStream(&#39;out.txt&#39;);
reader.on(&#39;data&#39;, function (chunk) {
  writer.write(chunk);
});
reader.on(&#39;end&#39;, function () {
  writer.end();
});

// 简洁的方式
var reader = fs.createReadStream(&#39;in.txt&#39;); 
var writer = fs.createWriteStream(&#39;out.txt&#39;); 
reader.pipe(writer);

可读流提供了管道方法pipe(),封装了data事件和写入操作。通过流的方式,上述代码不会受到V8内存限制的影响,有效地提高了程序的健壮性。

如果不需要进行字符串层面的操作,则不需要借助V8来处理,可以尝试进行纯粹的Buffer操作,这不会受到V8堆内存的限制。但是这种大片使用内存的情况依然要小心,即使V8不限制堆内存的大小,物理内存依然有限制。

更多node相关知识,请访问:nodejs 教程

以上是一文聊聊Node中的記憶體控制的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.cn。如有侵權,請聯絡admin@php.cn刪除