kd-cloud-web / Blog

一群人, 关于前端, 做一些有趣的事儿
13 stars 1 forks source link

从nodejs内存到v8垃圾处理 #66

Open zzkkui opened 2 years ago

zzkkui commented 2 years ago

从nodejs内存到v8垃圾处理

nodejs 内存

nodejs内存溢出

有时候我们运行node程序会碰到如下问题

Image_20211118145306

很显然内存溢出了,更详细一点是 V8 内存溢出了。在操作系统还有足够内存时发生了内存溢出,就让人很疑惑。

nodejs 内存指标

我们可以通过nodejs的process.memoryUsage()来查看内存状态

可以运行下面代码查看nodejs进程中的内存使用状态

const showMem = () => {
  const mem = process.memoryUsage()
  const format = (bytes) => {
    return bytes ? (bytes / 1024 / 1024).toFixed(2) + 'MB' : ''
  }
  console.log(`Process: heapTotal ${format(mem.heapTotal)}  heapUsed ${format(mem.heapUsed)}  rss ${format(mem.rss)}  external ${format(mem.external)}  arrayBuffers ${format(mem.arrayBuffers)}`)
}

const useMem = () => {
  const size = 20 * 1024 * 1024
  const arr = new Array(size)
  for (let i = 0; i < size; i++) {
    arr[i] = 0
  }
  return arr
}

const total = []
for (let i = 0; i < 30; i++) {
  showMem()
  total.push(useMem())
}

showMem()

image

上面的执行结果图中,heapTotal,headUsed 和 rss 越来越大,直到最后又溢出了。这里操作 nodejs 对内存有限制,或者更准确的说是 V8 堆内存有限制。并且也说明nodejs内存是根据需要申请内存的,而不是一下子申请极限内存。

然后我们通过 v8.getHeapStatistics().heap_size_limit 可以看到nodejs中堆内存的限制。以下是各个版本的nodejs的堆内存限制

(nodejs 堆内存限制的大小越来越大,这其实是得益于v8的升级进化。毕竟 v8 是为 chrome(浏览器)开发的引擎,而 v8 的放开限制,就是因为电脑配置的提升。)

这里列的都是 64 位系统的限制,32 位系统基本是减半的大小

但是我们把上面示例代码的 Array 改成 Buffer 后,再执行代码。

// ***
const useMem = () => {
  const size = 500 * 1024 * 1024
  const buffer = new Buffer(size)
  for (let i = 0; i < size; i++) {
    buffer[i] = 0
  }
  return buffer
}
// ***

image

可以看到 heapTotalheapUsed 几乎没有变化,但是 rssexternal 属性越来越大,直到程序运行结束也没有发生上述的内存溢出错误。这是因为 buffer 使用的内存并不是 V8 堆中的内存,而是使用的堆外内存

nodejs突破堆内存限制

nodejs 是可以打破堆内存限制的

注意 --max-semi-space-size 是有两块

例子:

// 在 node 启动时设置,一旦生效不能再修改(启动后)
node --max-old-space-size=1700 test.js  //单位为MB
node --max-semi-space-size=24 test.js  //单位为MB

通过 node --v8-options > log.log 查看配置项

nodejs 内存总结

nodejs 内存主要是由V8堆内存和堆外内存组成,其中v8堆内存受限于V8的内存限制导致nodejs在内存上也有一定的限制。

虽然nodejs在v8堆内存上有限制,但是在堆外内存是没有限制的,比如 buffer 就是用的堆外内存

nodejs也提供了选项来突破这个内存限制(事实上是ā额选项)

v8内存限制

限制内存的原因

更直接的原因其实就是 v8 的垃圾回收机制

垃圾回收策略

原理:定期找到那些不再用到的内存,然后释放

这里需要注意的是,这里说的垃圾回收,只是针对堆内存中的数据,栈内存(调用栈)会在程序的执行过程中就会被销毁

存在循环引用问题:如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o  这里

  return "azerty";
}

f();

实际的例子

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};

变量 div 有事件处理函数的引用,同时事件处理函数内部也有 div 的引用(可以访问到 div 变量)

目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异

问题:清除之后,剩余的对象内存位置是不变的,导致内存空间是不连续的,会出现内存碎片。

v8垃圾回收机制

v8的垃圾回收策略主要是基于分代式垃圾回收机制:按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存进行更高效的算法。根据对象的生存周期长短不一,而使用不同的算法,以达到最好的效果。

这里我们要了解一个概念:全停顿
全停顿:JavaScript 是一门单线程的语言,它是运行在主线程上的,那在进行垃圾回收时就会阻塞 JavaScript 脚本的执行,需等待垃圾回收完毕后再恢复脚本执行

所以垃圾回收持续时间越长,性能,体验就会越差

v8 的内存分代

v8中主要将内存分为新生代和老生代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象

SB)_OQ(LQ{S8(J``)%RMJZD

这里的老生代,新生代其实就是对应之前nodejs突破内存限制的选项 --max-old-space-size--max-semi-space-size

新生代垃圾回收

新生代内存区很小,大多数对象开始都会被分配在这里,这个区域小但是垃圾回收特别频繁,大概就是几十M(以nodejs v10*为例,64位系统为 32M,32位系统为16M)。这里主要通过 Scavenge 算法(内部主要才用 Cheney 算法实现)实现

Scavenge算法是一种采用复制的方式实现的垃圾回收算法。当开始进行垃圾回收的时候,新生代垃圾回收器会对 from 区中的对象做标记,编辑完成之后将 from 区的活动对象复制进 to 区并进行排序(排序的过程其实就是做了整理的过程,所以就不会有碎片),随后进入垃圾清理阶段。最后进行角色互换,把原来的适用于 from 变成空闲区 to,把原来的空闲区 to 变成使用区 from

clipboard

这也是为什么刚才说 --max-semi-space-size 有两个。

这是一种典型的空间换时间的算法,在垃圾回收过程主要就是将存活对象在From空间和To空间之间进行复制,同时完成两个空间之间的角色互换,因此该算法的缺点也比较明显,浪费了一半的内存用于复制。如果老生代内存(大)使用这种算法,会造成内存资源的浪费。

当一个对象经过两次复制(李兵的浏览器原理专栏介绍)后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中。

另外,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象(经历过Scavenge算法)会被直接晋升到老生代空间中,设置为 25% 的比例的原因是:当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配

老生代垃圾回收

老生代内存采用算法Mark-Sweep(标记清除)和Mark-Compact(标记整理)来进行管理。

通过上面对标记清除的了解,我们知道标记清除是有问题的:清除之后,剩余的对象内存位置是不变的,导致内存空间是不连续的,会出现内存碎片。如果新进来一个对象很大,它需要在这些内存碎片中找到适合自身大小的内存块,所以分配内存也是很耗时。

这里的标记整理就是为了解决这一问题的。但是在整理内存碎片的过程中,会涉及到一个排序(将内存对象都往一边移动),所以效率会比较低。造成全停顿的时间就更多

V8 在进行垃圾回收的时候也是结合这两点问题,做了一个策略:并不是所有的标记清除后都会立马进行标记整理。正常情况下以标记清除为主,当老生代空间不足存放从新生代晋升过来的对象时,会采用标记整理进行空间的整理。

除了标记整理之外,v8 还对垃圾回收做了一些优化策略

v8垃圾回收优化

增量标记

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记(如下图)。类似于React的fiber架构。

clipboard

但是增量标记会有两个问题:

针对上述两个问题,v8引入了三色标记法
三色标记法: 如下图所示,最初所有的对象都是白色,意味着回收器没有标记它们,从一组根对象开始,先将这组根对象标记为灰色并推入到标记工作表中,当回收器从标记工作表中弹出对象并访问它的引用对象时,将其自身由灰色转变成黑色,并将自身的下一个引用对象转为灰色,就这样一直往下走,直到没有可标记灰色的对象时,也就是无可达(无引用到)的对象了,那么剩下的所有白色对象都是无法到达的,即等待回收(如上图中的 C、E 将要等待回收)

image

采用三色标记法后我们在恢复执行时就好办多了,可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行就可以

而且v8再通过写屏障策略来防止应用程序将标记好的对象关系修改导致的错误回收

所谓写屏障就是一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 标记阶段可以正确标记,这个机制也被称作强三色不变性

惰性回收

增量标记完成后,此时所有对象都已被标记是否被回收,然而不必一次全部,可以采用延迟清理的处理手段,垃圾回收器可以根据需要逐一清理死对象所占用的内存页

并行回收

在执行垃圾回收的过程中同时开启多个辅助线程来进行垃圾清理的工作。

image

并发回收

主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作

image

辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,这是并发的优点。但它需要考虑主线程在执行 JavaScript 时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些读写锁机制来控制这一点

参考文档: