Chronicle-----流计算中避免FullGC

背景

最近在Jstorm中遇到一个问题,在一个流量很大的Jstorm集群中,其中一个Spout会定期从mysql中同步一些元信息并广播到下游的相关Bolt。当元数据信息不大时,这种广播的方式并不会造成太大的影响。

然而,当元数据信息愈发庞大时,广播元数据带来了各种各样的负面影响。

  1. 首先,当Spout读元数据广播时,大量元数据信息被序列化后,发送给下游Bolt,当下游Bolt收到时,元信息又被反序列化为一个大的对象,并更新。这时候对GC产生了很大的压力。
  2. 其次,每个Bolt作为Worker的一个线程去分别持有一份元数据信息,会占用很大的内存。

解决FGC引起的流量异常

从我们流计算的业务监控中,看到流量突然下跌。几分钟后重新恢复流量。立马看了一下Jstorm日志,发现没有发生系统调度,所以说在Jstorm层面应该没有引起流量的下跌。

那么既然不是Jstorm的原因的话,那只能从Topology层面找找原因。这时候在@简离的指导下,用Jstat看了对应Worker的GC情况。 发现FGC 4->5 的时候,old区增长了近10%,同时触发了FGC。这时候就要结合业务日志来看,这个时间点系统到底在做什么导致old区的迅速增长,由于有近10%的内存增长,应该是一个很大的对象分配。 从业务log中发现,发生FGC的时候,Spout从我们的元数据库读了一份元信息,并广播给下游Bolt。这份元信息是一个有近200W+ entry的HashMap。 为了验证这一猜想,我去看了其他集群运行较久的Job。 可以看到这个Job运行了9天,发生了5000+次FGC,平均下来5分钟发生近两次FGC。正好是我们Spout同步元数据的频率。

小插曲

在部署某个集群时,发现Job总是运行个五分钟就挂了,当时也是“百思不得骑姐”。放大worker的内存,以及适当降低并发数就正常了,当时只是简单的觉得是因为OOM,机器资源不足导致的。后面想到并发数特别高时,每个线程持有元数据相当占用内存空间,比较容易引起OOM。

怎么解决?

又是@简离的一番教育,介绍了我什么是堆外内存,什么是FreeGC编程-。- 主角登场chronicle-map,这个是开源的堆外内存实现的ConcurrentHashMap。原本应用在高频交易的场景下,在高频交易中FGC也是灾难性的。

ChronicleMap is a ConcurrentMap implementation which stores the entries off-heap, serializing/deserializing key and value objects to/from off-heap memory transparently. Chronicle Map supports

Key and value objects caching/reusing for making zero allocations (garbage) on queries. Flyweight values for eliminating serialization/deserialization cost and allowing direct read/write access to off-heap memory.

堆存储 VS 非堆存储

堆存储的优势
  • 常见的,写普通的Java代码。所有有经验的Java开发人员都可以做到。
  • 访问内存的安全性问题。
  • 自动的GC服务——无需自身管理的malloc()/free()操作。
  • 完整的 Java Lock API和JMM相结合。
  • 添加无序列化/复制数据到一个结构中去。
非堆存储的优势
  • 控制"停止一切(Stop the World)"的GC事件到你比较满意的层次。
  • 可以超越在规模上的堆存储结构(当使用堆存储的时候会变得很高)
  • 可以作为一个本地的IPC传输(无需java.net.Socket的IP回送)
  • NIO DirectByteBuffer到/dev/shm (tmpfs)的map 或者直接sun.misc.Unsafe.malloc()

什么才是非堆存储

上图介绍了两个使用ShardHashMap(SHM)作为进程间通信(IPC)的Java VM过程(PID1和PID2)。图表底部的水平轴代表的是完全SHM操作系统的所在域。当OpenHFT对象被操作时,它就会在操作系统中物理内存的用户地址空间或者内核地址空间的某处。往深一层思考,我们知道他们以“关于进程”的局部开始着手。从Linux操作系统来看,JVM是一个a.out (通过调用 gcc呈现的)。当a.out在运行的时候会有一个PID。一个 PID的 a.out (在运行时)包含以下三个方面:

  • 文档(低地址……执行代码的地方)
  • 数据(通过sbrk(2)从低地址升级到高地址来掌管)
  • 栈(从高地址到低地址来掌管)

这是PID在操作系统中的表现形式。也就是说,PID是一个执行的JVM,JVM有它自己操作对象潜在的局部性。

从JVM来看,操作对象作为On-PID-on-heap(一般的Java)或者On-PID-off-heap(通过Unsafe或者NIO到Linux mmap(2)的桥梁)。无论在On-PID-on-heap还是在On-PID-off-heap,所有的操作对象仍然存活在用户的地址空间。在C/C++中,API(操作系统调用的)提供了允许C++操作对象有 Off-PID-off-heap的地方,这些操作对象都寄存在内核地址空间内。

小插曲

在使用chronicle-map的过程中,因为Jstorm只支持java 1.7的缘故,我们只能使用chronicle-map 2.x的版本,在使用过程中,我的测试起了10个进程,每个进程500个线程去创建chronicle-map,发现了其builder源码的bug,源码中,当指定的共享文件存在时并且文件内容长度不为0时,它会通过创建一个ObjectStream 8个bytes地读取文件,然而当并发足够大时,会出现第一个线程开始向文件中写入meta信息,而其他的线程读文件时,恰巧8个bytes中读到了EOF引发EOFERROR,在公司内部版本中我已经修复了这个bug。

使用效果

从图中可以看出,FGC时,内存并不像之前那样出现了old区跳跃式增长;同时从业务监控中,也没有再出现断崖式地流量下跌。然而,我们Job每秒大概处理接近200W+的sql信息,db写入接近每秒600Mb,由于大量的YGC,整个Job并没有能够完全避免FGC。

路漫漫其修远兮,吾将上下而求索。