深入理解Java虚拟机之垃圾收集器
0 Views jvm with
本文字数:3,526 字 | 阅读时长 ≈ 13 min

深入理解Java虚拟机之垃圾收集器

0 Views jvm with
本文字数:3,526 字 | 阅读时长 ≈ 13 min

垃圾收集器

前面一文主要讲解了垃圾回收算法,如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

Serial 收集器

Serial收集器是最基本的、最古老的收集器。它是一个单线程收集器,它在收集时必须暂停所有其他工作线程,直到收集结束,因此会产生较长时间的停顿。新生代、老年代使用串行回收新生代采用复制算法暂停所有用户线程老年代采用标记-整理算法暂停所有用户线程

参数:-XX:+UseSerialGC串行收集器

ParNew收集器

ParNew收集器Serial收集器的多线程版本,新生代采用复制算法暂停所有用户线程,老年代采用标记-整理算法暂停所有用户线程。

参数控制:-XX:+UseParNewGC ParNew收集器;-XX:+ParallelGCThreads 限制线程数量。

ParNew收集器的特点是并发,他实现了允许用户线程于垃圾收集线程同时执行(不一定是并行,可能会交替执行)。

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集,又是并行的多线程收集器。

Parallel Scavenge收集器特点是更关注系统的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。提供两个参数用于精确控制吞吐量:1.-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间;2.-XX:GCTimeRatio直接设置吞吐量大小。从Serial单线程收集器到之后的多线程收集器,目的都是减少GC停顿线程的时间,以保证良好的响应速度;对于Parallel Scavenge收集器,减少GC停顿时间是以牺牲吞吐量和新生代空间来换取的。

Parallel Scavenage收集器可通过-XX:+UseAdaptiveSizePolicy参数让虚拟机根据当前系统的运行情况收集监控信息,动态调整这些参数以提供最合适的停顿时间和最大的吞吐量,这种方式成为GC自适应的调节策略

参数控制:-XX:+UseParallelGC 使用Parallel收集器 + 老年代串行。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和”标记-整理”算法。这个收集器在JDK1.6才开始提供。它更加重视”吞吐量优先”以及CPU资源敏感的场合。

参数控制:-XX:+UseParallelOldGC 使用Parallel收集器 + 老年代并行

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它基于”标记-清除”算法实现,整个过程包括四个步骤:

  1. 初始标记(CMS instial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍需要停顿所有线程。初始标记仅记录GC Roots能直接关联到的对象;并发标记则是记录GC Roots引用链上节点的引用对象;重新标记是为了修正并发标记期间导致标记变动的错误(如前面JVM使用OopMap记录节点引用对象,但这可能会改变原始节点的引用)。

优点:

​ 并发收集、低停顿。

缺点:

​ CMS收集器堆CPU资源非常敏感,因为使用了并发执行的策略,在程序运行中,JVM可能因为占用一部分线程而导致应用程序变慢,总吞吐量会降低。

参数控制:

    1. `-XX:+UseConcMarkSweepGC` 使用CMS收集器
      2. `-XX:+UseCMSCompactAtFullCollection` Full GC后,进行一次碎片整理;整理过后线程是独占的,会引起停顿时间变长。
      3. `-XX:+CMSFullGCsBeforeCompaction` 设置进行几次Full GC后,进行一次碎片整理
      4. `-XX:+ParallelCMSThreads` 设定CMS的线程数量(一般情况约等于可用CPU数量)

G1收集器

JDk1.7后全新的收集器,用于取代CMS

特点:

  1. 独特的垃圾回收策略,属于分代垃圾回收期
  2. 使用分区算法,不要求Eden,年轻代或老年代的空间都连续
  3. 并行性:回收期间,可由多个线程同时工作,有效利用多核CPU资源
  4. 并发性:与应用程序可交替执行,部分工作可以和应用程序同时执行
  5. 分代GC:分代收集器,同时兼顾年轻代和老年代
  6. 空间整理:回收过程中,会进行适当对象移动,减少空间碎片
  7. 可预见性:G1可选取部分区域进行回收,可以缩小回收范围,减少全局停顿

主要步骤:

  1. 新生代GC

  2. 并发标记周期:初始标记新生代GC ( 此时是并行,应用程序会暂停 ) -> 根区域扫描 -> 并发标记 -> 重新标记 ( 此时是并行,应用程序会暂停 ) -> 独占清理 ( 此时应用程序会暂停 ) -> 并发清理

  3. 混合回收:这个阶段即会执行正常的年轻代GC,也会选取一些被标记的老年代区域进行回收,同时处理新生代和老年代

  4. 若需要,会进行Full GC

    混合GC时发现空间不足
    在新生代GC时,Survivor区和老年代无法容纳幸存对象时
    以上两者都会导致一次Full GC产生
    

理解GC日志

本人使用的JDK1.8,所以直接以两段常见GC日志举例。注意本人配置了-XX:+PrintGCDetailsJVM运行参数:

垃圾收集器参数总结

参数 描述
UseSerialGC 虚拟机运行在Client模式下的默认值,打开此开关后使用Serial + Serial Old的收集器组合进行内存回收
UseParNewGC 打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收
UseParallelGC 虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行内存回收
UseParallelOldGC 打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收
SurvivorRatio 新生代中Eden区域与Survivor区域的容量比值,默认是8,代表Eden:Survivor = 8:1
PretenureSizeThreshold 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代中分配
MaxTenuringThreshould 晋升到老年代的对象年龄,每个对象在坚持过一次Minor GC之后,年龄就增加1,当超过这个参数值时就进入老年代
UseAdaptiveSizePolicy 动态调整Java堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况
ParallelGCThreads 设置GC时进行内存回收的线程数
GCTimeRatio GC时间占总时间的比率。默认值为99,即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效
MaxGCPauseMillis 设置GC的最大停顿时间啊,仅在使用Parallel Scavenge收集器时生效
CMSInitiatingOccupancyFraction 设置CMS收集器在老年代空间被使用多少后触发垃圾收集。默认值是68%,仅在使用CMS收集器时生效
UseCMSCompactAtFullCollection 设置CMS收集器在完成垃圾收集胡是否要进行一次内存碎片整理。仅在使用CMS收集器时生效
CMSFullGCsBeforeCompaction 设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用CMS收集器时生效

内存分配和回收策略

对象的分配,往大方向将,就是在堆上分配,对象主要分配在新生代的Eden区,少数情况也可能会直接分配在老年代中,分配的规整取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

在前面我们介绍过,Java JVM垃圾收集器采用的算法是分代收集算法,因为Java中占用了相当大的内存空间,所以通常所说的垃圾回收主要是指Java堆的垃圾回收;而分代收集算法的特点就是根据对象的生命周期将整块堆内存划分为:1.新生代、2.老年代、3.持久代(在JDK1.8之后被元数据区取代,废除持久代区);再细分,新生代划分为一块内存较大的Eden区和两个等大小的Survivor区。

内存分配策略

前面讲到过,当一个对象进入到堆内存,首先会占用Eden区和Survivor1区,当发生GC(Minor GC)时,会将Eden和Survivor1区域中仍存活的对象复制到Survivor2区,如果Survivor2区内存不够,则将对象分配到老年代区,如果老年代区内存也满了,就会触发一次Full GC,将新生代和老年代都回收。

深入学习,我们先以一张图了解:

对象优先在Eden区分配

参数控制:-Xms堆内存最小空间;-Xmx堆内存最大空间;-Xmn年轻代内存大小;-XX:SurvivorRatio=8设置Eden:Survivor=8:1

按照Java堆内存区域分布也能想到,对象肯定首先经过Eden区域,当Eden区域没有足够的空间分配时,JVM将发起一次Minior GC。

面试题:Minor GC 和 Full GC 区别?

新生代GC(Minor GC): 指发生在新生代的垃圾回收动作,因为Java对象大多数都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收的速度也很快。

老年代GC (Major GC / Full GC): 指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC。Major GC 的速度一般会比Minor GC慢10倍以上。

举个栗子:

/**
 * VM参数:-XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M
 */
public class YoungGC {

    private static final int SIZE = 1024 * 1024;

    public static void main(String[] args) {
        byte[] b1 = new byte[2 * SIZE];
        byte[] b2 = new byte[2 * SIZE];
        byte[] b3 = new byte[2 * SIZE];
        byte[] b4 = new byte[4 * SIZE];
        System.gc();
    }
}

注意:

    1. 调用`System.gc()`一般都会触发Full GC / Major GC (老年代GC,中断所有线程进行GC),出现了Major GC一般都伴随着至少一次的Minor GC(新生代GC)。
      2. 默认对象进入堆内存,优先在新生代的Eden区和Survivor1区分配内存,并且默认堆内存中Eden区与Survivor区内存比例是:8:1。
      3. 上图中,当分配b4时发现4M的大小无法存入到Eden区中,因此发生Minor GC。GC期间虚拟机又发现已有的b1,b2,b3对象全部无法放入Suvivor空间(此时Survivor区只有1M空间),所以只好将对象存入到老年代中。

大对象直接进入老年代

参数控制:-XX:PretenureSizeThreshold令大于这个设置值的对象直接进入老年代分配 (这个参数不能像-Xmx那样直接写3M,并且该参数只对SerialParNew收集器有效)。

所谓的大对象是指 需要大量连续内存空间的Java对象,最典型的大对象是那种很长的字符串以及数组。

举个栗子:

/**
 * VM参数:-XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:PretenureSizeThreshold=3145728 -XX:+UseSerialGC
 */
public class OldGC {

    private static final int SIZE = 1024 * 1024;

    public static void main(String[] args) {
        byte[] b = new byte[4 * SIZE];
    }
}
Heap
 def new generation   total 9216K, used 2293K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  27% used [0x00000007bec00000, 0x00000007bee3d6f0, 0x00000007bf400000)
  from space 1024K,   0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
  to   space 1024K,   0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
 tenured generation   total 10240K, used 4096K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  40% used [0x00000007bf600000, 0x00000007bfa00010, 0x00000007bfa00200, 0x00000007c0000000)
 Metaspace       used 3168K, capacity 4556K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 392K, committed 512K, reserved 1048576K

可以看到,年轻代 ( def new generation ) 占用内存很少,而老年代 ( tenured generation ) 占用了40%。说明Java堆在分配b这个4M大小对象的时候,直接将该对象分配在老年代,而不是分配在年轻代。

长期存活的对象将进入老年代

JVM通过设置一个计数器(age)通过对象在分代收集过程中存活的状态来决定是否存入到老年代中。比如对象在Eden区经过一次Minor GC存活并能分配在Survivor区 ( 如果不能分配在Survivor区就直接进老年代 ),对象年龄就设为1,对象每经过一次Minor GC年龄就增加1,当他的年龄到15 ( 默认值 ) 就分配到老年代。

参数控制:-XX:MaxTenuringThreshold=15设置对象直接进入老年代

/**
 * VM参数:-XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:MaxTenuringThreshold=15
 */
public class OldGC {

    private static final int SIZE = 1024 * 1024;

    public static void main(String[] args) {
        byte[] b1 = new byte[4 * SIZE];
        byte[] b2 = new byte[4 * SIZE];
        byte[] b3 = new byte[4 * SIZE];
    }
}
Heap
 PSYoungGen      total 9216K, used 6389K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 78% used [0x00000007bf600000,0x00000007bfc3d710,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
  to   space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
 ParOldGen       total 10240K, used 8192K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 80% used [0x00000007bec00000,0x00000007bf400020,0x00000007bf600000)
 Metaspace       used 3165K, capacity 4556K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 392K, committed 512K, reserved 1048576K

对象年龄的判断

上面提到JVM设置一个计数器 ( age ) 来记录对象年龄大小,对象达到MaxTenuringThreshold中要求的年龄便直接进入老年代。

但实际上JVM并没有要求对象的年龄必须达到MaxTenuringThreshould才进入老年代,如果在Survivor空间中相同年龄所有对象大小的中和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代

就如同上面的例子中,b1,b2,b3随便的内存实际都大于Survivor的内存空间,所以他们并没有再次存入Survivor空间 ( from space 1024K, 0%可以看出并没有占用Suvivor的空间 ),而是直接进入了老年代 ( 80%可以看出 )


交流

以上仅是个人见解,欢迎提出意见或建议。

QQ交流群:671017003 欢迎各位前辈或萌新入驻


联系

If you have some questions after you see this article, you can contact me or you can find some info by clicking these links.

如果你觉得这篇文章帮助到了你,你可以帮作者买一杯果汁表示鼓励