使用堆外内存优化服务           

本文记录一次使用堆外内存来进行服务优化的经验。

笔者曾负责维护过一个服务,开始之前,可用性在2个9的水平,如图所示:


分析发现,服务存在比较严重的gc问题。上游给我们的耗时限制是150ms,但是有大量超过100ms的gc,如下图所示:

    [rd@c3-ai-prod-star01 logs]$ grep -irn 'Total time for which application threads were stopped' gc-20210625142528.log.1.current 
        | grep "2021-06-30T21" | awk  '$11 > 0.1 {print $0}' | wc -l
    149

这是随机统计一次,一个小时中STW 超过100ms的gc就有149次,更不计那些短时间的gc。分析gc日志,如下所示:

可以发现固定有12.2G的对象一直在老年代,要么是没有被回收,要么是常驻内存。dump内存找到这些对象,和对应的开发同学沟通,是服务启动的时候就会创建好,之后一直存活。堆内存一共24G,常驻内存就一直占用12G,导致ygc的时间变长。我分析这里面原因有两个:

  1. 老年代东西太多,老年代空间得不到释放,新生到分配不到更多的空间,导致一些可以在年轻代回收的对象在晋升到老年代
  2. 老年代东西太多,影响ygc的标记过程,因为标记要对全内存进行操作

究竟是什么对象一直占着内存呢,dump内存进行分析,如图所示:

可以发现主要是TokenDomainFrequency和Trie对象占据内存偏多。这里顺便提到一个小技巧,绝大多数gc问题都是我们的业务代码不规范引起导致的,所以我们应该重点关注对象数最多、占空间最大的业务对象,而不是类似于char、Map这种java原生对象。另外dump分析这两个一种只占了1.6G,但是gc日志显示却占用了12G,那是因为这里的大小只表示对象本身的大小,对于对象属性所关联的对象大小不计算在内,这也是我们分析内存的时候,总是看到最多的都是char、Map这种。

这种场景就是使用堆外内存的绝佳场所。使用堆外内存的两个主要优点是:

  1. 对服务gc的影响很小,这也是本次使用堆外内存的原因。
  2. 常规的网络IO操作,需要将数据先从堆内存,拷贝到堆外内存中。如果直接使用堆外内存的话可以节省一次数据copy的过程,具体原因可以参见使用堆外内存优点

对于使用堆外内存,笔者并没有做太多技术选型,使用了较为主流的工具OHCache。OHCache将与堆外内存交互的细节已经封装好了,使用起来和平常使用Map并没有什么区别。具体实现本文后面介绍。将模型对象迁移到堆外内存之后,服务gc如下图所示:



可以发现gcTime和gcCount都下降约一倍左右,服务的gc压力明显改善了。

接下来介绍一下如何操作堆外内存。java语言,操作堆外内存的方式主要有两种,一种是使用ByteBuffer类中的allocateDirect方法,一种是使用Unsafe类的allocateMemory来操作。但是Unsafe只能在JDK的代码中使用,正常业务代码无法使用,所以大多数情况下我们都使用的是ByteBuffer。ByteBuffer分配堆外内存的基本过程如下:

其中预分配内存是最主要的环节,作用是判断当前的空间是否满足申请。大概的过程是:

  1. 判断当前内存是否足够,如果足够的话就返回
  2. 如果当前内存不足,就回收掉不用的内存
  3. 如果还是不足,就发起一次System.gc。清理掉已经已经成为垃圾的ByteBuffer对象所引用的堆外内存
  4. 循环执行1、2步骤,到了指定次数,还是不足,就报OOM

这里需要说明一下的是,步骤2、3都有回收堆外内存的操作,其实有所不同。第二步是,当一个对象被垃圾回收,满足特定情况下装入一个队列,此处就是从队列中取出对象,将其所对应的堆外内存回收掉;第三步是指,ByteBuffer已经成了垃圾对象,但由于某些原因,比如在老年代,没有被垃圾回收送进队列,正也是发起一次System.gc的目的所在。具体流程如图所示:

可以发现,虽然堆外内存有一些优点,但是使用不慎会执行System.gc,触发系统发生fullgc。针对这个,要一方面做好监控,一方面要提前评估预计使用多少堆外内存,避免发生fullgc。

接下来介绍下堆外内存是如何被回收的呢?这里就得谈到虚引用的概念了。创建一个虚引用对象必须指定一个队列,当这个对象的回收,对象引用会被放入到队列中,被一个后台线程进行处理。特别的,如果对象还是实现了Cleaner类,就会执行类的clean方法。ByteBuffer正是使用这种机制,在内部维持了一个Cleaner对象,其中clean方法的操作就是回收掉对应的堆外内存。

那么OHCache是如何实现堆外内存存取操作的呢?OHCache是线程安全的,它的实现方式非常类似于java7版本中的ConcurrentHashMap。将一个数组分成若干个段(Segment),每个Segment称之为OffHeapLinkedMap,每个OffHeapLinkedMap中又包括若干个桶,每个桶中存放的实际上是一个链表,结果如下所示:

OHCache有两种实现方式,一种是OHCacheLinkedImpl,另一种实现方式是OHCacheChunkedImpl。根据官方资料的介绍OHCacheChunkedImpl还处于实验性的,没有正式对外发布使用。两种实现方式的主要区别是,OHCacheLinkedImpl对每一个put操作都申请堆外内存,而OHCacheChunkedImpl是按照Segment来预先申请分配内存。因此如果是大量小的对象,OHCacheChunkedImpl可以避免频繁申请内存,理论上性能会好一些。但是对于大的对象,会造成内存利用不充分问题。一般的讲,申请内存操作不会成为系统性能的瓶颈,可以通过打点验证,通过实时监控ohCache的OHCacheStats来分析佐证。

最后介绍下笔者使用OHCache踩到的两坑。一个是如果设置throwOOME为false,可能存在put操作失败,只是返回了一个false,如果业务代码没有对返回结果进行处理,这次put操作可能就漏掉了;每次存入的对象不能太大,默认不能超过限制每个OffHeapLinkedMap的大小,如果超过,也会导致put失败。

OHCache的使用示例可以查看:github,另外,笔者也将OHCache简单封装了一下,可以替换java.util.Map,具体代码见:github

参考资料:
https://www.cnblogs.com/liang1101/p/13499781.html
https://juejin.cn/post/6844903710766661639

  
如果对文章有任何不同意见,有两种办法: