Java垃圾收集机制

Java垃圾收集机制


   本文起名为Java垃圾收集机制,给人的感觉就像是垃圾收集是Java语言特有的。事实上,垃圾收集(Garbage Collection)远比Java久远。垃圾收集需要考虑3件事情:哪些内存需要回收、什么时候回收、如何回收。带着这三个问题,我们去看看Java是如何实现垃圾回收的。

  Java的垃圾回收(GC)机制主要作用于运行时数据区的哪些部分呢?在上篇文章“Java虚拟机工作原理”中我们介绍了JVM运行时数据区有程序计数器、虚拟机栈、本地方法栈、堆、方法区5个区域。其中前三个区域随线程的创建而创建,随线程的消亡而消亡;栈中的栈帧随着方法的进入和退出而有条不紊地执行出栈和入栈操作。因此这三个区域的不需要过多的考虑垃圾回收问题。而Java堆和方法区则不一样一个接口的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。因此垃圾收集器所关注的也就是这部分内存。

  回到垃圾收集的第一件事上:哪些内存需要回收?Java堆中存放着程序中几乎所有的对象实例,垃圾收集器在对堆进行回收前,首先需要判断哪些对象还“活着”,哪些已经“死去”。通常判断的方法有引用计数算法、可达性分析算法。引用计数算法给对象中添加一个引用计数器,每当一个地方引用它时,计数器值加1;当引用失效时,计数器值减1,如果计数器的值为0,则说明对象不再被使用(死去了)。然而Java虚拟机中并没有选用计数算法来管理内存,因为引用计数算法难以解决对象之间相互循环引用的问题。可达性分析算法是将一系列称为“GC Roots”的对象作为起始节点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的(也死去了)。其中可作为GC Roots对象的有:虚拟机栈(栈帧中本地变量表)中引用的对象,方法去中静态属性引用的对象,方法区中常量引用的对象,本地方法栈中引用的对象。上边说的都是Java堆中的内存回收,而方法区(HotSpot中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用类。判断一个常量是否是废弃常量只需判断是否还存在对该常量有引用的对象。而判断无用类需要同时满足3个条件:该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例;加载该类的ClassLoader已经被回收;该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

  • 标记-清除算法(Mark-Sweep算法)

  算法分为两个部分(标记、清除),首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法主要有两个不足:一个是效率问题,标记和清除两个过程的效率都不高;一个是空间问题,标记清除后会产生大量的内存碎片。标记清除算法的执行过程如下图所示:

Java垃圾收集机制

  • 复制算法

  为了解决标记-清除算法效率问题,复制算法将可用内存两等分,每次只使用其中一部分,当使用的部分用完,就将存活的对象复制到令一块中,然后将使用过的部分一次清除。这样避免了内存碎片的额问题,但是内存空间的利用率不高。复制算法执行如下:

Java垃圾收集机制

现在的商业虚拟机都采用这种方法手机新生代,因为新生代中的对象98%是“朝生夕死”,所以并不需要按照1:1分配内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间。当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和用过的Survivor空间。如果回收时,Eden和Survivor中还存活的对象空间大于另外一块Survivor空间时,这些存活的对象可直接通过分配担保机制进入老年代。

  • 标记-整理算法

  复制算法在对象存活率较高时就需要进行较多的复制操作,效率会变低。而且复制算法会浪费50%的内存空间。老年代中对象的存活率较高,所以在老年代一般不能直接选用复制算法。根据老年代对象存活率较高的特点,“标记-整理”算法应运而生,该算法首先也是标记所有需要回收的对象,然后将存活的对象都向一端移动,然后直接清理掉端边界以外的内存。如下图所示:

Java垃圾收集机制

分代收集算法

  该算法并没有新的思想,只是根据对象存活周期的不同将内存划分为几块。一般把Java堆分为新生代和老年代,这样就可以根据各年代的特点采用适当的收集算法,对于新生代中大批对象死去的特点,选择复制算法;针对老年代中对象存活率高的特点,使用“标记-清除”或“标记-整理”算法进行回收。

垃圾收集器

  垃圾收集器就是垃圾回收算法的具体实现了,我们先看一下JDK 1.7 Update 14之后的HotSpot虚拟机中包含的所有收集器如下图,我们来依依分析如下垃圾回收器。

Java垃圾收集机制

  • Serial收集器、Serial Old收集器

  Serial收集器是一个单线程的收集器,在单线程完成垃圾收集工作并回收垃圾时,必须停止其他所有的工作线程。Serial收集器新生代采取复制算法进行垃圾回收。而Serial Old收集器针对老年代采用标记-整理算法暂停所有用户线程。其工作过程如下:

Java垃圾收集机制

  • ParNew收集器

  ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为如Serial收集器的控制参数、收集算法、停顿、回收策略等都与Serial收集器完全一样。其工作过程如下:

Java垃圾收集机制

ParNew是Server模式下的虚拟机的首选新生代回收器,在新生代除了Serial回收器,也只有ParNew回收器能和CMS回收器配合工作。

  • Parallel Scavenge收集器、Parallel Old收集器

  Parallel Scavenge收集器是一个新生代收集器,也是采用复制算法,并行的多线程收集器。它和ParNew收集器关注的侧重点不同,它的目标是达到一个可控制的吞吐量,吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。吞吐量越高说明CPU的利用率越高,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。Parallel Scavenge收集器提供两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。由于Parallel Scavenge收集器是与吞吐量密切相关的,因此也经常被称为“吞吐量优先”收集器,除了如上两个参数外,收集器还有-XX:+UseAdaptiveSizePolicy参数,这个参数打开后,就不需要手动指定新生代的大小(-Xmn)、Eden与Survivor的比例(-XX:SurvivorRatio)、晋升老年代对象的大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

  Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。采用Parallel Old和Parallel Scavenge收集器组合可以达到较高的吞吐量。

  • CMS收集器

  CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。从名字看,CMS收集器是基于“标记-清除”算法实现的,它的运作过程比其他收集器更加复杂,整个过程分为4个步骤:初始标记、并发标记、重新标记、并发清除。其中初始标记、重新标记仍然需要暂停所有线程。初始标记仅仅标记GC Roots能直接关联到的对象。并发标记就是进行GC Roots Tracing的过程。重新标记是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的时间一般比初始标记阶段稍长,但远比并发标记��时间短。但收集器最长的并发标记和并发清除都可以和用户线程一起执行。工作过程如下:

Java垃圾收集机制

  CMS是一款并发低停顿的收集器,但是还主要有一下3个明显缺点:

1.CMS收集器对CPU资源非常敏感,在并发标记、并发清理阶段虽然不会导致用户线程停顿,但是会占用一部分CPU资源而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4。在CPU数量较少时,CMS对用户程序的影响可能更大。

2.CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”而导致一次Full GC的产生。由于并发清理阶段用户线程还在运行,因此就会有新的垃圾产生,且CMS无法在当次收集中处理掉,需要留到下一次GC时再清理。这部分垃圾就是浮动垃圾。正是由于并发清理阶段用户线程还在执行,那也就是需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其它收集器一样等到老年代几乎完全被填满才进行收集,需要预留一部分空间提供并发收集时的程序运作使用。如果CMS运行期间预留内存无法满足需要会出现“Concurrent Mode Failure”失败,从而触发Serial Old收集器进行垃圾回收。

3.CMS是一款基于标记-清除算法的回收器,因此会产生内存碎片。为解决内存碎片问题,CMS提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,用于在CMS收集器要进行FullGC时开启内存碎片的合并整理过程,内存整理过程是无法并发的,因此停顿时间会变长。因此虚拟机设计者还提出了-XX:CMSFullGCsBeforeCompaction参数,这个参数用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。

  • G1收集器

 G1(Garbage-First)收集器是一款面向服务端应用的垃圾收集器,与其他GC收集器相比,G1具备以下特点:

1.并行与并发:G1充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短停顿时间,部分其它收集器原本需要停顿Java线程执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

2.分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新创建的对象和已经存活了一段时间,熬过多次GC的旧对象以获取更好的收集效果。

3.空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

4.可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  其它收集器收集的范围是新生代或老年代,而使用G1收集器时,Java堆内存布局与其他收集器有很大差别,它将Java对划分成多个大小相等的独立区域,虽保留了新生代和老年代的概念,但新生代和老年代不再是物理隔离的,它们都是一部分Region的集合。

  G1收集器之所以能够建立可预测的停顿时间模型,是因为它可以有计划的避免对整个堆中进行垃圾回收。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所需要的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,从而提高了在有限时间内获取尽可能高的收集效率。

G1是把内存“化整为零”,但是各个Region并不是不相关的,因此在某个Region上进行垃圾回收时,对对象做可达性判定确定对象是否存活时,难道还需要扫描整个Java堆才能保证正确性吗?其实不然,在G1收集器中,Region之间的对象引用,虚拟机使用Remembered Set来避免对全堆的扫描。每个Region都有一个Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,都会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region中,如果是,便通过CardTable把相关的引用信息记录到被引用对象所属的Region的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

  如果不计算维护Remembered Set的操作,G1收集器的运作大致分为4个步骤:初始标记、并发标记、最终标记、筛选回收。初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面。最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各Region的回收价值和成本进行排序。根据用户所期望的GC停顿时间来制定回收计划。

Java垃圾收集机制

相关推荐