深入学习 JVM - GC

GC Arithmetic

可达性分析

GC Roots其实不是一组对象,而通常是一组特别管理的指向引用类型对象的指针,这些指针是tracing GC的trace的起点。

只有引用类型的变量才被认为是Roots,值类型的变量永远不被认为是Roots。

哪些对象可以作为 GC Root:

  • 虚拟机栈(栈帧中的局部变量表,Local Variable Table)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

选择这些对象作为 GC Root 的依据是什么?

可作为GC Roots的节点主要在全局性的引用与执行上下文中。

要明确的是,tracing gc必须以当前存活的对象集为Roots,因此必须选取确定存活的引用类型对象。GC管理的区域是Java堆,而虚拟机栈、方法区和本地方法栈不被GC所管理,因此选用这些区域内引用的对象作为GC Roots,是不会被GC所回收的。

其中虚拟机栈和本地方法栈都是线程私有的内存区域,只要线程没有终止,就能确保它们中引用的对象的存活。而方法区中类静态属性引用的对象是显然存活的。常量引用的对象在当前可能存活,因此,也可能是GC roots的一部分。

对象的结构

常见回收算法

内存分配策略

  1. 优先分配对象到 Eden 区;
  2. 大对象直接分配到老年代;
  3. 长期存活对象分配到老年代,-XX:MaxTenuringThreshold=15;
  4. 空间分配担保,-XX:+HandlePromotionFailure,默认开启;
  5. 动态对象年龄判定;

优先分配对象到 Eden 区

内存分配情况:将JVM内存划分为一块较大的Eden空间(80%)和两块小的Servivor(各占10%)。当回收时,将 Eden 和 Survivor 中还存活的对象一次性采用复制算法直接复制到另外一块 Survivor 空间上,最后清理 Eden 空间和原先的 Survivor 空间中的数据。

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,JVM将发起一次 Minor GC。

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多是具有朝生夕灭的特性,所以Minor GC非常频繁,而且该速度也比较快。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,一般可能也会伴随着一次Minor GC,但是与Minor GC不同的是,Major GC的速度慢十倍以上。

大对象直接分配到老年代

大对象做一个定义:这里指的是需要大量连续内存空间的Java对象。最典型的大对象可以是很长的字符串和数组。

大对象对于JVM的内存分配来说是十分麻烦的,如果我们将大对象分配在新生代中,那样子的话很容易导致内存还有不少空间时,提前触发垃圾收集以获取足够的连续空间来“安置”它们。

为了避免上述情况的经常发生而导致不需要的GC活动所浪费的资源和时间,可采用的分配策略是将大对象直接分配到老年代中去,虚拟机中也提供了-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代里面分配内容。

-XX:PretenureSizeThreshold,默认为0
-XX:PretenureSizeThreshold只对Serial和ParNew收集器有效。

长期存活对象分配到老年代

当 JVM 采用分代收集的思想来管理内存时,为了识别哪些对象应该放在新生代、哪些对象应该放在老年代,JVM 给每个对象定义了一个对象年龄计数器。

对象年龄计数器:如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,便可以被移动到Survivor空间中,年龄计数器将设置该对象的年龄为1。对于对象在Survivor区每经过一次Minor GC,年龄便增加1岁,当它的年龄增加到一定程度(可通过参数-XX:MaxTenuringThreshold设置)默认15,该对象便会进入到老年代中。成为老年代的对象。

动态对象年龄判定

事实上,有的虚拟机并不永远地要求对象的年龄必须达到MaxTeruringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Surivior空间的一半,年龄大于或等于该年龄的对象就可以直接进行老年代,无须等到MaxTeruringThreshold中所要求的年龄。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。

如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure打开,避免Full GC过于频繁。

https://cloud.tencent.com/developer/article/1082730

逃逸分析(Escape Analysis)与栈上分配

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析。

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

简单说,就是把没有发生逃逸的对象分配到栈空间上。

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸

使用逃逸分析,编译器可以对代码做如下优化:

  1. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

  2. 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

JVM参数 XX:+DoEscapeAnalysis 和 -XX:-DoEscapeAnalysis,默认情况下开启。
以上三种优化中,栈上内存分配其实是依靠标量替换来实现的。


GC回收器

CMS

分代回收:

  • 年轻代:采用 stop-the-world mark-copy 算法;
  • 年老代:采用 Mostly Concurrent mark-sweep 算法;

使用 Mark-Sweep GC Arithmetic,该算法不足之处在于:

  1. 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次垃圾收集动作。
  2. 效率问题:因为内存碎片的存在,操作会变得更加费时,因为查找下一个可用空闲块已不再是一个简单操作。

GC Process:

  1. 初始标记(STW)
  2. 并发标记(两阶段:并发标记 和 并发预处理)
  3. 重新标记(STW)
  4. 并发清理(两阶段:并发清理 和 并发重置)

Advantage:

  1. 并发GC;
  2. 低停顿;

Weakness:

  1. 占用较多CPU资源;
  2. 无法处理Floating Garbage,并发标记与并发清除过程会产生浮动垃圾,如果CMS之前预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将退化使用Serial Old收集器,重新进行老年代的垃圾收集,这样停顿时间就很长了。
  3. 出现Concurrent Mode Failure问题,造成使用Serial Old GC进行回收老年代;
  4. 基于“标记-清除算法”,收集结束时会有大量空间碎片产生,导致明明剩余空间充足,却无法为大对象分配足够的连续内存。

Floating Garbage:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。在这期间用户可能创建新的对象。为了处理这部分浮动垃圾和对象,CMS在并发清理之前,需要预留出足够空间给并发清理期间的用户线程使用。一般会显示使用-XX:CMSInitiatingOccupancyFraction参数设置触发CMS时的老年代空间比例,如果老年代增长不是太快,可以适当提高比例,以减少Full GC的次数。
关于浮动垃圾和内存碎片的问题,HDFS namenode在堆内存达到100G规模时,通常设置75%触发Full GC,不开启压缩,优先考虑STW造成的延迟。
打开-XX:+UseCMSCompactAtFullCollection开关参数(默认打开)在进行Full GC之前整理内存碎片(称为“压缩”);使用-XX:CMSFullGCsBeforeCompaction参数(默认0)设置多少次不带压缩的Full CG之后才进行一次带压缩的Full GC。内存整理无法并行,还需要STW,需要适当调整内存整理的频率,在GC性能与空间利用率之间平衡。

G1

GC Process:

  1. 初始标记(initial mark,STW):它标记了从GC Root开始直接可达的对象。
  2. 并发标记(Concurrent Marking):这个阶段从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息。
  3. 最终标记(Remark,STW):标记那些在并发标记阶段发生变化的对象,将被回收。
  4. 清除垃圾(Cleanup):清除空Region(没有存活对象的),加入到free list。

Advantage:

  1. 并行与并发:G1 能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  2. 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新创建的对象和已经存活了一段时间,熬过多次 GC 的旧对象以获取更好的收集效果。
  3. 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  4. 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。

什么是RSet?

RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。

Region1和Region3中的对象都引用了Region2中的对象,因此在Region2的RSet中记录了这两个引用。

什么是CSet?

一组可被回收的分区(Region)的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。

ZGC

读屏障(Load Barrier)


Reference