引言

在计算机科学中,内存管理涉及到对新生成对象的内存分配和无引用对象的垃圾回收。当为新生成对象分配内存空间时,可能会因为没有足够的大小而触发一次 GC,通过对其他对象的回收来收集空间满足本次分配,或是向系统请求更大的内存空间,若此时操作系统的物理内存和交换空间不足以满足时,就会触发 OOM。由此可见,OOM 和 GC 是内存管理始终绕不开的话题,本文将从一次和 rabbitmq GC 相关的 OOM 来聊聊垃圾回收算法,同时针对如何避免 rabbitmq OOM 给出了一些建议。

背景

这得从上段工作经历说起,我们线上有个丐中丐版的 rabbitmq 集群,每个 rabbitmq 节点有 8GB 内存,一共 3 个节点,mirroed queue 配置为 all。这里不得不提一嘴 mirrored queue 是啥,说白了也就是主从复制,每份数据除了主节点有以外,其余的从节点也会复制一份,为 all 就意味着每份数据所有的集群节点都会保留一份。看到这里,你估计也明白了这个丐中丐集群实际可写入的容量也就最大 8GB,这还不算 rabbitmq 的 high memory watermark 机制,这又是啥?这相当于一个熔断开关,当 rabbitmq 的内存达到 high memory watermark 阈值时,就会阻塞生产者继续写入。
按照 rabbitmq 的官方文档来说,这个值最大设置成 0.4 会比较安全,大了就会出问题。相当于我 24GB 的内存实际可用的也就 3.2 GB 左右,为了给公司创收,我们于是头铁调成 0.5。没成想,最终线上 OOM 了。为什么最大只能 0.4 呢?

High memory watermark blocks publishers and prevents new messages from being enqueued. Since garbage collection can double the memory used by a queue, it is unsafe to set the high memory watermark above 0.5. The default high memory watermark is set to 0.4 since this is safer as not all memory is used by queues. This is entirely workload specific, which differs across RabbitMQ deployments.

根据 rabbitmq 的官方文档我们可以看出,在 GC 的过程中,单个队列的内存会翻倍,同时因为 rabbitmq 还有其他非队列对象会占用内存。但是 GC 为什么会导致内存翻倍并最终 OOM 呢?

为什么会 OOM?

什么是 OOM?

Out of memory (OOM) is an often undesired state of computer operation where no additional memory can be allocated for use by programs or the operating system. Such a system will be unable to load any additional programs, and since many programs may load additional data into memory during execution, these will cease to function correctly. This usually occurs because all available memory, including disk swap space, has been allocated.

内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。造成这样的原因通常有两种,一种是长期保持某些对象的引用,导致垃圾回收器不能及时回收,也叫内存泄漏。另外一种是当需要保存多个耗用内存过大或当加载单个超大的对象时,该对象的大小超过了当前剩余的可用内存空间。

Rabbitmq 的垃圾回收机制

Rabbitmq 是运行 Erlang 虚拟机上的,Erlang 虚拟机负责了 rabbitmq 的垃圾回收工作。熟悉 Erlang 的同学应该知道,Erlang 虚拟机上会同时运行多个进程(每个进程对应于 rabbitmq 的一个队列),每个进程都会有自己的私有堆空间和栈空间,多个进程之间同时共享一个堆空间,共享堆空间用于保存大于 64 bytes 的二进制对象,各进程私有堆通过指针指向引用。进程内的内存空间包括 PCB,栈以及私有堆。

  • PCB:进程控制块存储程序ID(PID),进程状态等信息;
  • 栈:是一块向下增长的内存区域,存储出入参,函数回调地址以及本地变量等;
  • 私有堆:是一块向上增长的内存区域,存储进程消息以及对象。大于 64 bytes 的对象存储在共享堆空间中;
    当进程内栈空间和私有堆空间相遇时(栈空间和私有堆空间是相向扩张的),就会触发一次 GC。

image.png

每个私有堆都有一个分代半空间复制回收器,而共享二进制堆则是基于引用计数算法进行垃圾回收。

复制回收器

Erlang 为私有堆提供一个复制回收器。在垃圾回收过程中,对象将会从一个区域复制到另一个新的区域。整个私有堆空间被划分为 From 空间和 To 空间。回收器从根节点开始(栈以及寄存器等),将其所有指向的引用复制到 To 空间。

image.png
在根节点的所有引用被复制到 To 空间以后,从 scan start pointer 开始,将 To
空间对象引用的对象也复制到 To 空间中。
image.png
重复上述过程,直到 scan start pointer 和 scan stop pointer 重合,GC 过程则结束。当 GC 结束以后,From 空间变成新的 To 空间,而 To 空间则变成 From 空间。
image.png

分代半空间复制回收器

除了提供上述回收算法以外,Erlang 同时支持分代回收算法。整个内存空间会被划分成 From, To 和 Old。Old 空间(以下简称老年代)用来存放存活时间长的对象。而新分配的对象则存放在 To 空间(以下简称新生代)。分代垃圾回收基于大多数新生成的对象生命周期很短,更应该被频繁地回收的假设。分代能够避免对整个堆空间的过度 GC。
image.png
相比于复制回收算法不同的点在于每次 GC 结束的时候对应的内存空间地址记为 high watermark, high watermark 之下的对象在 GC 过程中将被复制到老年代空间内。
image.png
当针对新生代空间运行 N 次回收或者手动调用 Erlang:garbage_collect(),将会触发 fullsweep, fullsweep 将同时复制新生代和老年代对象至一个新的 To 空间内,复制完成后原新生代和老年代空间将被释放。也就是这时,整个私有堆所占用的内存空间会翻倍。

垃圾回收算法

上述提到的 rabbitmq 的垃圾回收算法算是多种简单垃圾回收算法的组合体,下面我们将一起聊聊常见的垃圾回收算法。并且我们将从吞吐量,最大暂停时间,堆使用效率和访问的局部性四个标准评价这些垃圾回收算法。

  • 吞吐量: 单位时间的处理能力,计算公式为 HEAP_SIZE/GC 花费的时间;
  • 最大暂停时间:是指因为执行 GC 而暂停运行应用程序的最长时间;
  • 访问的局部性:具有引用关系的对象之间通常很可能存在连续访问的情况,通过将此类对象在内存中连续排列可以达到加快运用程序运行效率的效果;
  • 堆使用效率:影响堆使用效率有两个因素,一个是对象头部大小,一般对象头部用于记录对象的类型,对象的空间大小以及标志位,另一个是堆空间的用法;

标记-清除算法

标记-清除算法是由标记和清除两个阶段构成,并且每个对象都有1 bit 的标志位。在标记阶段,从根节点(栈与寄存器等)出发,标记所有其所引用对象的标志位。

image.png

image.png

在标记阶段,回收器会标记所有的活动对象。在回收阶段,回收器会遍历所有的未标记对象,回收其所占用的内存空间。同时,回收器会清除所有活动对象上的标记,以便于继续下一个 GC 循环。

image.png

image.png

清除阶段结束后的堆状态

优点:
  • 实现简单
  • 在 GC 的过程中不会改变对象之间的相对顺序
缺点:
  • 产生大量的内存碎片:大量的内存碎片会导致之后的大对象内存分配仍然得不到满足,从而频繁触发 GC

引用计数法

引用计数法会在每个对象上记录其所被引用的次数。当计数器的值为0时,此对象即可被回收。

优点:
  • 垃圾可被即刻回收:因为每个对象都维护了一个引用计数器,所以当计数为 0 时,对象可立即被作为垃圾回收,整个内存空间不会变成垃圾堆;
  • 最大暂停时间短:如前所述,因为对象在变成垃圾的同时就被回收,大幅削减了最大暂停时间;
缺点:
  • 计数器值的增减处理繁重
  • 计数器需要占用很多位:用于引用计数的计数器最大必须能数完堆中所有对象的引用数;
  • 实现烦琐复杂
  • 循环引用无法回收

复制算法

复制算法是利用 From 空间进行分配的。当 From 空间被完全占满时,GC 会将活动 对象全部复制到 To 空间。当复制完成后,该算法会把 From 空间和 To 空间互换,GC 也就结束了。

image.png

优点:
  • 优秀的吞吐量:GC 标记 - 清除算法消耗的吞吐量是搜索活动对象(标记阶段)所花费的时间和搜索整体堆(清除阶段)所花费的时间之和。 另一方面,因为 GC 复制算法只搜索并复制活动对象,所以跟一般的 GC 标记 - 清除算法相比,它能在较短时间内完成 GC。也就是说,其吞吐量优秀;
  • 高速分配
  • 不会发生碎片化
  • 访问的局部性好:将互相引用的对象放置在内存中连续的区域;
缺点:
  • 堆使用效率低下;
  • 递归调用函数:复制某个对象时要递归复制它的子对象。因此在每次进行复制的时候都要调用函数;

标记-压缩算法

标记 - 压缩算法由标记阶段和压缩阶段构成。标记阶段和标记-清除算法中的标记阶段完全一致,用于标记所有的活动对象,而压缩阶段通过数次搜索堆来重新装填活动对象。
压缩阶段一般由 3 个阶段组成:

  1. 设定 forwading 指针:搜索整个堆,为活动对象设置 forwading 指针,使其指向新空间的地址;

image.png

  1. 更新指针: 搜索整个堆,更新各个对象其所引用对象的地址;

image.png

  1. 移动对象:搜索整个堆,将对象移动到 forwading 指针对应的地址;

image.png

压缩阶段结束后堆的状态

image.png

优点:
  • 堆的利用效率高
缺点:
  • 压缩过程计算成本高:标记 - 清除算法中,清除阶段也要搜索整个堆,不过搜索 1 次就够了。但 GC 标记 - 压缩算法要搜索 3 次,这样就要花费约 3 倍的时间;

分代算法

分代在对象中引入年龄的概念,通过优先回收容易成为垃圾的对象,提高垃圾回收的效率。分代算法将整个堆空间划分为多个区域,一般包括新生代和老年代。我们把发生在新生代上的 GC 叫做 minor GC。在新生代上经过数次 GC 之后存活下来的对象会被晋升到老年代中。老年代中 GC 的频率相较于新生代更少。值得一提的是,分代算法不能单独使用,必须和前面提到的垃圾回收算法结合使用,从而提高前述垃圾回收算法的效率。
下面将简单介绍 David Ungar 研究出来的把 GC 复制算法和分代垃圾回收这两者组合运用的方法。

image.png
在 Ungar 的回收算法中,我们总共把堆空间分成 4 个区域,包括一个生成空间,两个幸存空间和一个老年代空间。我们将生成空间和幸存空间合称为新生代空间。新生代对象会被分配到新生代空间,老年代对象则会被分配到老年代空间里。
生成空间就如它的字面意思一样,是生成对象的空间,也就是进行分配的空间。当生成 空间满了的时候,新生代 GC 就会启动,将生成空间中的所有活动对象复制。2 个幸存空间和 GC 复制算法里的 From 空间、To 空间很像,我们经常只利用其中的一个。 在每次执行新生代 GC 的时候,活动对象就会被复制到另一个幸存空间里。在此我们将正在使用的幸存空间作为 From 幸存空间,将没有使用的幸存空间作为 To 幸存空间。只有从一定次数的新生代 GC 中存活下来的对象才会得到晋升,也就是会被复制到老年 代空间去。

image.png
老年代空间的 GC 过程也如新生代 GC 一样,是前述普通 GC 算法的应用,但是因为老年代空间一般较小, GC 一般不使用复制算法(复制算法的堆利用效率较低)。

优点:
  • 吞吐量较好:综合来看,因为基于新生成的对象更易成为垃圾的假设,总体 GC 的锁花费的时间有所减少
缺点:
  • 在部分程序中可能起到反作用:分代假设只适用于大部分情况,极少数情况下分代假设反而可能导致更频繁的新生代与老年代 GC

增量式算法

为了避免因为 GC 而导致的应用程序 STW(Stop the world)时间过长,由此衍生出增量式算法。增量式算法主要通过将 GC 过程和应用程序交替运行来达到目的。

image.png

三色标记法

Edsger W. Dijkstra 等人提出三色标记法,是将 GC 中的对象按照各自的情况分成三种,这三种颜色和所包含的意思分别如下所示。

  • 白色:还未搜索过的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色:正在搜索的对象,尚未将所有子对象访问完毕,因此其子对象可能指向白色对象。
  • 黑色:搜索完成的对象。其所有子对象皆已被扫描。黑色对象的所有指针都不可能指向白色对象。

下面将向大家简单介绍增量式的标记-清楚算法。增量式的标记-清楚算法可分为三个阶段:

  • 根查找阶段
  • 标记阶段
  • 清除阶段
    我们在根查找阶段把能直接从根引用的对象涂成灰色。在标记阶段查找灰色对象,将其子对象也涂成灰色,查找结束后将灰色对象涂成黑色。在清除阶段则查找堆,将白色对象释放,黑色对象变回白色对象,进入下一轮 GC。那增量体现在哪里呢?在标记阶段执行标记动作一定次数后,会暂停 GC,再次执行应用程序。这样就能大大缩短最大暂停时间。在应用程序重新运行的时候,对象之间的相互引用关系可能会发生改变。

image.png
(a)是回收器暂停前的状态,此时 A 被涂成了黑色,B 被涂成了灰色,接下来应该继续沿着 B 继续查找并标记了。当回收器被暂停,应用程序恢复运行,A 对象的引用更改为了 C 对象,然后删除 B 对 C 的引用。如果重新运行回收器会发生什么呢?C 对象不会被标记,最终会被释放。这是因为此时唯一引用 C 对象的 A 已经被标记为黑色,标记阶段只会沿着灰色对象继续查找活动对象。
为了防止这种情况,写入屏障应运而生。

写入屏障

image.png
写入屏障是一种契约机制,它保障了对内存的操作不会违背三色不变性。拿前面的例子而言,当 A 的引用更新为 C 时,C 对象此时也会被标记为灰色。

  • 强三色不变形:黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径;
优点:
  • 缩短了最大暂停时间(STW)
缺点:
  • 降低了吞吐量

如何避免 Rabbitmq OOM?

前面我们已经了解了为什么 rabbitmq 在 GC 的过程中会内存翻倍并 OOM,根本原因是在老年代 GC 的过程中触发了整个私有堆空间的内存复制。针对这种情况,我们可以如何避免呢?
避免消息积压, 消息积压会影响 rabbitmq 的性能,进而导致 rabbitmq 吞吐量减小。当消息积压过多时,甚至可能会引发 OOM。
如果消息积压严重,可以考虑开启 lazy queues。从 rabbitmq 3.6.0 版本,rabbitmq 支持 lazy queues。开启 lazy queues 功能后,rabbitmq server 在收到消息后会迅速将消息持久化到磁盘上,在消息被消费的时候再载入内存,这样能够减缓内存压力。
设置 high memory watermark 在 0.4 左右,如前所述,rabbitmq 在 GC 的过程中可能会导致内存翻倍,high memory watermark 可以在内存达到阈值后阻断生产者继续发布消息,避免内存过度增长。
使用 TTL 或者 max-length 配置控制队列长度,当队列达到指定长度或消息在 TTL 时间内未被及时消费时,rabbitmq 会将这部分消息写入死信队列或是直接丢弃。
除此以外,还可以考虑使用 kafka 等以外的消息队列组件,相较于 rabbitmq, kafka 以磁盘作为消息存储的载体,能够很大程度上避免因为消息积压而导致 OOM 的情况。

参考