在用JanusGraph做OLAP分析的项目中,我发现Spark的executor节点出现大量GC,每个executor的GC开销都在task运行时间的10%以上。用JVM async profiler查看后发现,大概40%~50%的CPU时间都花在GC上。尝试G1GC后,现象依旧没有任何改观。


只好老老实实做heap dump,但是一直苦于heap dump的结果太大,无法分析。最后从同事那里得知,可以用Memory Analyzer Tool (mat)的脚本去做分析,当然前提也要运行mat的机器有足够的内存,分析的结果是html,可以方便查看。
打heap dump有专门的命令:
jmap -dump:live,format=b,file=
分析heap dump用
./ParseHeapDump.sh /opt/memory_analyzer/m.hprof org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components
这样终于得到了dump_Leak_Suspects.zip(不同的heap dump文件,这个名字会不同),打开直接去找Histogram,结果发现了一直占着内存的是Guava cache的ConcurrentLinkedQueue$Node,这就很明显是Guava cache导致了内存泄漏。

Guava cache也是使用很广的library,为什么会有这种问题呢?这就要从我的测试场景说起。这个场景是用Spark跑JanusGraph的job,就是g.V().label().groupCount(),这是一条很简单的统计gremlin,如果觉得有groupCount让你觉得复杂,即使换g.V().count(),问题也是一样的。
在JanusGraph里面,当Spark job开始运行时,会调用HadoopInputFormat读取partition,HadoopInputFormat自己有一个static的RefCountedCloseable
private static final RefCountedCloseablerefCounter; static { refCounter = new RefCountedCloseable<>((conf) -> new JanusGraphVertexDeserializer(new JanusGraphHadoopSetupImpl(conf))); }
恰恰和直觉相反,这里面有大问题。我的程序只有一个static的guava cache,实际容量很小,只有16个entry,在运行过程中,总共调用了3,018,666,935次guava cache的getIfPresent(),就导致上面的严重GC。
从分析可以知道,Guava在纯read的环境下,海量的read带来memory leak问题。带来问题的地方是ConcurrentLinkedQueue,查看代码可以找到它:recencyQueue。guava/LocalCache.java at master · google/guava · GitHub
简单看了一下代码,还有注释。很清楚了,这个recencyQueue只有在有write的时候,或者发生eviction的时候,或者Read次数达到64,或者显式cleanup,才会被drain。言外之意,如果纯read,就要等64次后才有一次清除了。如果read量又大又频繁,cleanup自然就来不及了。
那该怎么办?简单,换别的cache吧,推荐Caffeine cache。不管读、写,还是读写混合,都是Guava cache的几倍。很多开源软件使用了,关键它还在接口上完全兼容Guava cache,就是为了替换Guava而生。GitHub - ben-manes/caffeine: A high performance caching library for Java
毫不犹豫,替换,只花了一个小时。半天以后,测试结果显示,几乎0GC,性能从原来的45分钟,减少到18分钟,开心~。愿读到这篇blog的你也开心受用:-)
和同事沟通后,发现早有人给Guava报过类似问题,而google的回复也很简单:不再improve Guava,而是建议换Caffeine cache:Guava LocalCache recencyQueue is 223M entires dominating 5.3GB of heap · Issue #2408 · google/guava · GitHub
在上面这个issue里面,Caffeine cache的作者还贴出了另一个issue,其中讲了Cache performance优化的方法:把recencyQueue和readCount替换成RingBuffer,还列出了性能数字,有兴趣的人可以看看。
Cache performance · Issue #2063 · google/guava · GitHub
最后,贴上spark executor在0GC下畅快运行的截图来终结此文。
