上图基本列出了HotSpot虚拟机中的几种垃圾收集器。其中蓝线部分表示这些垃圾收集器可以组合搭配使用。
由于J2SE 5.0开始,jvm会默认根据其运行的操作系统等因素,自动选择比较合适的垃圾收集器。一般来说这种选择还是不错的,当然也可以通过参数指定你想要使用的收集器。
如果不清楚当前用的是哪种收集器,下面的代码可以帮助进行识别
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
public class GCInformation {
public static void main(String[] args) {
List<GarbageCollectorMXBean> gcMxBeans =
ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean gcMxBean : gcMxBeans) {
System.out.println(gcMxBean.getName());
}
}
}
下面大致挑选Parallel Old、CMS、G1收集器简单说明下
Parallel Old
Parallel Old很适合于批处理、billing、payroll、科学计算等类型的任务。一般这些任务要求的堆内存比较大,要求整体的吞吐量高,并且可以接受出现较长时间的垃圾收集引起的停顿。
老生代的收集消耗受到其存储对象数量多少的影响,而不是堆本身的大小。因此可以用更多的内存换取更高的吞吐量,并且接受时间更长但次数更少的收集停顿。
CMS - Concurrent Mark Sweep
1. 并发
提到CMS,其首先引人注目的就是其提供的并发性Concurrent,这不同于Serial Old的串行,代表着CMS可以与用户线程同时工作。不过实际上,这里的Concurrent前需要加一个修饰 - the mostly,也就是说从坏的角度解释,它仍然不是全并发的。
之所以说CMS是 the mostly的,有几个原因。首先是其实现上采取了多阶段完成一个回收周期中的标记、清除任务的。这里有4个阶段,其中有些阶段是并发的,有些仍然是需要stop the world的:
- 初始标记 - STW
- 并发标记 - Concurrent
- 重新标记 - STW
- 并发清除 - Concurrent
从上面可以看出,1和3的阶段仍然不能用并发方式完成。但是这两个阶段的用时是很短的,因为初始标记仅仅是标记一下gc roots能直接关联到的对象,而重新标记是检查并发标记阶段用户线程导致标记发生了变动的那一部分对象的标记记录,虽然其时间要长于初始标记,但仍然远远短于并发标记阶段。
而并发标记就是通过gc roots进行tracing的过程,耗时很长。最后,当jvm确定所标记的对象没有问题了,就会进入并发清除阶段,由于这时的对象已经明确是死亡的了,所以可以安全的进行回收。这两个阶段都耗时较长,并且可以与用户线程并发执行。
为了对标记阶段实现并发,CMS需要解决concurrent marking race的问题,这个问题是由于并发执行时,用户线程可能将一个CMS还未检查区域中的引用拷贝到已检查区域中,导致CMS在回收时误将该引用对应的对象进行了回收,进而破坏了堆。
有两种方法解决这个问题:incremental update和“snapshot at the beginning” - SATB。CMS采用了后者,它在标记周期开始时,抓取存活对象的快照,并利用pre-write barrier来跟踪快照中对象的变化。由于HotSpot采用分代收集,并且已经有write barrier来跟踪对象引用的变化,并记录在card table中,因此,在标记阶段,我们可以用某种方式清理card table,这样清理之后card table中累计的新变化就是并发标记阶段用户线程引起的新变化,收集器可以重新检测这些变化并重新进行标记。由于这种用户引发的变化会不断发生,因而上面的过程会不断重复执行。最终,当收集器发现剩下的标记任务量很小的时候,它就会出发一个很短的stop the world过程,完成最后剩下的重新标记过程。
2. 并发失败
由于标记、清除是并发执行的原因,如果用户线程引起的对象变化快于处理的过程,导致CMS跟不上这个变化速度,就会产生所谓的浮动垃圾floating garbage,这些垃圾只有留待下一次回收周期中处理。为了保证浮动垃圾的存在不会导致用户线程没有内存可用,CMS需要预留一部分空间供并发收集时给用户线程使用量,例如jdk 1.5中,默认老生代使用到68%就会激活垃圾回收。如果连预留的这部分内存也不够使用了,就可能引起concurrent mode failure的问题,当其发生时,会记录到GC的日志。这时就需要Serial Old这个备用收集器接管老生代的收集工作。
3. 碎片与full gc
CMS的老生代清理使用free list记录空出来的内存空间,它并不会每次都对内存进行整理compaction,因而会产生内存碎片。最终当碎片太多,导致新生代的对象无法升级promotion到老生代中时,就会引发一次full gc来进行compact,这个过程是stop the world的。如果在gc日志中看到“promotion failure”,或者出现大于1、2秒的停顿,那说明发生了提升失败。
JVM提供了-XX:+UseCMSCompactAtFullCollection来完成上面的compact执行过程,其默认开启,使得CMS在每次full gc都会进行碎片整理。但这导致了停顿时间变长。因此又提供了-XX:CMSFullGCsBeforeCompaction来设置执行多少次不compact的full gc后再执行一次带compact的收集。
4. CPU
由于并发的原因,在该阶段收集器会与用户线程共享cpu资源。这相应的会降低应用的总吞吐量。CMS默认开启的并发线程是 (CPU数 + 3)/ 4,也就是当CPU数量大于4时,回收会利用最高25%的cpu资源,并随着CPU数量增加这个比例减少。但当CPU数量小于4时,垃圾回收对用户线程的影响就比较大了。相比Parallel收集器,CMS减少的应用吞吐量大致是10% - 40%。
总的来说,CMS的停顿时间低,适用于桌面UI应用、相应用户请求的Web Server、响应数据库查询的服务等要求低停顿的场景。
G1 - Garbage First
G1收集器在技术实现上有很多和老一辈收集器相似的地方。例如,它同样使用monolithic、STW的方式收集新生代,使用和CMS很相似的the mostly concurrent标记阶段,具有concurrent sweep阶段,新生代区同样由eden和survivor的regions组成,使用STAB解决并发标记竞争的问题,等等。
当然,它也有不同于其他收集器的地方。首先是其内存区域划分为多个大小相等的region,虽然仍然有分代的概念,但从物理上新生代和老生代不再是隔离的两个区域,它们分别由一些region组成,并且在物理上不要求这些region在内存中必须相互连续的占据一段空间,如下图所示。
另一个不同的地方是其处理compaction的方式,G1使用一种被称为“incremental compaction”的技术,该技术用于尽量避免full gc的发生。由于G1也使用了remembered set来跟踪region间的对象引用,如果当heap中没有任何引用指向某一个region,那么该region就可以被安全的compact,并且不用做remap的工作。
该方法也显露了一个现象,就是有些region比其他region使用得更频繁,通俗的说就是更受欢迎。G1的一些实现就是基于这个假设的,但这也是G1的问题之一,因为该假设并非总是正确的。
1. G1的Remembered Set
由于G1使用region来分块管理对象,因此以前的收集器使用remembered set来跟踪老生代向新生代中对象的引用,就扩展到了每个region之间进行跟踪。这使得实现变得更复杂,每个region都有其自己相关的一个RS,因此RS的总量可能很大。
当虚拟机发现程序对引用类型的数据进行写操作时,会产生一个write barrier暂时中断这个写操作,并检查引用对象是否处于不同的region中。如果是,就通过CardTable把相关引用信息记录到被引用对象所属region的RS中
2. 收集过程
大致上,G1的收集过程也可以划分为4个阶段:
- 初始标记 - STW
- 并发标记 - Concurrent
- 最终标记 - STW、Parallel
- 筛选回收
可以看出,这里的前3个过程和CMS的前3个过程很相似。当然细节实现还是有差别的。
对于最终标记阶段,虚拟机将并发标记这段时间对象的变化记录在线程的Remembered Set Logs中,并在最终标记阶段将Remembered Set Logs的数据合并到Remembered Set中,因此需要停止用户线程,引发STW,但可以并行的执行(Parallel)。
对于筛选回收阶段,虚拟机对各个region的回收价值和成本进行排序,形成一个优先列表,这也是Garbage First名称由来的原因。然后根据用户期望的gc停顿时间来制定回收计划。其实从官方透露的信息看也可以做到Concurrent,但是由于只回收一部分region,使用STW可以大幅提高效率。