写点什么

深度剖析 | 【JVM 深层系列】[HotSpotVM 研究系列] JVM 调优的"标准参数"的各种陷阱和坑点分析(攻克盲点及混淆点)「 1 」

作者:洛神灬殇
  • 2022-12-16
    江苏
  • 本文字数:5324 字

    阅读完需:约 17 分钟

深度剖析  | 【JVM深层系列】[HotSpotVM研究系列] JVM调优的"标准参数"的各种陷阱和坑点分析(攻克盲点及混淆点)「 1 」

【易错问题】Major GC 和 Full GC 的区别是什么?触发条件呢?

相信大多数人的理解是 Major GC 只针对老年代,Full GC 会先触发一次 Minor GC,不知对否?我参考了 R 大的分析和介绍,总结了一下相关的说明和分析结论。

在基于 HotSpotVM 的基础角度

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

Partial GC(部分回收模式)

Partial GC 代表着并不收集整个 GC 堆的模式


  • Young Generation GC(新生代回收模式):它主要是进行回收新生代范围内的内存对象的 GC 回收器。

  • Old/Tenured Generation GC(老年代回收模式):它主要是针对于回收老年代 Old/Tenured Generation 范围内的 GC 垃圾回收器(CMS 的 Concurrent Collection 是这个模式)。

  • Mixed Generation GC(混合代回收模式):收集整个 young gen 以及部分 old gen 的 GC。只有 G1 有这个模式

Full GC(全体回收模式)

Full GC 代表着收集整个 JVM 的运行时堆+方法区+直接堆外内存的总体范围内。(甚至可以理解为 JVM 进程范围内的绝大部分范围的数据区域)。


它会涵盖了所有的模式和区域包含:Young Gen(新生代)、Tenured Gen(老生代)、Perm/Meta Gen(元空间)(JDK8 前后的版本)等全局范围的 GC 垃圾回收模式。


在一般情况下 Major GC 通常是跟 Full GC 是等价的,收集整个 GC 堆。但如果从 HotSpot VM 底层的细节出发,如果再有人说“Major GC”的时候一定要问清楚他想要指的是上面的 Full GC 还是 Old/Tenured GC。

基于最简单的分代式 GC 策略

触发条件是:Young GC

按 HotSpot VM 的 Serial GC 的实现来看,当 Young gen 中的 Eden 区分达到阈值(属于一定的百分比进行控制)的时候触发。


注意:Young GC 中有部分存活对象会晋升到 Old/Tenured Gen,所以 Young GC 后 Old Gen 的占用量通常会有所升高

触发条件是:Full GC
  1. 当准备要触发一次 Young GC 时,如果发现统计数据说之前 Young Old/Tenured Gen 剩余的空间大,则不会触发 Young GC,而是转为触发 Full GC(因为 HotSpot VM 的 GC 里,除了 CMS 的 Concurrent collection 之外,其它能收集 Old/Tenured Gen 的 GC 都会同时收集整个 GC 堆,包括 Young gen,所以不需要事先触发一次单独的 Young GC);

  2. 如果有 Perm/Meta gen 的话,要在 Perm/Meta gen 分配空间但已经没有足够空间时,也要触发一次 full GC。

  3. System.gc()方法或者 Heap Dump 自带的 GC,默认也是触发 Full GC。HotSpot VM 里其它非并发 GC 的触发条件复杂一些,不过大致的原理与上面说的其实一样。


注意:Parallel Scavenge(-XX:+UseParallelGC)框架下,默认是在要触发 Full GC 前先执行一次 Young GC,并且两次 GC 之间能让应用程序稍微运行一小下,以期降低 Full GC 的暂停时间(因为 young GC 会尽量清理了 Young Gen 的垃圾对象,减少了 Full GC 的扫描工作量)。控制这个行为的 VM 参数是-XX:+ScavengeBeforeFullGC。

触发条件是:Concurrent GC

Concurrent GC 的触发条件就不太一样。以 CMS GC 为例,它主要是定时去检查 Old Gen 的使用量,当使用量超过了触发比例就会启动一次 CMS GC,对 Old gen 做并发收集。

GC 回收器对应的 GC 模式列举

在 Hotspot JVM 实现的 Serial GC, Parallel GC, CMS, G1 GC 中大致可以对应到某个 Young GC 和 Old GC 算法组合;


  • Serial GC 算法:Serial Young GC + Serial Old GC (实际上它是全局范围的 Full GC);

  • Parallel GC 算法:Parallel Young GC + 非并行的 PS MarkSweep GC / 并行的 Parallel Old GC(这俩实际上也是全局范围的 Full GC),选 PS MarkSweep GC 还是 Parallel Old GC 由参数 UseParallelOldGC 来控制;

  • CMS 算法:ParNew(Young)GC + CMS(Old)GC (piggyback on ParNew 的结果/老生代存活下来的 object 只做记录,不做 compaction)+ Full GC for CMS 算法(应对核心的 CMS GC 某些时候的不赶趟,开销很大);

  • G1 GC:Young GC + mixed GC(新生代,再加上部分老生代)+ Full GC for G1 GC 算法(应对 G1 GC 算法某些时候的不赶趟,开销很大);

GC 回收模式的触发总结

  • 搞清楚了上面这些组合,我们再来看看各类 GC 算法的触发条件。简单说,触发条件就是某 GC 算法对应区域满了,或是预测快满了。比如,

  • 各种 Young GC 的触发原因都是 eden 区满了;

  • Serial Old GC/PS MarkSweep GC/Parallel Old GC 的触发则是在要执行 Young GC 时候预测其 promote 的 object 的总 size 超过老生代剩余 size;

  • CMS GC 的 initial marking 的触发条件是老生代使用比率超过某值;

  • G1 GC 的 initial marking 的触发条件是 Heap 使用比率超过某值;

  • Full GC for CMS 算法和 Full GC for G1 GC 算法的触发原因很明显,就是 4.3 和 4.4 的 fancy 算法不赶趟了,只能全局范围大搞一次 GC 了(相信我,这很慢!这很慢!这很慢!);

【坑点与坑点】-XX:+DisableExplicitGC 与 NIO 的 direct memory 的关系

很多人都见过 JVM 调优建议里使用这个参数,对吧?但是为什么要用它,什么时候应该用而什么时候用了会掉坑里呢?


  1. 首先,要了解的是这个参数的作用。在 Oracle/Sun JDK 这个具体实现上,System.gc()的默认效果是引发一次 stop-the-world 的 Full GC,由上面所知就是针对于整个 GC 堆做内存垃圾收集。

  2. 再次,如果采用了用了-XX:+DisableExplicitGC 参数后,System.gc()的调用就会变成一个空调用,完全不会触发任何 GC(但是“函数调用”本身的开销还是存在的哦~)。

  3. 为啥要用这个参数呢?最主要的原因是为了防止某些小白同学在代码里到处写 System.gc()的调用而干扰了程序的正常运行吧。

  4. 有些应用程序本来可能正常跑一天也不会出一次 Full GC,但就是因为有人在代码里调用了 System.gc()而不得不间歇性被暂停。

  5. 有些时候这些调用是在某些库或框架里写的,改不了它们的代码但又不想被这些调用干扰也会用这参数。

-XX:+DisableExplicitGC 看起来这参数应该总是开着嘛。有啥坑呢?

下述三个条件同时满足时会发生的

  1. 应用本身在 GC 堆内的对象行为良好,正常情况下很久都不发生 Full GC。

  2. 应用大量使用了 NIO 的 direct memory,经常、反复的申请 DirectByteBuffer。

  3. 使用了-XX:+DisableExplicitGC。

能观察到的现象是:

java.lang.OutOfMemoryError: Direct buffer memory      at java.nio.Bits.reserveMemory(Bits.java:633)      at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:98)      at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)  
复制代码
用一个案例来分析这现象:
import java.nio.*;  public class DisableExplicitGCDemo {    public static void main(String[] args) {      for (int i = 0; i < 100000; i++) {        ByteBuffer.allocateDirect(128);      }      System.out.println("Done");    }  }  
复制代码


然后编译、运行。


$ java -version  java version "1.6.0_25"  Java(TM) SE Runtime Environment (build 1.6.0_25-b06)  Java HotSpot(TM) 64-Bit Server VM (build 20.0-b11, mixed mode)  $ javac DisableExplicitGCDemo.java   $ java -XX:MaxDirectMemorySize=10m -XX:+PrintGC -XX:+DisableExplicitGC DisableExplicitGCDemoException in thread "main" java.lang.OutOfMemoryError: Direct buffer memory      at java.nio.Bits.reserveMemory(Bits.java:633)      at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:98)      at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)      at DisableExplicitGCDemo.main(DisableExplicitGCDemo.java:6)  $ java -XX:MaxDirectMemorySize=10m -XX:+PrintGC DisableExplicitGCDemo  [GC 10996K->10480K(120704K), 0.0433980 secs]  [Full GC 10480K->10415K(120704K), 0.0359420 secs]  Done  
复制代码


  • 可以看到,同样的程序,不带-XX:+DisableExplicitGC时能正常完成运行,而带上这个参数后却出现了 OOM。-XX:MaxDirectMemorySize=10m 限制了 DirectByteBuffer 能分配的空间的限额,以便问题更容易展现出来。不用这个参数就得多跑一会儿了。

  • 循环不断申请 DirectByteBuffer 但并没有引用,所以这些 DirectByteBuffer 应该刚创建出来就已经满足被 GC 的条件,等下次 GC 运行的时候就应该可以被回收。

  • 实际上却没这么简单。DirectByteBuffer 是种典型的“冰山”对象,也就是说它的 Java 对象虽然很小很无辜,但它背后却会关联着一定量的 native memory 资源,而这些资源并不在 GC 的控制之下,需要自己注意控制好。


对 JVM 如何使用 native memory 不熟悉的同学可以研究一下这篇演讲,“Where Does All the Native Memory Go”。

【盲点问题】DirectByteBuffer 的回收问题

Oracle/Sun JDK 的实现里,DirectByteBuffer 有几处值得注意的地方。


  1. DirectByteBuffer 没有 finalizer,它的 native memory 的清理工作是通过 sun.misc.Cleaner 自动完成的。

  2. sun.misc.Cleaner 是一种基于 PhantomReference 的清理工具,比普通的 finalizer 轻量些。


"A cleaner tracks a referent object and encapsulates a thunk of arbitrary cleanup code. Some time after the GC detects that a cleaner's referent has become phantom-reachable, the reference-handler thread will run the cleaner."

源码注释
/**  * General-purpose phantom-reference-based cleaners.  *  * <p> Cleaners are a lightweight and more robust alternative to finalization.  * They are lightweight because they are not created by the VM and thus do not  * require a JNI upcall to be created, and because their cleanup code is  * invoked directly by the reference-handler thread rather than by the  * finalizer thread.  They are more robust because they use phantom references,  * the weakest type of reference object, thereby avoiding the nasty ordering  * problems inherent to finalization.  *  * <p> A cleaner tracks a referent object and encapsulates a thunk of arbitrary  * cleanup code.  Some time after the GC detects that a cleaner's referent has  * become phantom-reachable, the reference-handler thread will run the cleaner.  * Cleaners may also be invoked directly; they are thread safe and ensure that  * they run their thunks at most once.  *  * <p> Cleaners are not a replacement for finalization.  They should be used  * only when the cleanup code is extremely simple and straightforward.  * Nontrivial cleaners are inadvisable since they risk blocking the  * reference-handler thread and delaying further cleanup and finalization.  *  *  * @author Mark Reinhold  * @version %I%, %E%  */  
复制代码


Oracle/Sun JDK 中的 HotSpot VM 只会在 Old Gen GC(Full GC/Major GC 或者 Concurrent GC 都算)的时候才会对 Old Gen 中的对象做 Reference Processing,而在 Young GC/Minor GC 时只会对 Young Gen 里的对象做 Reference processing。Full GC 会对 Old Gen 做 Reference processing,进而能触发 Cleaner 对已死的 DirectByteBuffer 对象做清理工作。


  • 如果很长一段时间里没做过 GC 或者只做了 Young GC 的话则不会在 Old Gen 触发 Cleaner 的工作,那么就可能让本来已经死了的、但已经晋升到 Old Gen 的 DirectByteBuffer 关联的 Native Memory 得不到及时释放

  • 为 DirectByteBuffer 分配空间过程中会显式调用 System.gc(),以通过 Full GC 来强迫已经无用的 DirectByteBuffer 对象释放掉它们关联的 native memory


// These methods should be called whenever direct memory is allocated or  // freed.  They allow the user to control the amount of direct memory  // which a process may access.  All sizes are specified in bytes.  static void reserveMemory(long size) {       synchronized (Bits.class) {          if (!memoryLimitSet && VM.isBooted()) {              maxMemory = VM.maxDirectMemory();              memoryLimitSet = true;          }          if (size <= maxMemory - reservedMemory) {              reservedMemory += size;              return;          }      }      System.gc();      try {          Thread.sleep(100);      } catch (InterruptedException x) {          // Restore interrupt status          Thread.currentThread().interrupt();      }      synchronized (Bits.class) {          if (reservedMemory + size > maxMemory)              throw new OutOfMemoryError("Direct buffer memory");             reservedMemory += size;    }  }  
复制代码

总结分析

这几个实现特征使得 Oracle/Sun JDK 依赖于 System.gc()触发 GC 来保证 DirectByteMemory 的清理工作能及时完成。


如果打开了-XX:+DisableExplicitGC,清理工作就可能得不到及时完成,于是就有机会见到 direct memory 的 OOM,也就是上面的例子演示的情况。我们这边在实际生产环境中确实遇到过这样的问题。


如果你在使用 Oracle/Sun JDK,应用里有任何地方用了 direct memory,那么使用-XX:+DisableExplicitGC 要小心。如果用了该参数而且遇到 direct memory 的 OOM,可以尝试去掉该参数看是否能避开这种 OOM。如果担心 System.gc()调用造成 Full GC 频繁,可以尝试下面提到 -XX:+ExplicitGCInvokesConcurrent 参数

发布于: 刚刚阅读数: 4
用户头像

洛神灬殇

关注

🏆 InfoQ写作平台-签约作者 🏆 2020-03-25 加入

【个人简介】酷爱计算机科学、醉心编程技术、喜爱健身运动、热衷悬疑推理的“极客达人” 【技术格言】任何足够先进的技术都与魔法无异 【技术范畴】Java领域、Spring生态、MySQL专项、微服务/分布式体系和算法设计等

评论

发布
暂无评论
深度剖析  | 【JVM深层系列】[HotSpotVM研究系列] JVM调优的"标准参数"的各种陷阱和坑点分析(攻克盲点及混淆点)「 1 」_JVM_洛神灬殇_InfoQ写作社区