深入理解Java虚拟机—垃圾收集器适用场景

前两天在忙(在玩游戏),所以没更新,今天补上


上一篇:深入理解Java虚拟机—低延迟垃圾收集器

下一篇:深入理解Java虚拟机— jvm调优案例分析与实战

一. 选择合适的垃圾收集器

Epsilon(A No-Op Garbage Collector)

1. 简介

Epsilon(A No-Op Garbage Collector)垃圾回收器控制内存分配,但是不执行任何垃圾回收工作。一旦java的堆被耗尽,jvm就直接关闭。设计的目的是提供一个完全消极的GC实现,分配有限的内存分配,最大限度降低消费内存占用量和内存吞吐时的延迟时间。一个好的实现是隔离代码变化,不影响其他GC,最小限度的改变其他的JVM代码。

  • Performance testing,什么都不执行的GC非常适合用于差异性分析。no-op GC可以用于过滤掉GC诱发的新能损耗,比如GC线程的调度,GC屏障的消耗,GC周期的不合适触发,内存位置变化等。此外有些延迟者不是由于GC引起的,比如scheduling hiccups, compiler transition hiccups,所以去除GC引发的延迟有助于统计这些延迟。
  • Memory pressure testing, 在测试java代码时,确定分配内存的阈值有助于设置内存压力常量值。这时no-op就很有用,它可以简单地接受一个分配的内存分配上限,当内存超限时就失败。例如:测试需要分配小于1G的内存,就使用-Xmx1g参数来配置no-op GC,然后当内存耗尽的时候就直接crash

2. 使用场景

  • VM interface testing, 以VM开发视角,有一个简单的GC实现,有助于理解VM-GC的最小接口实现。它也用于证明VM-GC接口的健全性
  • Extremely short lived jobs, 一个短声明周期的工作可能会依赖快速退出来释放资源,这个时候接收GC周期来清理heap其实是在浪费时间,因为heap会在退出时清理。并且GC周期可能会占用一会时间,因为它依赖heap上的数据量
  • Last-drop latency improvements, 对那些极端延迟敏感的应用,开发者十分清楚内存占用,或者是几乎没有垃圾回收的应用,此时耗时较长的GC周期将会是一件坏事。
  • Last-drop throughput improvements, 即便对那些无需内存分配的工作,选择一个GC意味着选择了一系列的GC屏障,所有的OpenJDK GC都是分代的,所以他们至少会有一个写屏障。避免这些屏障可以带来一点点的吞吐量提升

3. 案例

a). 使用G1垃圾收集器:
public class TestEpsilon {
    public static void main(String[] args) {
        System.out.println("程序开始");
        boolean flag = true;
        List<Garbage> list = new ArrayList<>();
        long count = 0;
        while (flag) {
            list.add(new Garbage(list.size() + 1));
            if (list.size() == 1000000 && count == 0) {
                list.clear();
                count++;
            }
        }
        System.out.println("程序结束");
    }
}
class Garbage {
    private int number;
    public Garbage(int number) {
        this.number = number;
    }
    /**
     * GC在清除对象时,会调用finalize()方法
     */
    @Override
    public void finalize() {
        System.out.println(this + " : " + number + " is dying");
    }
    public int getNumber() {
        return number;
    }
    public void setNumber(int number) {
        this.number = number;
    }
}

启动参数: -Xms100m -Xmx100m
运行结果:

程序开始
...
com.gf.demo8.Garbage@15ddf76b : 305097 is dying
com.gf.demo8.Garbage@35e52705 : 305224 is dying
com.gf.demo8.Garbage@32c14bc1 : 305362 is dying
com.gf.demo8.Garbage@7521660a : 305705 is dying
com.gf.demo8.Garbage@f3da16a : 305948 is dying
com.gf.demo8.Garbage@13fc7287 : 306089 is dying
    at java.base/java.lang.ref.Finalizer.register(Finalizer.java:66)
    at java.base/java.lang.Object.<init>(Object.java:50)
    at com.gf.demo8.Garbage.<init>(TestEpsilon.java:28)
    at com.gf.demo8.TestEpsilon.main(TestEpsilon.java:14)
...

会发现G1一直回收对象,直到内存不够用



b). 使用Epsilon垃圾收集器

启动参数:
UnlockExperimentalVMOptions:解锁隐藏的虚拟机参数

-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms100m -Xmx100m

运行程序后,结果如下:

程序开始
Terminating due to java.lang.OutOfMemoryError: Java heap space

会发现很快就内存溢出了,因为Epsilon不会去回收对象。

二. 垃圾收集器日志

配置参数:

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:D:/jvmlog/gc.log
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=D:/jvmlog/gc.log

简单日志格式:

在这里插入图片描述

# Minor GC 新生代 GC
[GC (Allocation Failure)  7164K->704K(19456K), 0.0017002 secs]

# System.gc() 触发 Full GC
[GC (System.gc())  4157K->648K(19456K), 0.0019522 secs]
[Full GC (System.gc())  648K->609K(19456K), 0.0099904 secs]

# jmap -histo:live 触发 Full GC
[GC (Heap Inspection Initiated GC)  938K->737K(19456K), 0.0009119 secs]
[Full GC (Heap Inspection Initiated GC)  737K->573K(19456K), 0.0070892 secs]

下图说明了一条简单格式的垃圾收集日志各个字段的含义:
在这里插入图片描述

详细日志格式:

使用 -XX:+PrintGCDetails和-XX:+PrintGCDateStamps 这两个参数可以打印详细的垃圾收集日志和垃圾收集的时间戳。

第一条是一次 Minor GC,第二条是 Full GC:

2019-12-03T16:20:47.980-0800: [GC (System.gc()) [PSYoungGen: 4068K->656K(9216K)] 4076K->672K(19456K), 0.0016106 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2019-12-03T16:20:47.982-0800: [Full GC (System.gc()) 
            [PSYoungGen: 656K->0K(9216K)],
        [ParOldGen: 16K->570K(10240K)] 672K->570K(19456K), 
        [Metaspace: 3910K->3910K(1056768K)],
 0.0110117 secs] 
 [Times: user=0.02 sys=0.00, real=0.01 secs]

在这里插入图片描述

收集内容主体:
沿着日志顺序往后看,Full GC (System.gc()),收集类型(是 Full GC 还是 Minor GC) ,括号里跟着发生此次垃圾收集的原因。
再后面是年轻代、老年代、Metaspace 区详细的收集情况。
[PSYoungGen: 656K->0K(9216K)],翻译为 「年轻代:年轻代收集前内存使用量->年轻代垃圾收集后内存使用量(年轻代可用内存总大小)」,垃圾收集前年轻代已使用 656K,垃圾收集后已使用 0K,说明被回收了 656K,总可用大小为 9216K(9M)。诶,不对呀?怎么是 9M 呢,年轻代不是分了 10 M 吗。因为可用内存和总内存不能划等号,S0 和 S1 只能有一块被算进可用内存,所以可用内存为 Eden + S0/S1=9M。
[ParOldGen: 16K->570K(10240K)] 672K->570K(19456K),翻译为 「[老年代:老年代收集前内存使用量->老年代垃圾收集后内存使用量(老年代可用内存总大小)] 堆空间(包括年轻代和老年代)垃圾收集前内存使用量->堆空间垃圾收集后内存使用量(堆空间总可用大小)」。
垃圾收集前老年使用 16K,收集后呢,竟然变大了,确定没有看错吗。是的,没有。这是因为年轻代的对象有一些进入了老年代导致的。老年代 16K 变成了 570K,说明有 554K 是年轻代晋升而来的。而内存总大小由 672K 减少到了 570K,说明有102K的内存真正的被清理了。
[Metaspace: 3910K->3910K(1056768K)] 翻译为元空间回收前大小为 3910K,回收后大小为3910K,总可用大小为 1056768K。我们不是设置的 6M 吗,怎么这么大,没起作用吗。实际上这个值是 CompressedClassSpaceSize +(2*InitialBootClassLoaderMetaspaceSize) 的大小,我们只设置了 MaxMetaspaceSize ,并没有设置这两个参数。使用如下命令可以看到这两个值的默认大小

jinfo -flag CompressedClassSpaceSize 75867
-XX:CompressedClassSpaceSize=1073741824
jinfo -flag InitialBootClassLoaderMetaspaceSize 75867
-XX:InitialBootClassLoaderMetaspaceSize=4194304

单位是 byte,CompressedClassSpaceSize 的值是 1048576K(其实就是1G,默认值),InitialBootClassLoaderMetaspaceSize的值是 4M,用上面的公式计算,正好是 1056768K(1032M)
耗时统计
[Times: user=0.02 sys=0.00, real=0.01 secs]
user=0.02 表示执行用户态代码的耗时,这里也就是 GC 线程消耗的 CPU 时间。如果是多线程收集器,这个值会高于 real 时间。
sys=0.00 表示执行内核态代码的耗时。
real=0.01 表示应用停顿时长,多线程垃圾收集情况下,此数值应该接近(user + sys) / GCThreads(收集线程数),即单核上的平均停顿时间。

CMS 垃圾收集器日志

CMS 后摘的一段 GC 日志,由于内容过长,下面我就直接在日志上做注释了:

# System.gc() 触发一次 Full GC
# -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 参数
# 导致Full GC 以 CMS GC 方式执行
# 先由 ParNew 收集器回收年轻代
2019-12-03T16:43:03.179-0800: [GC (System.gc()) 2019-12-03T16:43:03.179-0800: [ParNew: 3988K->267K(9216K), 0.0091869 secs] 3988K->919K(19456K), 0.0092257 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 

# 初始标记阶段,标记那些直接被 GC root 引用或者被年轻代存活对象所引用的所有对象
# 老年代当前使用 651K
# 老年代可用大小 10240K=10M
# 当前堆内存使用量 919K
# 当前堆可用内存 19456K=19M
# “1 CMS-initial-mark” 这里的 1 表示老生代
2019-12-03T16:43:03.189-0800: [GC (CMS Initial Mark) [1 CMS-initial-mark: 651K(10240K)] 919K(19456K), 0.0002156 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

# 并发标记开始
# 标记所有存活的对象,它会根据上个阶段找到的 GC Roots 遍历查找
2019-12-03T16:43:03.189-0800: [CMS-concurrent-mark-start]

# 并发标记阶段耗时统计
2019-12-03T16:43:03.190-0800: [CMS-concurrent-mark: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 

# 并发预清理阶段开始
# 在上述并发标记过程中,一些对象的引用可能会发生变化,JVM 会将包含这个对象的区域(Card)标记为 Dirty
# 在此阶段,能够从 Dirty 对象到达的对象也会被标记,这个标记做完之后,dirty card 标记就会被清除了
2019-12-03T16:43:03.190-0800: [CMS-concurrent-preclean-start]

# 并发预清理耗时统计
2019-12-03T16:43:03.190-0800: [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

# 重新标记阶段,目的是完成老年代中所有存活对象的标记
# 上一阶段是并发执行的,在执行过程中对象的引用关系还会发生变化,所以再次标记
# 因为配置了 -XX:+CMSScavengeBeforeRemark 参数,所以会在标记发生一次 Minor GC
# 进行一次Minor GC,完成后年轻代可用空间 267K,年轻代总大小9216K
2019-12-03T16:43:03.190-0800: [GC (CMS Final Remark) [YG occupancy: 267 K (9216 K)]
# 更详细的年轻代收集情况
2019-12-03T16:43:03.190-0800: [GC (CMS Final Remark) 2019-12-03T16:43:03.190-0800: [ParNew: 267K->103K(9216K), 0.0021800 secs] 919K->755K(19456K), 0.0022127 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
# 在程序暂停时重新进行扫描(Rescan),以完成存活对象的标记
2019-12-03T16:43:03.192-0800: [Rescan (parallel) , 0.0002866 secs]
# 第一子阶段:处理弱引用
2019-12-03T16:43:03.193-0800: [weak refs processing, 0.0015605 secs]
# 第二子阶段:卸载不适用的类
2019-12-03T16:43:03.194-0800: [class unloading, 0.0010847 secs]
# 第三子阶段:清理持有class级别 metadata 的符号表(symbol tables),以及内部化字符串对应的 string tables
# 完成后老年代使用量为651K(老年代总大小10240K=10M)
# 整个堆使用量 755K(总堆大小19456K=19M)
2019-12-03T16:43:03.195-0800: [scrub symbol table, 0.0015690 secs]
2019-12-03T16:43:03.197-0800: [scrub string table, 0.0003786 secs][1 CMS-remark: 651K(10240K)] 755K(19456K), 0.0075058 secs] [Times: user=0.01 sys=0.01, real=0.00 secs]

#开始并发清理 清除未被标记、不再使用的对象以释放内存空间
2019-12-03T16:43:03.198-0800: [CMS-concurrent-sweep-start]
#并发清理阶段耗时
2019-12-03T16:43:03.198-0800: [CMS-concurrent-sweep: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

# 开始并发重置,重置CMS算法相关的内部数据, 为下一次GC循环做准备
2019-12-03T16:43:03.198-0800: [CMS-concurrent-reset-start]
# 重置耗时
2019-12-03T16:43:03.199-0800: [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

# 下面是执行 jmap -histo:live 命令触发的 Full GC
# GC 类型是 Full GC
# 触发原因是 Heap Inspection Initiated GC
# CMS收集老年代:从清理前的650K变为清理后的617K,总的老年代10M,耗时0.0048490秒
# 总堆使用大小由 1245K变为617K,总堆19M
# metaspace: 3912K变为3912K,
# metaspace 总大小显示为  CompressedClassSpaceSize +(2*InitialBootClassLoaderMetaspaceSize)
2019-12-03T16:43:20.115-0800: [Full GC (Heap Inspection Initiated GC) 2019-12-03T16:43:20.115-0800: [CMS: 650K->617K(10240K), 0.0048490 secs] 1245K->617K(19456K), [Metaspace: 3912K->3912K(1056768K)], 0.0049050 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

G1 垃圾收集器日志

开启 G1 收集器的参数如下:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
# 进行了一次年轻代 GC,耗时0.0008029S
[GC pause (G1 Humongous Allocation) (young), 0.0008029 secs]
# 4个GC线程并行执行
   [Parallel Time: 0.5 ms, GC Workers: 4]
   # GC 线程耗时统计,反应收集的稳定性和效率
      [GC Worker Start (ms): Min: 90438.1, Avg: 90438.2, Max: 90438.4, Diff: 0.3]
      # 扫描堆外内存耗时统计
      [Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.2, Diff: 0.2, Sum: 0.6]
      # 更新和扫描RSets 耗时统计
      [Update RS (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.2]
         [Processed Buffers: Min: 0, Avg: 0.2, Max: 1, Diff: 1, Sum: 1]
      [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      #扫描堆中的 root 对象耗时统计
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      # 拷贝存活对象耗时统计
      [Object Copy (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      # GC 线程确保自身安全停止耗时统计
      [Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.5]
         [Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 4]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      # GC的worker 线程的工作时间总计
      [GC Worker Total (ms): Min: 0.1, Avg: 0.4, Max: 0.5, Diff: 0.3, Sum: 1.5]
      # GC的worker 线程完成作业的时间统计
      [GC Worker End (ms): Min: 90438.6, Avg: 90438.6, Max: 90438.6, Diff: 0.0]
   # 修复GC期间code root指针改变的耗时
   [Code Root Fixup: 0.0 ms]
   # 清除code root耗时
   [Code Root Purge: 0.0 ms]
   # 清除card tables 中的dirty card的耗时
   [Clear CT: 0.0 ms]
   # 其他方面比如选择CSet、处理已用对象、引用入ReferenceQueues、释放CSet中的region等的耗时
   [Other: 0.3 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.1 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.0 ms]
      [Humongous Register: 0.0 ms]
      [Humongous Reclaim: 0.0 ms]
      [Free CSet: 0.0 ms]
   # 收集前 Eden区使用量 1024K(总容量9216K),收集后容量0B(总容量9216K)
   # Survivors 区收集前后的大小
   # 堆空间收集前使用量13.4M(总量20M),收集后650.2K
   [Eden: 1024.0K(9216.0K)->0.0B(9216.0K) Survivors: 1024.0K->1024.0K Heap: 13.4M(20.0M)->650.2K(20.0M)]
 [Times: user=0.00 sys=0.00, real=0.00 secs] 

# 初始标记阶段,耗时0.0031800s
2019-12-03T16:48:25.456-0800: [GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0031800 secs][Parallel Time: 2.5 ms, GC Workers: 4]
      [GC Worker Start (ms): Min: 4115.2, Avg: 4115.4, Max: 4115.8, Diff: 0.6]
      ...
   [Eden: 3072.0K(10.0M)->0.0B(9216.0K) Survivors: 0.0B->1024.0K Heap: 9216.0K(20.0M)->744.0K(20.0M)]
 [Times: user=0.01 sys=0.00, real=0.00 secs] 

# Root区扫描 
2019-12-03T16:48:25.460-0800: [GC concurrent-root-region-scan-start]
2019-12-03T16:48:25.462-0800: [GC concurrent-root-region-scan-end, 0.0024198 secs]
# 并发标记
2019-12-03T16:48:25.462-0800: [GC concurrent-mark-start]
2019-12-03T16:48:25.462-0800: [GC concurrent-mark-end, 0.0001306 secs]

# 再次标记
2019-12-03T16:48:25.462-0800: [GC remark 2019-12-03T16:48:25.462-0800: [Finalize Marking, 0.0015922 secs] 2019-12-03T16:48:25.464-0800: [GC ref-proc, 0.0004899 secs] 2019-12-03T16:48:25.465-0800: [Unloading, 0.0016093 secs], 0.0040544 secs]
 [Times: user=0.01 sys=0.00, real=0.00 secs] 
# 清理工作 
2019-12-03T16:48:25.467-0800: [GC cleanup 4000K->4000K(20M), 0.0003710 secs]
 [Times: user=0.00 sys=0.00, real=0.00 secs]

三. 内存分配与回收策略

  • 对象优先在Eden上分配
  • 大对象可以直接进入老年代 设置标签-XX:PretenureSizeThreshold=n n为对象的最大值,超过这个最大值就会直接在老年代分配
  • 长期存活的对象将进入老年代 虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并且经过第一次Minor GC仍然存活,并且能够被Survivor空间容纳,进入移动到Survivor空间,并且设置对象年龄为1,对象在Survivor区每熬过一个Minor GC,年龄就增加1岁,当它的年龄到达一定的程度(默认为15岁),就会被移动到老年代,这个年龄阀值可以通过-XX:MaxTenuringThreshold设置。
  • 动态对象年龄判断 虚拟机并不是永远要求对象的年龄达到-XX:MaxTenuringThreshold=1才移动到老年代,如果Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象也被移动到老年代。
  • 空间分配担保 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC

解释一下冒险:

  • 前面提到过,新生代使用复制收集算法,但是为了内存利用率。只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况是内存回收之后,新生代中所有的对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象存活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
  • 取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败。如果出现担保失败(HandlerPromotionFailure),那就只好在失败后重新发起一次FULL GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是将HandlerPromotionFailure开关打开,避免Full GC过于频繁。

新生代GC的过程

  1. 新对象会被分配到Eden空间中。
  2. 当Eden空间满时,就会触发minor GC过程。
  3. 没有被引用的对象就会被垃圾收集
  4. 被引用的对象会被复制到S0 survivor区,并且age加1
  5. 当对象被移动到S0 survivor区,Eden空间会被清空
    当minor GC再次发生 首先Eden区进行GC,然后S0 survivor区进行GC
  6. 最后进行GC操作之后被引用的对象变成from survivor空间
  7. GC后被引用的对象会被复制到to survicor空间,另外age加1
  8. 复制完成之后,将Eden区和from survivor空间清空
    当minor GC再次发生 首先Eden区进行GC,然后to survivor空间进行GC
  9. 最后进行GC操作之后被引用的对象变成from survivor空间
  10. GC后被引用的对象会被复制到to survivor空间,另外age加1
  11. 复制完成后将Eden区和from survivor空间清空

猜你喜欢

转载自blog.csdn.net/haiyanghan/article/details/108743112