读书笔记——《深入理解Java虚拟机》系列之垃圾收集器与GC日志分析

在上一篇博客中,博主和大家一起学了几种常见的垃圾收集算法。我们也知道了分代收集法是目前虚拟机中常用的收集算法。

收集算法可以被看作内存回收问题的理论基础,而不同的垃圾收集器就是内存回收的具体实现了。由于在Java 虚拟机规范中并没有规定需要如何实现垃圾收集器,因此各个厂家或者不同版本的虚拟机所提供的垃圾收集器都有可能有很大的不同。

下图是HotSpot虚拟机所提供的适用于不同年代的垃圾收集器,两个收集器之间存在的连线代表着它们可以搭配使用:

6d8b0a6296b44dceb992b715e935e87c.jpg

在为大家介绍各个收集器之前博主先为大家介绍一下JVM两种工作模式:

  • client模式:JVM在client模式下进行工作时,启动应用程序较快,但是在内存管理,内存回收优化方面都不如server模式,因此它比较适合我们启动运行一些较小规模的测试程序。
  • server模式:JVM在server模式下进行工作时,启动应用程序较慢,但是随着程序运行一段时间后,程序运行的性能将远远高于client模式,因此当我们需要提供一些稳定服务时,我们应该将JVM设置为server模式。

1. Serial 收集器和Serial Old收集器

Serial垃圾收集器是一款最基本的单线程收集器,它不仅只有一条线程进行工作,而且当它工作时必须暂停其他所有的工作线程(stop the world)。因此这种垃圾收集器尽管简单高效,但是只适合用在client模式上(它也是虚拟机运行在client模式下默认的新生代垃圾收集器)。

Serial垃圾收集器是针对新生代内存回收时使用的垃圾收集器,使用算法是复制算法;而Serial Old收集器是针对老年代内存回收时使用的垃圾收集器,使用算法是标记-整理算法,这两种垃圾收集器都需要在工作时暂停其它所有的用户线程,如下图所示:

91348b916df64d568e864acb25f662ac-serial.jpg

下面我首先给大家看一个简单的例子来帮助大家理解Serial GC的工作原理:

  • 首先我们写一个空程序:
public class SerialGC{
    public static void main(String[] args){

    }
}
  • 以下是运行这个程序的参数
java -Xmx20m -Xms20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails SerialGC

-Xmx 和-Xms 用来限制最大堆大小和最小堆大小,同时保证堆内存不会自动扩张
-Xmn 用来声明堆内存中新生代的大小,在这里是10m
-XX:+UseSerialGC 表示使用Serial垃圾收集器
-XX:+PrintGCDetails 表示输出详细的GC信息

  • 使用这些参数去运行我们上边的程序,我们可以得到以下的信息:
Heap
 def new generation   total 9216K, used 1016K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  12% used [0x00000000fec00000, 0x00000000fecfe090, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 2597K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

我在这里给大家简单的解释一下上面的输出信息:

def new generation total 9216K, used 1016K ->新生代 总共9M(9*1024K=9216),为什么是9M呢?因为我们通过-Xmn总共为新生代分配了10M的空间,新生代中eden,from,to的默认比例是8:1:1(除非我们更改比例),因此任意时刻可用的新生代都只有90%,在这里就是9M了。至于为什么,一个空的程序里面会默认占1M的堆内存,博主也还没有想到,欢迎大家与我一起讨论,我目前想的是程序中的字符串常量被放在了堆中,但是应该也不至于有1M才对。。。

eden space 8192K, 12% used [0x00000000fec00000, 0x00000000fecfe090, 0x00000000ff400000) ->eden 8M
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000) ->from 1M
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000) ->to 1M

tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000) ->老年代10M
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)

Metaspace used 2597K, capacity 4486K, committed 4864K, reserved 1056768K ->Java 8废弃永久代后,出现的元空间,它使用的计算机本地内存
class space used 275K, capacity 386K, committed 512K, reserved 1048576K

  • 现在我们修改一下刚才的程序:
public class SerialGC{
    public static void main(String[] args){
        int m = 1024*1024;
        byte[] b = new byte[7*m];
    }
}
  • 再以刚才的参数运行我们新的程序:
Heap
 def new generation   total 9216K, used 8184K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  99% used [0x00000000fec00000, 0x00000000ff3fe0a0, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 2598K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

正如我刚才所说到的,我们的虚拟机新生代总内存只有9M,Eden区只有8M,我在程序中new了1个7M大小的数组对象,再加上我们上面提到的堆中原本就存在的1016K,总共是8184K占了Eden区的99%。我们都知道Eden区是为新生对象分配内存空间的区域,接下来我们就要再次修改我们的程序,再创建一个1M大小的数组对象,看看GC信息会有什么变化:

public class SerialGC{
    public static void main(String[] args){
        int m = 1024*1024;
        byte[] b = new byte[7*m];
        byte[] b2 = new byte[1*m];
    }
}
  • 新的GC信息如下:
[GC (Allocation Failure) [DefNew: 8019K->536K(9216K), 0.0112137 secs] 8019K->7704K(19456K), 0.0112710 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
Heap
 def new generation   total 9216K, used 1642K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  13% used [0x00000000fec00000, 0x00000000fed14930, 0x00000000ff400000)
  from space 1024K,  52% used [0x00000000ff500000, 0x00000000ff586060, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 7168K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  70% used [0x00000000ff600000, 0x00000000ffd00010, 0x00000000ffd00200, 0x0000000100000000)
 Metaspace       used 2599K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

在新的GC信息中我们可以看出,我们修改后的程序在运行时在新生代发生了一次Minor GC,现在我就来为大家解释一下这些信息:
[GC (Allocation Failure) [DefNew: 8019K->536K(9216K), 0.0112137 secs] 8019K->7704K(19456K), 0.0112710 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
DefNew: 是垃圾收集器的名称,我们这个例子里是单线程(single-threaded), 采用标记复制(mark-copy)算法的, 使整个JVM暂停运行(stop-the-world)的年轻代(Young generation) 垃圾收集器(garbage collector)

8019K->536K(9216K), 0.0112137 secs:NewGenMemBeforeGC->NewGenMemAfterGC(TotalNewGenMem), NewGenPauseTime

  • NewGenMemBeforeGC 表示新生代在GC发生前所占用的内存大小
  • NewGenMemAfterGC 表示新生代在GC发生后所占用的内存大小
  • TotalNewGenMem 表示新生代内存的总大小
  • NewGenPauseTime 表示在新生代进行内存回收时JVM暂停处理的时间

8019K->7704K(19456K), 0.0112710 secs:HeapMemBeforeGC->HeapMemAfterGC(TotalHeapMem), HeapPauseTime

  • HeapMemBeforeGC 表示JVM Heap在发生GC前占用的内存
  • HeapMemAfterGC 表示JVM Heap在发生GC后占用的内存
  • TotalHeapMem 表示JVM Heap所占有的总内存
  • HeapPauseTime 表示在GC过程中JVM暂停处理的总时间

[Times: user=0.01 sys=0.00, real=0.01 secs]

  • user – 此次垃圾回收, 垃圾收集线程消耗的所有CPU时间(Total CPU time).
  • sys – 操作系统调用(OS call) 以及等待系统事件的时间(waiting for system event)
  • real – 应用程序暂停的时间(Clock time). 由于串行垃圾收集器(Serial Garbage Collector)只会使用单个线程, 所以 real time 等于 user 以及 system time 的总和.

现在我们来关注一下GC信息中的具体数据,在之前程序中只新建了一个7M的数组时,新生代的Eden区内存已经占了99%;当我们新建一个1M的数组时,由于Eden区内存不足以放下一个1M的数组,因此触发了一次Minor GC,Eden区的对象应该被存储到to区内,但是由于Eden区中的数组b(7M)是一个远远超过to区(1M)内存的大对象,因此数组b被直接放入到了老年代,这就是为什么新程序的GC信息中写着:tenured generation total 10240K, used 7168K;在Eden区又有了空间之后,新的数组b2就被成功分配在Eden区了。

2. ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本,它的收集算法,对象分配规则,回收策略等都与Serial收集器完全相同,是针对新生代内存回收的垃圾收集器。当新生代内存不够用时,它也会暂停全部用户线程,然后开启若干条GC线程使用复制算法并行进行垃圾回收,它和Serial Old收集器配合使用的效果如下图:

24d38305c4b84d3d9c563c53478ee8ce-ParNew.jpg

我们可以使用 -XX:+UseParNew来显示指定使用ParNew收集器,它默认开启的收集线程数与CPU的数量相同,可以使用-XX:ParallelGCThreads参数来限制垃圾收集线程的数量。

3. Parallel Scavenge 收集器和Parallel Old收集器

Parallel Scavenge 收集器与ParNew收集器十分的相似,同样是针对新生代的垃圾回收器,同样采用了复制算法,也是并行的多线程垃圾收集器。那么它的特别之处在哪里呢?

实际上,Parallel Scavenge 收集器的侧重点在于精准地控制垃圾收集停顿时间和吞吐量(Throughput)。所谓吞吐量就是CPU运行用户代码的时间与CPU消耗的总时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),假设我们的Java虚拟机总共运行了100分钟,垃圾收集用时1分钟,吞吐量就是99%。

Parallel Scavenge 收集器提供了-XX:MaxGCPauseMillis参数来控制最大垃圾收集停顿时间和-XX:GCTimeRatio来控制吞吐量大小。除了这两个参数之外,它还有一个开关参数-XX:+UseAdaptiveSizePolicy,当这个参数打开后,我们无需手动指定新生代大小(-Xmn),Eden与Survior区的比例(-XX:SurvivorRatio),晋升老年代对象大小(-XX:PretenureSizeThreashold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种自适应的调节策略也是Parallel Scavenge 收集器与ParNew收集器一个重要区别。

至于Parallel Old收集器就是Parallel Scavenge 收集器针对老年代内存回收的版本,它采用的是多线程和“标记-整理算法”。这两个收集器配合使用的效果图如下:

d9ca3b6c7c6d40b6bc98c067b1806cfd-parallel.jpg

4. CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一款用于老年代内存回收的侧重于获取最短停顿时间的垃圾收集器,当我们的应用对响应时间有着比较严格的要求时,CMS收集器能够提供较短停顿时间,从而给用户带来较好的体验。

CMS收集器的收集过程包括了以下几步:

  • 初始标记 (CMS initial mark)
  • 并发标记 (CMS concurrent mark)
  • 重新标记 (CMS remark)
  • 并发清除 (CMS concurrent sweep)

其中,初始标记和重新标记两个步骤仍然需要stop the world。初始标记用于快速标记所有GC Roots能够到达的对象;并发标记就是恢复用户程序,同时并发地跟踪GC Roots;重新标记则是为了标记并发标记期间由于用户程序继续运行而导致的可达性发生变化的对象,这个阶段会比初始标记阶段的时间更加长一些,但是远远小于并发标记的时间;最后恢复用户程序,并发地清除未标记的垃圾对象。收集过程如下图所示:

2686f59d1efa4beebbc12db867544de2-CMS.jpg

但是CMS收集器也有以下几个缺点:

  1. 由于GC线程与应用程序并发执行时会抢占CPU资源,因此会造成整体的吞吐量下降。也就是说,从吞吐量的指标上来说,CMS搜集器是要弱于parallel scavenge搜集器的。
  2. CMS收集器无法处理浮动垃圾(由于在并发清除过程中用户的线程还在运行,伴随着程序运行而产生的垃圾对象就叫做浮动垃圾)。
  3. CMS收集器由于采取了“标记-清除”算法因此不可避免地会产生内存碎片。为了解决这个问题CMS收集器增加了碎片自动整理功能。

5. G1收集器

G1(Garbage First)收集器是目前收集器技术发展的最前沿结果之一。不同于之前的各种收集器针对于整个新生代或者是老年代,使用G1收集器时,Java堆内存的布局被分为了多个大小相等的独立区域(Region),虽然还保留着新生代和老年代的概念,但是新生代与老年代不再是物理隔离的了,它们都是一部分Region的集合。

G1收集器的工作原理就是跟踪每个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需的时间),在后台维护一个优先列表,每次根据允许的收集时间,有限回收价值最大的Region(这也就是Garbage First名称的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获得尽可能高的收集效率。

它的收集过程如下:

  • 初始标记 (initial marking)
  • 并发标记 (concurrent marking)
  • 最终标记 (final marking)
  • 筛选回收 (live data counting and evacuation)

初始标记阶段仅仅是标记GC Roots可达的对象,并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段时从GC Roots开始对堆中对象进行可达性分析,找出存活对象;而最终标记阶段则是为了修正在并发标记阶段因用户程序继续运作而导致标记产生变化的标记记录;最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,然后根据用户所期望的GC停顿时间来指定回收计划,最终回收对象。运行过程如下图所示:

1ed58853219a4148b237b777d024c27a-G1.jpg

6. 常见的组合GC

  • Serial 与 Serial Old组合: 新生代使用Serial收集器,老年代使用Serial Old收集器。这个组合是client模式下的默认垃圾搜集器组合,我们可以通过参数-XX:+UseSerialGC显示开启。由于两个收集器都是串行收集内存,因此比较适合小型应用程序或平常我们开发,调试的程序。
  • Parallel Scavenge 与 Parallel Old组合:新生代使用Parallel Scavenge收集器,老年代使用Parallel Old收集器。这个组合采用了多线程并行的垃圾回收机制,因此比较适合一些对吞吐量有一定要求的程序,同时由于时多线程工作,这个组合对CPU核数的要求也比较高。可以通过-XX:+UseParallelGC参数显示开启。
  • ParNew,CMS和Serial Old组合:新生代使用ParNew收集器,老年代使用CMS收集器当出现ConcurrentMode Failure 或 PromotionFailed时会采用Serial Old收集器。这个组合比较适合对响应时间有着比较强需求,需要提供较好的用户体验的后台程序,最典型的就是Java Web程序。可以通过参数-XX:+UseConcMarkSweepGC显示开启。

7.总结

本篇博客为大家介绍了目前市面上比较常见的几个垃圾收集器以及它们采取的垃圾收集算法,同时也简单的介绍了几个大家可能用到的相关虚拟机参数。希望大家通过我给出GC日志的例子中,学会阅读GC详细信息,从而在问题发生时能够更加快速地定位问题,解决问题。不知不觉,这篇博客就写的比较长了,我们下篇博客再见~。

你可能感兴趣的:(JavaGC,Java垃圾收集器,GC日志分析,串行垃圾收集器,并行垃圾收集器,Java虚拟机)