Home >Web Front-end >JS Tutorial >An article about memory control in Node

An article about memory control in Node

青灯夜游
青灯夜游forward
2023-04-26 17:37:052269browse

The Node service built based on non-blocking and event-driven has the advantage of low memory consumption and is very suitable for processing massive network requests. Under the premise of massive requests, issues related to "memory control" need to be considered.

An article about memory control in Node

1. V8’s garbage collection mechanism and memory limitations

Js uses a garbage collection mechanism to perform automatic memory management, and developers do not need to do it like other languages ​​( c/c), always pay attention to the allocation and release of memory during the process of writing code. In browsers, the garbage collection mechanism has little impact on application performance, but for performance-sensitive server-side programs, the quality of memory management and the quality of garbage collection will have an impact on services. [Related tutorial recommendations: nodejs video tutorial, Programming teaching]

1.1 Node and V8

Node is a Js runtime built on Chrome On the platform, V8 is the Js script engine of Node

1.2 Memory limitations of V8

In general back-end languages, there are no restrictions on basic memory usage, but in Node When Js uses memory, it can only use part of the memory. Under such restrictions, Node cannot directly operate large memory objects.

The main reason for the problem is that Node is built based on V8. The Js objects used in Node are basically allocated and managed through V8's own method.

1.3 V8 object allocation

In V8, all Js objects are allocated through the heap.

Check the memory usage in v8

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

heapTotal and heapUsed are the heap memory usage of V8. The former is the heap memory that has been applied for, and the latter is The amount currently used.

When a variable is declared and assigned a value in code, the memory of the object used is allocated on the heap. If the applied heap free memory is not enough to allocate new objects, heap memory will continue to be applied until the heap size exceeds the limit of V8

Why does V8 limit the size of the heap? The superficial reason is that V8 was originally designed for browsers As for the design, it is unlikely to encounter scenarios that use a large amount of memory. For web pages, V8's limit value is more than enough. The underlying reason is the limitation of V8's garbage collection mechanism. According to the official statement, taking 1.5G of garbage collection heap memory as an example, it takes more than 50 milliseconds for v8 to do a small garbage collection, and even more than 1 second to do a non-incremental garbage collection. This is the time during garbage collection that causes the JS thread to pause execution, and the application's performance and responsiveness will plummet. Directly limiting the heap memory is a good choice under the current considerations.

This restriction can be relaxed. When Node is started, you can pass --max-old-space-size or --max-new-space-size To adjust the size of the memory limit, once it takes effect, it cannot be changed dynamically. Such as:

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

1.4 V8’s garbage collection mechanism

Various garbage collection algorithms used in v8

1.4.1 V8’s main garbage collection algorithm

v8's garbage collection strategy is mainly based on the generational garbage collection mechanism.

In actual applications, the life cycle of objects varies. In the current garbage collection algorithm, memory garbage collection is performed in different generations according to the survival time of the object, and a more efficient algorithm is applied to the memory of different generations.

  • V8 memory generation

    In v8, the memory is mainly divided into two generations: the new generation and the old generation. Objects in the young generation are objects with a short survival time, while objects in the old generation are objects with a long survival time or are resident in memory.

    Memory space of the new generation Memory space of the old generation

    The overall size of the v8 heap = the memory space used by the new generation and the memory space used by the old generation

    The maximum value of the v8 heap memory can only use about 1.4GB of memory under 64-bit systems and only about 1.4GB under 32-bit systems. Can use about 0.7GB of memory

  • Scavenge algorithm

    On the basis of generations, objects in the new generation are mainly garbage collected through the Scavenge algorithm. The specific implementation of Scavenge mainly uses the Cheney algorithm. Cheney's algorithm is a garbage collection algorithm implemented by copying. Divide the heap memory into two parts, and each part of the space is called semispace. Of the two semispaces, only one is in use and the other is idle. The semispace space in use is called From space, and the space in idle state is called To space. When allocating an object, it is first allocated in the From space. When garbage collection starts, the surviving objects in the From space will be checked. These surviving objects will be copied to the To space, and the space occupied by non-surviving objects will be released. After the copying is completed, the roles of From space and To space are reversed. In short, during the garbage collection process, surviving objects are copied between two semispaces. The disadvantage of Scavenge is that it can only use half of the heap memory, which is determined by the partitioning space and copy mechanism. However, Scavenge has excellent performance in terms of time efficiency because it only copies surviving objects, and only a small portion of surviving objects are used in scenarios with short life cycles. Since Scavenge is a typical algorithm that sacrifices space for time, it cannot be applied to all garbage collections on a large scale. But Scavenge is very suitable for application in the new generation, because the life cycle of objects in the new generation is short and suitable for this algorithm. An article about memory control in NodeThe actual heap memory used is the sum of the two semispace spaces in the new generation and the memory size used in the old generation.

When an object still survives after multiple copies, it will be considered an object with a long life cycle, and will be moved to the old generation and managed using a new algorithm. The process of moving objects from the young generation to the old generation is called promotion.

In the simple Scavenge process, the surviving objects in the From space will be copied to the To space, and then the roles of the From space and the To space will be reversed (flip). However, under the premise of generational garbage collection, surviving objects in the From space need to be checked before being copied to the To space. Under certain conditions, objects with long survival periods need to be moved to the old generation, that is, object promotion is completed.

There are two main conditions for object promotion. One is whether the object has experienced Scavenge recycling, and the other is that the memory usage ratio of the To space exceeds the limit.

By default, V8’s object allocation is mainly concentrated in the From space. When an object is copied from the From space to the To space, its memory address will be checked to determine whether the object has experienced a Scavenge recycling. If it has been experienced, the object will be copied from the From space to the old generation space. If not, it will be copied to the To space. The promotion flow chart is as follows:

未命名文件 (1).png

#Another judgment condition is the memory usage ratio of To space. When copying an object from the From space to the To space, if the To space has been used 25%, the object will be directly promoted to the old generation space. The promotion flow chart is as follows:

未命名文件 (2).png

The reason for setting the 25% limit: When this Scavenge recycling is completed, this To space will become From space, and the next memory allocation will be carried out in this space. If the ratio is too high, it will affect subsequent memory allocation.

After the object is promoted, it will be treated as an object with a longer survival period in the old generation space and will be processed by the new recycling algorithm.

  • Mark-Sweep & Mark-Compact

    For objects in the old generation, since surviving objects account for a large proportion, there are two problems if you use Scavenge: One is that there are many surviving objects, and the efficiency of copying surviving objects will be very low; the other problem is that half of the space is wasted. To this end, v8 mainly uses a combination of Mark-Sweep and Mark-Compact for garbage collection in the old generation.

    Mark-Sweep means mark clearing, which is divided into two stages: marking and clearing. Compared with Scavenge, Mark-Sweep does not divide the memory space into two halves, so there is no behavior of wasting half of the space. Unlike Scavenge, which copies live objects, Mark-Sweep traverses all objects in the heap during the marking phase and marks the living objects. In the subsequent clearing phase, only unmarked objects are cleared. It can be found that Scavenge only copies living objects, while Mark-Sweep only cleans up dead objects. Live objects only account for a small part of the new generation, and dead objects only account for a small part of the old generation. This is the reason why the two recycling methods can handle it efficiently. The schematic diagram after Mark-Sweep is marked in the old generation space is as follows. The black parts are marked as dead objects

未命名文件 (4).png

The biggest problem with Mark-Sweep is that after marking and clearing the space, the memory space will become discontinuous. This kind of memory fragmentation will cause problems for subsequent memory allocation. When a large object needs to be allocated, all the fragmented space will not be able to complete the allocation, and garbage collection will be triggered in advance, and this recycling is unnecessary.

Mark-Compact is to solve the memory fragmentation problem of Mark-Sweep. Mark-Compact means mark compilation, which evolved from Mark-Sweep. The difference is that after the object is marked as dead, during the cleaning process, the living objects are moved to one end. After the movement is completed, the memory outside the boundary is directly cleared. Schematic diagram after marking and moving living objects. The white grid is the living object, the dark grid is the dead object, and the light grid is the hole left after the living object is moved.

未命名文件 (5).png

After completing the move, you can directly clear the memory area behind the rightmost surviving object to complete recycling.

In V8’s recycling strategy, Mark-Sweep and Mark-Compact are used in combination.

A simple comparison of three major garbage collection algorithms

Recycling algorithm Mark-Sweep Mark-Compact Scavenge
Speed Medium Slowest Fastest
Space overhead Less (with fragmentation) Less (no fragmentation) Double space (no fragmentation)
Whether to move the object No Yes Yes

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

  • Incremental Marking

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

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

An article about memory control in 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 教程

The above is the detailed content of An article about memory control in Node. For more information, please follow other related articles on the PHP Chinese website!

Statement:
This article is reproduced at:juejin.cn. If there is any infringement, please contact admin@php.cn delete