记一次线上FGC问题排查

科技资讯 投稿 6100 0 评论

记一次线上FGC问题排查

引言

背景

服务新功能发版一周后下午,突然收到 CMS GC 告警,导致单台节点被拉出,随后集群内每个节点先后都发生了一次 CMS GC,拉出后的节点垃圾回收后接入流量恢复正常(事后排查发现被重启了)。

排查过程

GC 日志排查

GC 问题首先排查的应该是 GC 日志,日志能能够清晰的判定发生 GC 的那一刻是什么导致的 GC,通过分析 GC 日志,能够清晰的得出 GC 哪一部分在出问题,如下是 GC 日志示例:

0.514: [GC (Allocation Failure [PSYoungGen: 4445K->1386K(28672K] 168285K->165234K(200704K, 0.0036830 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
0.518: [Full GC (Ergonomics [PSYoungGen: 1386K->0K(28672K] [ParOldGen: 163848K->165101K(172032K] 165234K->165101K(200704K, [Metaspace: 3509K->3509K(1056768K], 0.0103061 secs] [Times: user=0.05 sys=0.00, real=0.01 secs]
0.528: [GC (Allocation Failure [PSYoungGen: 0K->0K(28672K] 165101K->165101K(200704K, 0.0019968 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
0.530: [Full GC (Allocation Failure [PSYoungGen: 0K->0K(28672K] [ParOldGen: 165101K->165082K(172032K] 165101K->165082K(200704K, [Metaspace: 3509K->3509K(1056768K], 0.0108352 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]

如上 GC 日志能很明显发现导致 Full GC 的问题是:Full GC 之后,新生代内存没有变化,老年代内存使用从 165101K 降低到 165082K (几乎没有变化)。这个程序最后内存溢出了,因为没有可用的堆内存创建 70m 的大对象。

JVM 监控埋点排查

上述 GC 日志丢失问题基本无解,发生 GC 则立即重建,除非人为干预,否则很难拿到当时的 GC 日志,且很难预知下次发生 GC 问题时间(如果能上报 GC 日子就不会有这样的问题,事后发现有,但是我没找到。)。

事故现场”,通过监控,可以清楚的看到 JVM 内部在时间线上是如何分配内存及回收内存的。

主要关注的核心指标如下:

    GC(垃圾收集)瞬时和累计详情
    • FullGC 次数
    • YoungGC 次数
    • FullGC 耗时
    • YoungGC 耗时
  • 堆内存详情
      堆内存总和
  • 堆内存老年代字节数
  • 堆内存年轻代 Survivor 区字节数
  • 堆内存年轻代 Eden 区字节数
  • 已提交内存字节数
  • 元空间元空间字节数
  • 非堆内存
      非堆内存提交字节数
  • 非堆内存初始字节数
  • 非堆内存最大字节数
  • 直接缓冲区
      DirectBuffer 总大小(字节)
  • DirectBuffer 使用大小(字节)
  • JVM 线程数
      线程总数量
  • 死锁线程数量
  • 新建线程数量
  • 阻塞线程数量
  • 可运行线程数量
  • 终结线程数量
  • 限时等待线程数量
  • 等待中线程数量
  • 堆内存排查

    首先查看堆内存,确认是否有内存溢出(指无法申请足够的内存导致),对内监控如下:
    可以看到发生 Full GC 后,堆内存明显降低了很多,但是在未发生大量 Full GC 后也有内存回收到和全量 GC 同等位置,所以可以断定堆内存是可以正常回收的,不是导致大量 Full GC 的元凶。

    非堆内存排查

    Metaspace 是用来干嘛的?JDK8 的到来,JVM 不再有 PermGen(永久代),但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做 “Metaspace” 的本地内存(Native memory)中。

      程序运行时:当运行 Java 程序时,该程序所需的类和方法。
    • 类被引用时:当程序首次引用某个类时,加载该类。
    • 反射:当使用反射 API 访问某个类时,加载该类。
    • 动态代理:当使用动态代理创建代理对象时,加载该对象所需的类。

    由上得出结论,如果一个服务内没有大量的反射或者动态代理等类加载需求时,讲道理,程序启动后,类的加载数量应该是波动很小的(不排除一些异常堆栈反射时也会加载类导致增加),但是如上监控显示,GC 后,metaspace 的内存使用量一直缓步增长,即程序内不停地制造“类”。

    查看当前 JVM 设置的非堆内存大小如下:

    程序代码排查

    由上面排查得出的结论:程序内在大量的创建类导致非堆内存被打爆。结合当前服务内存在大量使用 Groovy 动态脚本功能,大概率应该是创建脚本出了问题,脚本创建动态类代码如下:

    public static GroovyObject buildGroovyObject(String script {
        GroovyClassLoader classLoader = new GroovyClassLoader(;
        try {
            Class<?> groovyClass = classLoader.parseClass(script;
            GroovyObject groovyObject = (GroovyObject groovyClass.newInstance(;
            classLoader.clearCache(;
    
            log.info("groovy buildScript success: {}", groovyObject;
            return groovyObject;
        } catch (Exception e {
            throw new RuntimeException("buildScript error", e;
        } finally {
            try {
                classLoader.close(;
            } catch (IOException e {
                log.error("close GroovyClassLoader error", e;
            }
        }
    }
    

    线上打开日志,确实证明了在不停的创建类。

    public static GroovyObject buildScript(String scriptId, String script {
        Validate.notEmpty(scriptId, "scriptId is empty";
        Validate.notEmpty(scriptId, "script is empty";
    
        // 尝试缓存获取
        String currScriptMD5 = DigestUtils.md5DigestAsHex(script.getBytes(;
        if (GROOVY_OBJECT_CACHE_MAP.containsKey(scriptId
                && currScriptMD5.equals(GROOVY_OBJECT_CACHE_MAP.get(scriptId.getScriptMD5( {
            log.info("groovyObjectCache hit, scriptId: {}", scriptId;
            return GROOVY_OBJECT_CACHE_MAP.get(scriptId.getGroovyObject(;
        }
    
        // 创建
        try {
            GroovyObject groovyObject = buildGroovyObject(script;
    
            // 塞入缓存
            GROOVY_OBJECT_CACHE_MAP.put(scriptId, GroovyCacheData.builder(
                    .scriptMD5(currScriptMD5
                    .groovyObject(groovyObject
                    .build(;
        } catch (Exception e {
            throw new RuntimeException(String.format("scriptId: %s buildGroovyObject error", scriptId, e;
        }
    
        return GROOVY_OBJECT_CACHE_MAP.get(scriptId.getGroovyObject(;
    }
    

    此处代码逻辑在之前的测试中都是反复验证过的,不会存在问题,即只有缓存 Key 出问题导致了类的重复加载。结合最近修改上线的逻辑,排查后发现,scriptId 存在重复的可能,导致不同脚本,相同 scriptId 不停重复加载(加载的频次 10 分钟更新一次,所以非堆使用缓慢上升)。

    此处埋了一个小坑:加载的类使用 Map 存储的,即同一个 cacheKey 调用 Map.put( 方法,重复加载的类会被后面加载的类给替换掉,即之前加载的类已经不在被 Map 所“持有”,会被垃圾回收器回收掉,按理来说 Metaspace 不应该一直增长下去!?

    类加载与 Groovy 类加载、Metaspace 何时会被回收。

    总结

    知其然知其所以然。

    gceasy.io 在线 GC 日志分析工具,此处笔者参照了美团技术团队文章 Java 中 9 种常见的 CMS GC 问题分析与解决 收益匪浅,推荐大家阅读。

    往期精彩

    个人技术博客:https://jifuwei.github.io/ >

    编程笔记 » 记一次线上FGC问题排查

    赞同 (27) or 分享 (0)
    游客 发表我的评论   换个身份
    取消评论

    表情
    (0)个小伙伴在吐槽