• 性能提升3倍之路:记Guava cache带来的GC问题


        问题 

          在用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,可以看到这个RefCountedCloseable其实是包了一个JanusGraphVertexDeserializer,它是用来做deserialization的,也就是把原始的partition数据,转换成一个TinkerVertex,给JanusGraph的上层。也就是这个JanusGraphVertexDeserializer,它会由一个JanusGraphHadoopSetupImpl来创建(有点绕),JanusGraphHadoopSetupImpl里面有个方法getTypeInspector(),它是用来做schema检查的,即每个vertex,edge,或者property的名字是否符合规范,都要它来check,这个TypeInspector里面就有guava cache,它是根据字符串返回一个Long,也就是得到ID。总结一下,每个spark task都会用一个static的guava cache去查找string,返回Long。有问题吗?看上去没问题,不是吗?

    private static final RefCountedCloseable refCounter;
    
    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下畅快运行的截图来终结此文。

        

  • 相关阅读:
    计算机网络学习易错点
    【数据结构笔记02】数据结构之线性表的链式表示和实现(单链表)
    this指针和相关的用处——C++
    45页新能源充电桩运营平台规划与建设方案
    Android+Appium自动化测试环境搭建及实操
    git创建与合并分支
    OpenAI再次与Altman谈判;ChatGPT Voice正式上线
    AOSP10 替换系统launcher
    深度学习(生成式模型)——Classifier Free Guidance Diffusion
    神经辐射场 (NeRF) 概念
  • 原文地址:https://blog.csdn.net/penngrove/article/details/126542322