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

本贴最后更新于 2530 天前,其中的信息可能已经时移俗易

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

收集算法可以被看作内存回收问题的理论基础,而不同的垃圾收集器就是内存回收的具体实现了。由于在 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 详细信息,从而在问题发生时能够更加快速地定位问题,解决问题。不知不觉,这篇博客就写的比较长了,我们下篇博客再见~。

  • B3log

    B3log 是一个开源组织,名字来源于“Bulletin Board Blog”缩写,目标是将独立博客与论坛结合,形成一种新的网络社区体验,详细请看 B3log 构思。目前 B3log 已经开源了多款产品:SymSoloVditor思源笔记

    1063 引用 • 3453 回帖 • 203 关注
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3187 引用 • 8213 回帖
  • 收集器
    2 引用

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...