Table of Contents

1. 前言

本文是系列文章Node.JS Profile的一部分,完整的文章列表请去总章查看。

本文是Node内存相关文章的其中一篇,主要负责介绍内存相关的基础知识,及Node V8内存相关的一些理论知识,为读者打好基础方便后续内存实践相关知识点的理解。

2. 基础概念

Node进程的内存分为三大部分:

  • Code: the actual code being executed
  • Stack: contains all value types (primitives like integer or Boolean) with pointers referencing objects on the heap and pointers defining the control flow of the program
  • Heap: a memory segment dedicated to storing reference types like objects, strings and closures.

2.1 RSS

A running program is always represented through some space allocated in memory. This space is called Resident Set.

Node进程整体占用内存大小,也就是你在系统面板之类的地方看到的node进程的内存占用大小。

2.2 Heap

用户的程序基本上使用的就是这块内存,堆内存。我们需要关心的也只有这里的内存信息。

2.3 Heap Limit

node进程的堆大小是有上限的。在64位操作系统上,不带任何参数启动node,进程默认的堆大小是1.4G。所以有大量内存开销的应用程序必须非常注意这一点,否则会遇到:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

以下是一个简单的范例,如何查看堆内存,注意查看heap_size_limit字段的值。

这个堆大小可以用过v8参数flag--max-old-space-size来修改,单位是MB

遇到内存泄露的情况,也可以使用这个方法加大堆大小来争取时间。

3. GC

这部分的概念和技术要点主要阅读上文资料中阿里团队的两篇GC文章即可,即这篇1这篇2

阿里的文章分析的是v4的node,现在最新的LTS版本v8的node还是有部分调整的。最新内容可以看这篇官方的v8博客文章,撰于2017-11-29。

本文会梳理下大致的技术要点。下述内容全部假设读者有最基础的GC相关概念,部分名词解释会被忽略。

3.1 GC基础知识

  • GC回收的目标是从根节点开始,不可达(unreachable)的对象
  • GC触发:
    • 内存分配遇到内存不足
    • 内存使用量的阈值触发
  • allocation failure:内存分配失败,而启动的GC
  • last resort gc:两次allocation failure失败之后的最终GC,再失败则OOM
  • Stop-the-world:应用程序停顿,执行GC
    • 增量式 GC(incremental):程序不需要等到垃圾回收完全结束才能重新开始运行,在垃圾回收的过程中控制权可以临时交还给运行时进行一定的操作
      • 2011年即存在,主要发生在 Mark-Sweep/Mark-Compact 的 marking 阶段(incremental_marking_throughput)
    • 并发式 GC(concurrent):在垃圾回收的同时不需要停止程序的运行,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作
    • 并行式 GC(parallel):即在 GC 的时候使用多个线程一起来完成 GC 工作,提高单位时间的 GC 吞吐量
  • 准确式 GC (Accurate GC):通过底层设计的方法,能在GC的时候无需访问内容就判断出内存是指针还是整数,精确回收需要回收的内存

3.2 堆内存划分

3.2.1 内存页基础概念

  • 内存按照 1MB 分页,并且都按照 1MB 对齐
  • 新生代的内存页是连续的
  • 老生代的内存页是分散的,以链表的形式串联起来
  • Large Object Space 也分页,但页的大小会比 1MB 大一些

3.2.2 New Space(新生代)

  • 大部分的对象都属于新生代,诞生在这里
  • 使用 Scavenge 回收内存,新生代内存空间被平分成两半(两个 semispace),任一时刻只有一半被使用(空间换时间)
  • GC日志中看到的 new 和 semispace 相关的字段就与 New Space 有关

3.2.3 Old Space(老生代)

  • 对象大部分是从新生代(即 New Space)晋升而来
  • pretenuring机制:某些函数创建的对象有很高的存活率率(survival rate),经常晋升到老生代(存活超过2次)的时候,下次这些函数再创建的对象将会直接在 Old Space 分配
  • GC日志中看到的 old 相关的字段就与 Old Space 有关
  • survival 和 promoted 相关的字段则与对象在新老生代之间的迁移有关
  • 使用 Mark-Sweep-Compact 回收内存

3.2.4 Large Object Space(老生代)

  • 需要分配一个 1MB 的页(减去 header)无法直接容纳的对象时,就会直接在 Large Object Space 而不是 New Space 分配
  • GC时 Large Object Space 里的对象不会被移动或者复制(因为成本太高)
  • Large Object Space 属于老生代,使用 Mark-Sweep-Compact 回收内存

3.2.5 Map Space(老生代)

  • 存储对象布局结构
  • 使用 Mark-Sweep-Compact 回收内存

3.2.6 Code Space(老生代)

  • 编译器针对运行平台架构编译出的机器码(存储在可执行内存中)本身也是数据,连同一些其它的元数据(比如由哪个编译器编译,源代码的位置等),放置在 Code Space 中
  • JavaScript 代码中的函数一开始只会被解析成抽象语法树,只有在它第一次执行的时候才会被真正编译成机器码,并且在程序的执行过程中会根据统计数据不断进行优化和修改
  • 使用 Mark-Sweep-Compact 回收内存

3.2.7 堆空间页管理抽象:Memory Allocator

  • 与操作系统交互,当空间需要新的页的时候,它从操作系统手上分配(使用mmap)内存再交给空间
  • 当有内存页不再使用的时侯,它从空间手上接过这些内存,还给操作系统(使用munmap

3.2.8 堆外内存:External memory

  • 一般是C++插件,会自行管理内存,这部分内存就是非Node堆内部的内存,即堆外内存
  • 但Node语法中的Buffer,是自己管理内存的,即堆外内存

3.3 GC算法

3.3.1 新生代:Scavenge

Scavenge

  • 空间换时间
  • 将新生代内存空间内存一切为二,任一时刻只有一半(semispace)被使用
  • 每次触发新生代GC,则把存活的对象拷贝(memcpy)到另一半中,然后将需要清理的对象清理掉
  • Scavenge会Stop-the-world
  • Scavenge一般在0~3ms内,对应用不产生影响

写屏障(write barrier)

  • 用来解决从老生代到新生代的引用检索问题
  • 每次往一个对象写入一个指针(添加引用)的时候,都执行一段代码,这段代码会检查这个被写入的指针是否是由老生代对象指向新生代对象的
  • 如果是,则往store buffer中添加一条记录
  • 通过检索store buffer就能很快找到所有老生代到新生代的引用

3.3.2 老生代:Mark-Sweep/Mark-Compact

三色 marking

  • 三色:白、灰、黑
  • 堆内非根节点对象全部标白
  • 沿着根对象,将所有带引用的对象标黑,检索过程中先标黑
  • 扫描完毕后,堆内非根节点对象就只剩黑和白,黑色不可回收,白色可以回收
  • 因标记过程中对象会push进栈,而大对象则无法这么做,会走一个比较特殊的标记过程,因此大对象过多会显著影响GC效率
  • Marking后的回收分:SweepingCompacting

Sweeping

  • 找到死亡对象占用的连续区块,将这些块添加到随该页维护的一个 freelist 里
  • 这个数据结构保存了页上可用于下次分配的内存位置
  • V8 中按照可用内存块大小的区间分出了多个 freelist,这样能更快找到合适的可用内存

Compacting

  • 将页中的所有存活的对象都转移到另一页里(evacuation)
  • 存活对象都被移走了的那一页就可以直接还给操作系统
  • 主要发生在某一页中死亡对象留下来的空洞(hole)比较多的时候

优化:增量式 marking(incremental marking)

  • 将 marking 拆分开来,当堆大小涨到一定程度的时候,开始增量式 GC
  • 在每次分配了一定量的内存后/触发了足够多次写屏障后,就暂停一下程序,做几毫秒到几十毫秒的 marking,然后恢复程序的运行

优化:black allocation

  • v8 5.x 引入
  • 将所有新出现在 Old Space 的对象(包括pretentured 的分配或者晋升)直接标记为黑色,放在特殊的内存页(black page)中
  • 直接活过下一次GC

优化:lazy sweeping, concurrent sweeping, parallel sweeping

  • lazy sweeping
    • 已经标记完哪些对象的内存可以被回收之后,并没有必要马上回收完这些内存
    • 只有当所有页的内存都被回收完之后,才会重新开始 marking
  • concurrent sweeping
    • 让非程序线程的其他线程进行sweeping工作
  • parallel sweeping
    • 让多个sweeping线程同时工作

3.4 Orinoco: young generation garbage collection 2017-11-29

  • 新生代内存空间封顶16M(up to 16MiB)
  • 从M62号版本开始,v8开始使用parallel Scavenger算法回收新生代垃圾(Starting with M62, V8 switched the default algorithm for collecting the young generation to a parallel Scavenger)
  • Parallel Mark-Evacuate
    • 工作人员在新生代垃圾回收中试验了Parallel Mark-Evacuate算法(We experimented with a parallel Mark-Evacuate algorithm based on the V8’s full Mark-Sweep-Compact collector.)
    • 该算法借鉴了老生代的标记清除回收机制
  • Parallel Scavenge
    • Parallel Mark-Evacuate算法分离了扫描标记存活对象、拷贝存活对象、更新指针这几个步骤
    • Parallel Scavenge则是将上述几个步骤合而为一,进一步提升效率,也是M62版本开始v8使用的新生代垃圾回收算法
  • 该新算法减少了20%-50%的新生代垃圾回收时间(V8 now ships with the parallel Scavenger which reduces the main thread young generation garbage collection total time by about 20%–50%)

4. 资料

EOF