由「Metaspace容量不足触发CMS GC」从而引发的思考

转载请注明原文链接: https://www.jianshu.com/p/468...

某天早上,毛老师在群里问「cat 上怎么看 gc」。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第1张图片

看到有 GC 的问题,立马做出小鸡搓手状。


之后毛老师发来一张图。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第2张图片

图片展示了老年代内存占用情况。

第一个大陡坡是应用发布,老年代内存占比下降,很正常。

第二个小陡坡,老年代内存占用突然下降,应该是发生了老年代 GC。

但奇怪的是,此时老年代内存占用并不高,发生 GC 并不是正常现象。

于是,毛老师查看了 GC log。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第3张图片

从 GC log 中可以看出,老年代发生了一次 CMS GC。

但此时老年代内存使用占比 = 234011K / 2621440k ≈ 9%。

而 CMS 触发的条件是:

老年代内存使用占比达到 CMSInitiatingOccupancyFraction,默认为 92%,

毛老师设置的是 75%。

-XX:CMSInitiatingOccupancyFraction = 75

于是排除老年代占用过高的可能。

接着分析内存状况。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第4张图片

毛老师发现在老年代发生 GC 时,Metaspace 的内存占用也一起下降。

于是怀疑是 Metaspace 占用达到了设置的参数 MetaspaceSize,发生了 GC。

查看 JVM 参数设置,MetaspaceSize 参数被设置为128m。

-XX:MetaspaceSize = 128m -XX:MaxMetaspaceSize = 256m

问题的原因被集中在 Metaspace 上。

毛老师查看另外一个监控工具,发生小陡坡的纵坐标的确接近 128m。

此时,引发出另一个问题:

Metaspace 发生 GC,为何会引起老年代 GC。

于是,想到之前看过 阿飞Javaer 的文章 《JVM参数MetaspaceSize的误解》。

其中有几个关键点:

Metaspace 在空间不足时,会进行扩容,并逐渐达到设置的 MetaspaceSize。

Metaspace 扩容到 -XX:MetaspaceSize 参数指定的量,就会发生 FGC。

如果配置了 -XX:MetaspaceSize,那么触发 FGC 的阈值就是配置的值。

如果 Old 区配置 CMS 垃圾回收,那么扩容引起的 FGC 也会使用 CMS 算法进行回收。

其中的关键点是:

如果老年代设置了 CMS,则 Metasapce 扩容引起的 FGC 会转变成一次 CMS。

查看毛老师配置的 JVM 参数,果然设置了 CMS GC。

-XX:+UseConcMarkSweepGC

于是,解决问题的方法是调整 -XX:MetaspaceSize = 256m。

从监控来看,设置 -XX:MaxMetaspaceSize = 256m 已经足够。

因为后期并不会引发 CMS GC。


GC 的问题算是解决了,但同时引发了以下几点思考:

  1. Metaspace 分配和扩容有什么规律?
  2. JDK 1.8 中的 Metaspace 和 JDK 1.7 中的 Perm 区有什么区别?
  3. 老年代回收设置成非 CMS 时,Metaspace 占用到达 -XX:MetaspaceSize 会引发什么 GC?
  4. 如何制造 Metasapce 内存占用上升?

关于这个问题一和问题二,阿飞Javaer 已经解释的比较清楚。

对于 Metaspce,其初始大小并不等于设置的 -XX:MetaspaceSize 参数。

随着类的加载,Metaspce 会不断进行扩容,直到达到 -XX:MetaspaceSize 触发 GC。

而至于如何设置 Metaspace 的初始大小,目前的确没有办法。

在 openjdk 的 bug 列表中,找到一个 关于 Metaspace 初始大小的 bug,并且尚未解决。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第5张图片

对于问题二, 阿飞Javaer 在文章中也进行了说明。

Perm 的话,我们通过配置 -XX:PermSize 以及 -XX:MaxPermSize 来控制这块内存的大小。

JVM 在启动的时候会根据 -XX:PermSize 初始化分配一块连续的内存块。

这样的话,如果 -XX:PermSize 设置过大,就是一种赤果果的浪费。

关于 Metaspace,JVM 还提供了其余一些设置参数。

可以通过以下命令查看。

java -XX:+PrintFlagsFinal -version | grep Metaspace

关于 Metaspace 更多的内容,可以参考笨神的文章:《JVM源码分析之Metaspace解密》。

问题三

Metaspace 占用到达 -XX:MetaspaceSize 会引发什么?

已经知道,当老年代回收设置成 CMS GC 时,会触发一次 CMS GC。

那么如果不设置为 CMS GC,又会发生什么呢?

使用以下配置进行一个小尝试,然后查看 GC log。

-Xmx2048m -Xms2048m -Xmn1024m 
-XX:MetaspaceSize=40m -XX:MaxMetaspaceSize=128m
-XX:+PrintGCDetails -XX:+PrintGCDateStamps 
-XX:+PrintHeapAtGC -Xloggc:d:/heap_trace.txt

该配置并未设置 CMS GC,JDK 1.8 默认的老年代回收算法为 ParOldGen。

本文测试的应用在启动完成后,占用 Metaspace 空间约为 63m,可通过 jstat 命令查看。

于是,设置 -XX:MetaspaceSize = 40m,期望发生一次 GC。

从 GC log 中,可以找到以下关键日志。

[GC (Metadata GC Threshold) 
[PSYoungGen: 360403K->47455K(917504K)] 360531K->47591K(1966080K), 0.0343563 secs] 
[Times: user=0.08 sys=0.00, real=0.04 secs] 

[Full GC (Metadata GC Threshold) 
[PSYoungGen: 47455K->0K(917504K)] 
[ParOldGen: 136K->46676K(1048576K)] 47591K->46676K(1966080K), 
[Metaspace: 40381K->40381K(1085440K)], 0.1712704 secs] 
[Times: user=0.42 sys=0.02, real=0.17 secs] 

可以看出,由于 Metasapce 到达 -XX:MetaspaceSize = 40m 时候,触发了一次 YGC 和一次 Full GC。

一般而言,我们对 Full GC 的重视度比对 YGC 高很多。

所以一般都会直描述,当 Metasapce 到达 -XX:MetaspaceSize 时会触发一次 Full GC。

问题四

如何人工模拟 Metaspace 内存占用上升?

Metaspace 是 JDK 1.8 之后引入的一个区域。

有一点可以肯定的,Metaspace 会保存类的描述信息。

JVM 需要根据 Metaspace 中的信息,才能找到堆中类 java.lang.Class 所对应的对象。(有点绕)

既然 Metaspace 中会保存类描述信息,可以通过新建类来增加 Metaspace 的占用。

于是想到,使用 CGlib 动态代理,生成被代理类的子类。

简单的 SayHello 类。

public class SayHello {
    public void say() {
        System.out.println("hello everyone");
    }
}

简单的代理类,使用 CGlib 生成子类。

public class CglibProxy implements MethodInterceptor {

    public Object getProxy(Class clazz) {
        Enhancer enhancer = new Enhancer();
        // 设置需要创建子类的类
        enhancer.setSuperclass(clazz);
        enhancer.setCallback(this);
        enhancer.setUseCache(false);
        // 通过字节码技术动态创建子类实例
        return enhancer.create();
    }

    // 实现MethodInterceptor接口方法
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("前置代理");
        // 通过代理类调用父类中的方法
        Object result = proxy.invokeSuper(obj, args);
        System.out.println("后置代理");
        return result;
    }
}

简单新建一个 Controller 用于测试生成 10000 个 SayHello 子类。

@RequestMapping(value = "/getProxy", method = RequestMethod.GET)
@ResponseBody
public void getProxy() {
    CglibProxy proxy = new CglibProxy();
    for (int i = 0; i < 10000; i++) {
        //通过生成子类的方式创建代理类
        SayHello proxyTmp = (SayHello) proxy.getProxy(SayHello.class);
        proxyTmp.say();
    }
}

应用启动完毕后,请求 /getProxy 接口,发现 Metaspace 空间占用上升。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第6张图片

从堆 Dump 中也可以发现,有很多被 CGlib 所代理的 SayHello 类对象。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第7张图片

代理类对应的 java.lang.Class 对象分配在堆内,类的描述信息在 Metaspace 中。

堆中有多个 Class 对象,可以推断出 Metasapce 需要装下很多类描述信息。

最后,当 Metaspace 使用空间超过设置的 -XX:MaxMetaspaceSize=128m 时,就会发生 OOM。

Exception in thread "http-nio-8080-exec-6" java.lang.OutOfMemoryError: Metaspace

从 GC log 中可以看到,JVM 会在 Metaspace 占用满之后,尝试 Full GC。

但会出现以下字样。

Full GC (Last ditch collection)

此外,还有一个问题。

当 Metaspace 内存占用达到 -XX:MetaspaceSize 时,Metaspace 只扩容,不会引起 Full GC。

当 Metaspace 内存占用达到 -XX:MetaspaceSize 时,会发生 Full GC。

在发生第一次 Full GC 之后,Metaspace 依然会扩容。

那么,第二次触发 Full GC 的条件是?

有文章说,在触发第一次F Full GC 后,之后 Metaspace 的每次扩容,都会引起 Full GC。

但观察本文测试的 GC log 和 jstat 命令查看 Metasapce 扩容状况,可以看出:

在第一次 Full GC 之后,之后 Metaspace 的扩容,并不一定会引起 Full GC。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第8张图片

从 jstat 输出可以看到,在触发一次 Full GC 之后,Metaspace 依旧发生了扩容,但未发生 Full GC。

jstat FGC 次数一直都是 1。

此外,使用 GClib 动态生成类,Metaspace 继续扩容,到一定程度,触发了 Full GC。

但触发 FGC 时,Metaspace 占比并没用明显的规律。

由「Metaspace容量不足触发CMS GC」从而引发的思考_第9张图片

尝试了几次,由于 jstat 设置了 1s 钟输出一次,所以每次触发 Full GC 时候,MC 的数据都不一样,但基本是相同。

猜测在第一次 Full GC 之后,之后再次触发 Full GC 的阈值是有一定的计算公式的。

但具体如何计算,估计是需要深入源码了。


此外可以看到,每次 Metaspace 扩容时,都伴随着一次 YGC 或者 Full GC,不知道是否是巧合。

接着看到 占小狼 的文章 《JVM源码分析之垃圾收集的执行过程》。

文章有一句话:

从上述分析中可以发现,gc操作的入口都位于GenCollectedHeap::do_collection方法中。
不同的参数执行不同类型的gc。

打开 openjdk 8 中的 GenCollectedHeap 类,查看 do_collection 方法。

可以看到,在 do_collection 方法中,有这个一段代码。

if (complete) {
  // Delete metaspaces for unloaded class loaders and clean up loader_data graph
  ClassLoaderDataGraph::purge();
  MetaspaceAux::verify_metrics();
  // Resize the metaspace capacity after full collections
  MetaspaceGC::compute_new_size();
  update_full_collections_completed();
}

其中最主要的是 MetaspaceGC::compute_new_size();

得出,YGC 和 Full GC 的确会重新计算 Metaspace 的大小。

至于是否进行扩容和缩容,则需要根据 compute_new_size() 方法的计算结果而定。

得出,Metasapce 扩容导致 GC 这个说法,其实是不准确的。

正确的过程是:新建类导致 Metaspace 容量不够,触发 GC,GC 完成后重新计算 Metaspace 新容量,决定是否对 Metaspace 扩容或缩容。


参考资料

  1. JVM参数MetaspaceSize的误解 https://www.jianshu.com/p/b44...
  2. JVM源码分析之垃圾收集的执行过程 https://www.jianshu.com/p/04e...
  3. JVM源码分析之Metaspace解密 http://lovestblog.cn/blog/201...

你可能感兴趣的:(由「Metaspace容量不足触发CMS GC」从而引发的思考)