五、垃圾收集入门

很多时候,我们没有机会重写代码,又要面临需要提高 Java 应用性能的压力,这种情况下对垃圾收集器的调优就变得至关重要。

  1. Serial 收集器(常用于单 CPU环境)。
  2. Throughput(或者 Parallel)收集器。
  3. Concurrent 收集器(CMS)。
  4. G1 收集器。

1 垃圾收集概述

简单来说,垃圾收集由两步构成:查找不再使用的对象,以及释放这些对象所管理的内存。 JVM 从查找不再使用的对象(垃圾对象)入手。有时,这也被称为查找不再有任何对象引用的对象(暗指采用“引用计数”的方式统计对象引用)。

例,如下场景:一个程序要分配大小为 1000 字节的数组,紧接着又分配一个大小为 24 字节的数组,并在一个循环中持续进行这样的分配。最终程序会耗尽整个堆,结果如下图的第一行所示:堆空间被沾满,分配的数组间隔地分布于整个堆内:
![][1]
堆内存用尽会触发 JVM 回收不再使用的数组空间。假设所有大小为 24 字节的数组都不再被使用,而大小为 1000 字节的数组还继续使用,这样就形成了上图的第二行的场景。
虽然堆内部有足够的空闲空间,却找不到任何一个大于 24 字节的连续空间,除非 JVM 移动所有大小为 1000 字节的数组,让它们连续存储,把空闲的空间整合成一块更大的连续空间,供其他的内存分配使用(如上图的第三行)。

而垃圾收集的性能就是由这些基本操作所决定的:找到不再使用的对象、回收它们使用的内存、对堆的内存布局进行压缩整理。完成这些操作时不同的收集器采用了不同的方法,这也是不同垃圾收起表现出不同性能特征的原因。
通常垃圾收集器自身往往也是多线程的。接下来的讨论中,我们从逻辑上将县城分成了两组,分别是应用程序线程和处理垃圾收集的线程。垃圾收集器回收对象,或者在内存中移动对象时,必须确保应用程序线程不再继续使用这些对象。这一点在收集器移动对象时尤其重要:在操作过程中,对象的内存地址会发生变化,因此这个过程中任何应用线程都不应再访问该对象。

所有应用线程都停止运行所产生的停顿被称为时空停顿(stop-the-world)。通常这些停顿对应用的性能影响对打,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。

1.1 分代垃圾收集器

虽然实现的细节千差万别,但所有的垃圾收集器都遵循了同一个方式,即根据情况将堆划分成不同的代(Generation)。这些代被称为“老年代”(Old Generation 或 Tenured Generation)和“新生代”(Young Generation)。新生代又被进一步划分为不同的区段,分别称为 Eden 空间和 survivor 空间(不过 Eden 有时会被错误地用于指代整个新生代)。

新生代被填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其它地方。这种操作被称为 Minor GC。
采用这种设计有两个性能上的优势:

  1. 新生代仅是堆的一部分,这意味着应用线程停顿的时间更短。但是更加频繁。
  2. 对象分配与 Eden 空间,垃圾收集时,新生代空间被清空,Eden 空间的对象要么被移走,要么移动到另一个 Survivor 空间,要么被移动到老年代。这就相当于自动的进行了一次压缩整理。

所有的垃圾收集算法在对新生代回收时都存在“时空停顿”现象。

JVM 需要找出老年代中不再使用的对象,并对它们进行回收。而这便是垃圾收集算法差异最大的地方。简单的:停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对对空间进行整理。这个过程称为 Full GC。这通常会导致应用线程长时间的停顿。

另,通过更复杂的计算,我们还有可能在应用线程运行的同时找出不再使用的对象;
CMS 和 G1 收集器就是通过这种方式进行垃圾收集的。由于它们不需要停止应用线程就能找出不再用的对象, CMS 和 G1 收集器被称为 Concurrent 垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿(Low-Pause)收集器。Concurrent 收集器也使用各种不同的方法对老年代空间进行压缩。

使用 CMS 和 G1 收集器时,应用程序经历的停顿会更少(也更短),代价是会消耗更多的 CPU。

  1. 所有的 GC 算法都将堆划分成了老年代和新生代。
  2. 所有的 GC 算法在清理新生代对象时,都使用了“时空停顿”(stop-the-world)方式的垃圾收集方法。

1.2 GC 算法

JVM 提供了以下四种不同的垃圾收集算法。

1.Serial 垃圾收集器

它是单线程清理堆的内容。使用 Serial 垃圾收集器,无论是进行 Minor GC 还是 Full GC ,清理堆空间时,所有的应用线程都会被暂停。

2.Throughput 垃圾收集器

Throughput 收集器是 Server 级虚拟机(多 CPU)的默认收集器。
使用多线程回收新生代空间, Minoc GC 的速度比使用 Serial 收集器快得多。处理老年代在 JDK7 之后默认也是多线程。因为其使用多线程,也被称为 Parallel 收集器。
在 Minor GC 和 Full GC 时会暂停所有的应用线程,同时在 Full GC 过程中会对老年代空间进行压缩整理。

3.CMS 收集器

CMS 收集器设计的初衷是为了消除 Throughput 收集器和 Serial 收集器 Full GC 周期中的长时间停顿。 CMS 收集器在 Minor GC 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。
它不使用 Throughput 的收集算法(-XX:+UseParallelGC),而是用新的算法(-XX:+UseParNewGC)来收集新生代对象。
它在 Full GC 不再暂停应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象。这种算法使 CMS 成为一个低延迟的收集器:应用线程只在 Minor GC 以后后台线程扫描老年代时发生极其短暂的停顿。
代价是额外的 CPU 使用率。而且后台线程不再进行任何压缩整理的工作,这意味着逐渐碎片化,碎片化一定程度, CMS 会降级为 Serial 收集器:暂停所有应用线程,使用单线程回收。之后再恢复到并发回收。(这种思想在写锁降级为读锁也有体现)。

4.G1 收集器

G1 垃圾收集器(垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于4 GB)时产生的停顿。G1 收集算法将老年堆划分为若干个区域(Region),不过它依旧属于分代收集器。这些区域中的一部分包含新生代,新生代的垃圾收集仍然采用暂停所有应用线程的方法,将存活对象移动到老年代或者 Survivor 空间,这也是多线程完成的。

G1 收集器属于 Concurrent 收集器:老年代的垃圾收集工作由后台线程完成,大多数的工作不需要暂停应用线程。由于老年代被划分到不同的区域,G1 收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作,这也意味着在正常的处理过程中,G1 收集器实现了堆的压缩整理(至少是部分的整理)。因此,使用G1 收集器的堆不大容易发生碎片化——虽然这种问题无法避免。

通常情况下垃圾收集是由 JVM 在需要的时候触发:新生代用尽时会触发 Minor GC,老年代用尽时会触发 Full GC,或者堆空间即将填满时会触发 Concurrent 垃圾收集。
System.gc() 让应用程序强制进行 GC, Full GC。应用程序线程会因此而停顿相当长的一段时间。同时,调用这个方法也不会让应用程序更高效,它会让 GC 更早的开始,但那实际只是将性能的影响往后推迟而已。

2 GC 调优基础

2.1 调整堆的大小

如果分配的堆过于小,程序的大部分时间可能都消耗在 GC 上。
如果分配的过于大也不行,GC 停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长,这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。还有一个风险是,操作系统使用虚拟内存机制管理机器的物理内存。一台机器可能有 8G 的物理内存,不过操作系统可能让你感觉有更多的可用内存。虚拟内存的数量取决于操作系统的设置,譬如操作系统可能让你感觉它的内存达到了 16G 。操作系统通过名为“交换”(swapping)(或者称之为分页,虽然两者技术存在差异)。你可以载入需要 16G 内存的应用程序,操作系统在需要时会将程序运行时不活跃的数据由内存复制到磁盘。再次需要这部分内存的内容时,操作系统再将它们由磁盘重新载入到内存(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)。

系统中运行着大量不同的应用程序时,这个流程工作的很顺畅,因为大多数的应用程序不会同时处于活跃状态。但是,对于 Java 应用,它工作得并不那么好。如果一个 Java 应用使用了这个系统上大约 12G 的堆,操作系统可能在 RAM 上分配了 8G 的堆空间,另外 4G 的空间存在于磁盘。这样操作系统需要将相当一部分的数据由磁盘交换到内存,而发生 Full GC 时,因为 JVM 必须访问整个堆的内容,如果系统发生内存交换,停顿时间会更长。

堆的大小由 2 个参数值控制:初始值(-Xms)、最大值(-Xmx)。

2.2 代空间的调整

一旦堆的大小确定下来,JVM 就需要决定分配多少堆给新生代空间,多少给老年代空间。
必须清楚:如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。但是老年代相对比较小,容易填满,会更频繁的触发 Full GC。

  1. -XX:NewRatio=N:设置新生代与老年代的空间占用比率
  2. -XX:NewSize=N:设置新生代空间的初始大小
  3. -XX:MaxNewSize=N:设置新生代空间的最大大小
  4. -XmnN:将 NewSize 和 MaxNewSize 设定为同一个值的快捷方法。

2.3 永久代和元空间的调整

JVM 载入类的时候,它需要记录这些类的元数据。这部分数据被保存在一个单独的堆空间中。在 Java7 里,这部分空间被称为永久代(Permgen),在 Java8 中,它们被称为元空间(Metaspace)。
永久代和元空间并不完全一样。Java7 中永久代还保存了一些与类数据无关的杂项对象;这些对象在 Java8 中被挪到了普通的堆空间内。它们保存的信息只对编译器或者 JVM 的运行时有用。
通过 -XX:PermSize=N、-XX:MaxPerSize=N 来调整永久代大小。
通过 -XX:MetaspaceSize=N、-XX:MaxMetaspaceSize=N 来调整元空间的大小。

调整这些区间会触发 Full GC ,所以是一种代价昂贵的操作。如果程序在启动时发生大量的 Full GC(因为需要载入数量巨大的类),通常都是由于永久代或者元空间发生了大小调整。

3 垃圾回收工具

开启 GC 的日志功能:使用 -verbose:gc 或 -XX:+PrintGC 的任意一个能创建基本的 GC 日志。使用 -XX:+PrintGCDetails 创建更详细的 GC 日志。

4 小结

多说无益,多尝试。

![1]: http://leran2deeplearnjavawebtech.oss-cn-beijing.aliyuncs.com/learn/Java_performance_definitive_guide/5_1.png