许多高级编程语言都带有自动垃圾回收特性,以将程序员从繁琐复杂的内存分配和释放工作中解脱。本文将概述常见的垃圾回收算法,并详细介绍JVM众多垃圾回收器的特性、区别和选择指北。
垃圾回收算法
垃圾回收算法本质上是“识别已不会再被使用的内存数据并将其清除释放”的算法。其中“识别”这一阶段,主要有两种方式:
引用计数法
统计每个存活对象的引用次数,引用次数为0的对象视为待回收的垃圾对象。
引用计数法的弊端是无法回收循环引用的对象。如A引用B,B引用A,同时A和B都没有其他引用,此时A和B应该都是待回收的垃圾对象,但引用计数法无法识别。
可达性分析法
从GC Roots开始遍历内存池中的所有对象,任何能够从GC Roots直接或间接引用到的对象便被标记为“可达”,待遍历完成后,所有未被标记为“可达”的对象即为待回收的垃圾对象
GC Roots:
垃圾回收器用于进行可达性分析的根对象,GC Roots通常会有多个,以Java为例,一个运行中的JVM,其GC Roots可能包括:
Class - 由系统类加载器(system class loader)加载的对象
Thread - 所有活着的线程
Stack Local - Java方法的local变量或参数
JNI Local - JNI方法的local变量或参数
JNI Global - 全局JNI引用
Monitor Used - 用于同步的监控对象
Held by JVM - 用于JVM特殊目的由GC保留的对象,但实际上这个与JVM的实现是有关的。可能已知的一些类型是:系统类加载器、一些JVM知道的重要的异常类、一些用于处理异常的预分配对象以及一些自定义的类加载器等
由于引用计数法的弊端,已几乎不再被现代的编程语言使用。而可达性分析法则得到了广泛使用,几乎是所有垃圾回收算法标配的垃圾识别方式。
接下来,详细介绍一下常用的垃圾回收算法:
标记-清除算法(Mark-Sweep)
也被称为Mark-Clean算法
标记阶段:从GC Roots开始进行可达性分析,将所有可达对象标记出来
清除阶段:将所有未标记可达的对象清理掉
标记-清除算法带来的问题是内存碎片,被清除掉的对象会在内存中留下很多的“洞”,这些“洞”的大小可能不足以容下新创建的对象,随着碎片的增多,内存池中实际可用的内存可能会变得越来越少
拷贝算法
- 将内存划分为两个区域,一次只使用其中一个(暂称为from区)
- 在进行可达性分析的同时,将可达对象复制到另一个空白的内存区域中(暂称为to区)
- 可达性分析完成后,将to区与from区的身份互换,同时将原先的from区(现在的to区)中的对象清除
拷贝算法可以解决内存碎片的问题,因为向to区中复制的对象使用的都是连续的内存空间。同时拷贝算法的效率要比标记-清除算法高很多(尤其是在存活对象少、垃圾对象多的场景下),但代价是占用了冗余的内存空间。
标记-压缩算法(Mark-Sweep-Compact)
也被称为标记-整理算法、Mark-Compact算法、MSC算法
与标记-清除算法类似,区别在于在标记阶段完成后,将所有可达对象移动到内存池的另一端,形成一整块连续的内存空间,然后再将范围外的内存池清空。
标记-压缩算法的好处是不会产生内存碎片,也不会占用冗余的内存空间,但代价是需要付出额外的工作将存活对象移动,效率低于标记-清除算法。
增量回收(Incremental Collecting)
增量回收与上述的垃圾回收算法不是相互替代的关系,可以将其理解为一种标准垃圾回收算法的使用方式。
在垃圾回收可达性分析的过程中,为了避免分析过程中产生新的对象影响分析的准确性,通常会将进程置于STW(Stop-The-World)状态,即在垃圾回收时挂起所有线程,暂停除垃圾回收外的一切工作。进程占用的内存空间越大,待回收的垃圾对象越多,STW持续的时间就越长。
增量回收的基本思想是每次只收集一小片区域的内存空间,然后恢复其他线程的工作,依次反复,直到垃圾收集完成。使用这种方式能够避免长时间的STW。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分代回收(Generational Collecting)
不同的垃圾回收算法有不同的优势和代价,简单地选用其中一种无法应对可能出现的各种场景。分代回收的思想是将内存池按特点划分为多个区域,针对每个区域的特点应用不同的垃圾回收算法。
以Hotspot VM为例,将堆内存划分为新生代(Young Generation)和老年代(Old Generation)两个区域。新生代中存储新诞生的对象,老年代中存储长时间存活的对象。这样可以让新生代始终处于存活对象少、垃圾对象多的状态,需要比较频繁的进行垃圾回收,适合使用拷贝算法;而老年代则始终处于存活对象多,垃圾对象少的状态,不需要频繁进行垃圾回收,适合使用标记-压缩算法。
JVM垃圾回收器
本节将以HotSpot VM为例,介绍JVM中各种垃圾回收器
Serial
使用拷贝算法的垃圾回收器,只能用于新生代。Serial回收器使用单线程进行垃圾回收。
SerialOld
使用标记-压缩算法的垃圾回收器,只能用于老年代。使用单线程进行垃圾回收。
Serial+SerialOld回收器的组合是JVM最基础的垃圾回收器组合,只使用单CPU,STW时间较长,适用于处理能力不强的主机和对STW时长要求不高的软件,JVM如以client模式启动,则默认使用Serial+SerialOld回收器。
在启动参数中指定-XX:+UseSerialGC,会启用Serial+SerialOld组合
Serial和SerialOld回收器适用于只有单核CPU的运行环境,效率比较低,一般情况下,只用于开发环境或桌面程序
ParNew
Serial回收器的多线程版本,只能用于新生代。使用拷贝算法,多线程并行工作。在多CPU主机上的性能高于Serial,单CPU主机上的性能低于Serial。
在启动参数中指定-XX:+UseParNewGC,会使用ParNew+SerialOld的回收器组合
Parallel Scavenge
与ParNew一样,都是用于新生代的并行拷贝算法回收器。区别在于Parallel Scavenge回收器可以控制新生代垃圾回收的STW时间。
可以通过参数-XX:MaxGCPauseMillis设置最大的STW时间,这样Parallel Scavenge回收器就会通过增加或减少回收次数的方式把每次STW时长尽可能控制在指定的范围内。还可以通过-XX:GCTimeRatio参数指定用于垃圾回收的时间最大占比。
在使用Parallel Scavenge回收器时,最好通过参数-XX:+UseAdaptiveSizePolicy开启回收器的自适应策略,开启后JVM可以根据实际的情况动态调整新生代中Eden、S0、S1三个分区的大小比例,以及晋升老年代对象的年龄等参数,以满足指定的最大STW市场和最大垃圾回收时间占比要求。
在启动参数中指定-XX:+UseParallelGC,会使用Parallel Scavenge+SerialOld的回收器组合。(Hotspot VM中,此时老年代的回收器被称为PS MarkSweep,其实本质上就是SerialOld,具体可见R大对此的说明:http://hllvm.group.iteye.com/group/topic/27629)
Parallel Old
Parallel Scavenge的老年代版本,于JDK1.6中面世。在Parallel Old诞生之前,Parallel Scavenge处于一个比较尴尬的境地,由于没有对应的老年代回收器与其配合,仅在新生代使用Parallel算法很难达到对整体垃圾回收时长和STW时长的控制目的。
而现在,我们可以使用Parallel Scavenge+Parallel Old这一组合来解决这一问题
在启动参数中指定-XX:+UseParallelOldGC,会使用Parallel Scavenge+Parallel Old的回收器组合
作为服务端的JAVA程序,使用Parallel Scavenge+Parallel Old的回收器组合算是基础标配了,这两款回收器的组合能够有效地利用服务器的多CPU配置,提高垃圾回收效率。并且足够简单明了,不怎么需要进行精细的参数调优。
然而,如果应用程序对STW时长有非常严格的要求,那么HotSpot VM还提供了更强大的垃圾回收器:
Concurrent Mark Sweep(CMS)
CMS是标记-清除的改进算法,用于老年代,能够有效减少STW时长。
CMS是一种比较复杂的垃圾回收算法,此处尽可能进行简明扼要的介绍:
CMS将标记-清除细分为6个阶段:
- 初始标记
- 并发标记
- 并发预清理
- 重新标记
- 并发清理
- 并发重置
详细过程:
- 初始标记阶段中,CMS回收器标记出被GC Roots直接引用的对象,这一过程需要STW,但由于只进行深度为1的遍历,耗时很短。
- 并发标记阶段中,CMS回收器以初始标记阶段标记出的存活对象为根进行可达性遍历。在这一阶段中,不需要STW,其他线程可正常运行。
- 并发预清理阶段中,CMS回收器对并发标记阶段中老年代新增的对象重新进行标记。这一阶段存在的目的是尽可能减少下一阶段“重新标记”的STW时长。
- 重新标记阶段会进入STW,然后进行一次完整的可达性分析,由于前面三个阶段已经完成了绝大部分的工作,所以这一阶段的STW会很短。
- 并发清理阶段不需要STW,垃圾回收线程清理标记出的垃圾对象,同时其他线程可以正常工作。
- 并发重置阶段中,重置CMS回收器的数据结构,等待下一次垃圾回收。
可以看出来,CMS回收器的思路是把标记-清除算法的工作拆分成多个步骤,其中可以并行的尽可能并行,以达到STW时长最小化的目标。
同样地,CMS回收器也存在着弊端:
- 对CPU要求高,由于部分标记和清除阶段是免STW并且多线程并行的,这就给CPU增加了很大的线程切换压力,核数少的CPU使用CMS回收器的效果并不好,多核多CPU的高性能主机更加适合使用
- 垃圾回收的同时老年代中仍然在产生新的对象,这是CMS的并行机制导致的。所以CMS回收器不能在老年代满时才开始工作,Hotspot VM 6/7中,CMS回收器在老年代使用率92%时便开始工作。如果在CMS垃圾回收的过程中,新增的对象占满了剩余的8%空间,便会导致CMS回收失败,自动降级至SerialOld重新进行垃圾回收。(CMS垃圾回收触发的时机可以使用-XX:CMSInitiatingOccupancyFraction参数进行设置)
- CMS使用的是标记-清除算法,而不是标记-压缩算法,这就导致会出现大量的内存碎片。随着内存碎片的增多,最终势必会出现垃圾回收的并发阶段中内存不足的情况,如上所说,此时CMS回收器会自动降级为SerialOld回收器,以标记-压缩算法进行垃圾回收,同时也就会整理好内存碎片。
综上所述可以看出,CMS回收器的机制比前述的任何一种都要精细和复杂,同时对CPU资源的要求也要高出很多,以牺牲性能的代价换取最少的STW时长。
在启动参数中指定-XX:+UseConcMarkSweepGC,会开启ParNew+CMS的回收器组合。
CMS回收器在控制STW时长的表现上要比Parallel Old好很多,然而代价是占用更多的计算资源,如果服务器的计算资源有冗余,使用CMS是更好的选择。
Garbage-First(G1)
G1回收器诞生于Hotspot VM的7update4版本,这一最新型的垃圾回收器吸取了CMS回收器的经验和教训,旨在解决CMS回收器的各类弊端,同时提供更短更可控的STW时长。
G1的机制比CMS更加复杂,此处同样尽可能简明的进行介绍:
与其他回收器不同,G1是一个全代回收器,同时负责新生代和老年代的垃圾回收工作。
G1回收器打破了Hotspot VM以往的分代概念,新生代的Eden、S0、S1,以及老年代不再是物理分隔,而变成了灵活的逻辑分隔。G1将堆内存划分为2000个左右相等的内存块,每个内存块的大小为1-32Mb。每个内存块可以作为Eden、S0、S1或老年代使用,也就是说这些块的身份是不固定的。随着每次垃圾回收的完成,有些块的内存会被完全释放掉,成为空白块,而这些空白块在接下来可能成为任何一种角色。
G1在进行Young GC(对新生代进行的垃圾回收)时,会触发STW,并行地将所有Eden块和Survivor From块中的存活对象拷贝至一或多个Survivor To块中,然后将Eden块和Survivor From块清除成为空白块。接下来,G1会根据各种信息动态地计算并重新分配接下来Eden区和Survivor区应占的内存空间大小。
G1在进行Old GC(对老年代进行的垃圾回收)时,会采用类似于CMS回收器的多个阶段进行:
- 初始标记:标记出对老年代对象有引用的内存块,需要STW
- 并行标记:针对初始标记出的内存块进行可达性分析,并计算每个内存块的活跃度(可以理解为该块中的可达对象占比)。如果在这一环节中发现某个内存块中所有的对象都不可达,则将该块标记出来。
- 重新标记:将并行标记阶段中发现的整体不可达内存块彻底清除,同时进行一次完整的可达性分析和活跃度分析,需要STW(由于1、2环节的工作,这一STW过程会很短)
- 拷贝/清除:这一阶段中,G1会选取活跃度较低的内存块,对其进行并行的垃圾回收,具体的垃圾回收算法和G1的Young GC一样,即将待清理的内存块中的存活对象拷贝至一或多个内存块。需要STW
值得注意的是,G1的垃圾回收并非每次都将所有不可达对象完全清除,G1倾向于优先清除活跃度低的内存块,因为这些块中的存活对象少(或者压根没有存活对象),清理速度更快。对于那些活跃度高的内存块,G1会放置不管,直至某个时间点该块的活跃度足够低时再进行回收。
正因为如此,G1进行垃圾回收的时机更早,默认在堆内存使用率达到45%时就会开始垃圾回收。这体现了G1(Garbage-First/垃圾回收优先)这一名字的含义。
至于活跃度多低才会进行回收,则是由G1决定的,G1会调整自己的回收策略来尽可能满足用户设置的最大STW时长。
同时,由于使用拷贝算法,G1回收器不会产生内存碎片,这也是相较CMS的巨大优势之一。
G1回收器能够根据需要动态调整各个分代的内存占比,在快速实例化大量对象时表现尤佳。
G1回收器接收一个最大STW时长的任务指标,并会努力控制每次垃圾回收的STW时长来完成这一指标。默认的指标是200ms,注意不要给G1设置过于过分的任务指标,否则它为了完成任务可能是会乱来的……
G1适合运行在配有强大CPU的主机上的,且占用较大堆内存空间的应用程序中。如果你想要得到最佳的STW时长,并且愿意为此进行一些参数调优工作的话,使用G1回收器通常来说是最好的选择。
通过参数-XX:+UseG1GC 来启用G1回收器,并使用-XX:MaxGCPauseMillis=n来设置最大的STW时长,-XX:InitiatingHeapOccupancyPercent=n来设置触发垃圾回收的堆内存占用比