JVM系列3-GC算法 垃圾收集器概述

96
唐影若凡
2017.01.30 21:04* 字数 6488

声明:原创文章,转载请注明出处。http://www.jianshu.com/u/e02df63eaa87

垃圾收集 Garbage Collection 通常被称为“GC”,它诞生于1960年 MIT 的 Lisp 语言,经过半个多世纪,目前已经十分成熟了。

jvm 中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理,因此,我们的内存垃圾回收主要集中于 java 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的。

1、对象存活判断

判断对象是否存活一般有两种方式:

  • 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题
  • 可达性分析(Reachability Analysis)(JVM引用关系图):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不可达对象。

在Java语言中,GC Roots包括:

虚拟机栈中引用的对象。
方法区中类静态属性实体引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI引用的对象。

2、垃圾收集算法

2.1 标记 -清除算法

“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记””和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除
标记-清除算法
2.2 复制算法

“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

缺点

  • 只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。
  • 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。
复制
复制算法
2.3 标记-压缩(整理)算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。在存活率较低的时候,效率更高。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
通俗的讲:

  • 标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
  • 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。

不难看出,标记-整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价,可谓是一举两得。
缺点:标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法

标记压缩

标记压缩算法
2.4 分代收集算法

GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。

“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法(分成多个区域),只需要付出少量存活对象的复制成本就可以完成收集。
老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-压缩”算法来进行回收。

分代收集
2.5 算法对比

它们的共同点主要有以下两点。

  1. 三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域。
  2. 在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。

它们的区别按照下面几点来给各位展示。(>表示前者要优于后者,=表示两者效果一样)

  1. 效率:复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

  2. 内存整齐度:复制算法=标记/整理算法>标记/清除算法。

  3. 内存利用率:标记/整理算法=标记/清除算法>复制算法。

可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的,俗话说“吃水不忘挖井人”,因此各位也莫要忘记了标记/清除这一算法前辈。而且,在某些时候,标记/清除也会有用武之地。

3、垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。

3.1 Serial收集器 - young

串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。垃圾收集的过程中会Stop The World(STW-服务暂停)。

  • 收集算法:采用复制算法
  • 内存区域:针对新生代设计
  • 执行方式:单线程、串行
  • 执行过程:当新生代内存不够用时,先暂停全部用户程序,然后开启一条GC线程使用复制算法对垃圾进行回收,这一过程中可能会有一些对象提升到年老代。
  • 特点:由于单线程运行,且整个GC阶段都要暂停用户程序,因此会造成应用程序停顿时间较长,但对于小规模的程序来说,却非常适合。
  • 适用场景:平时的开发与调试程序使用,以及桌面应用交互程序。
  • 参数控制:-XX:+UseSerialGC串行收集器
Serial收集器
3.2 ParNew收集器 - young

ParNew(Parallel New并行)收集器其实就是Serial收集器的多线程版本。

  • 收集算法:采用复制算法
  • 内存区域:针对新生代设计
  • 执行方式:多线程、并行
  • 执行过程:当新生代内存不够用时,先暂停全部用户程序,然后开启若干条GC线程使用复制算法并行进行垃圾回收,这一过程中可能会有一些对象提升到年老代。
  • 特点:采用多线程并行运行,因此会对系统的内核处理器数目比较敏感,至少需要多于一个的处理器,有几个处理器就会开几个线程(不过线程数是可以使用参数-XX:ParallelGCThreads=<N>控制的),因此只适合于多核多处理器的系统。尽管整个GC阶段还是要暂停用户程序,但多线程并行处理并不会造成太长的停顿时间。因此就吞吐量来说,ParNew要大于serial,在处理器越多的时候,效果越明显。但是这并非绝对,对于单个处理器来说,由于并行执行的开销(比如同步),ParNew的性能将会低于serial收集器。不仅是单个处理器的时候,如果在容量较小的堆上,甚至在两个处理器的情况下,ParNew的性能都并非一定可以高过serial。
  • 适用场景:在中到大型的堆上,且系统处理器至少多于一个的情况
  • 参数控制-XX:+UseParNewGC ParNew收集器
    -XX:ParallelGCThreads 限制线程数量
ParNew收集器
3.3 Parallel Scavenge收集器 - young

Parallel Scavenge(并行回收)收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量它与ParNew最大的不同就是可设置的参数不一样,它可以让我们更精确的控制GC停顿时间以及吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例。

PS收集器提供参数主要包括控制最大的停顿时间(使用-XX:MaxGCPauseMillis=<N>),以及控制吞吐量(使用-XX:GCTimeRatio=<N>)。由此可以看出,parallel scavenge就是为了提供吞吐量控制的收集器。

不过千万不要以为把最大停顿时间调的越小越好,或者吞吐量越大越好,在使用parallel scavenge收集器时,主要有三个性能指标,最大停顿时间、吞吐量以及新生代区域的最小值。

parallel scavenge收集器具有相应的调节策略,它将会优先满足最大停顿时间的目标,次之是吞吐量,最后才是新生代区域的最小值。

因此,如果将最大停顿时间调的过小,将会牺牲整体的吞吐量以及新生代大小来满足你的私欲。手心手背都是肉,我们最好还是不要这么干。不过parallel scavenge有一个参数可以让parallel scavenge收集器全权接手内存区域大小的调节,这其中还包括了晋升为年老代(可使用-XX:MaxTenuringThreshold=n调节)的年龄,也就是使用-XX:UseAdaptiveSizePolicy打开内存区域大小自适应策略。

**参数控制:-XX:+UseParallelGC 使用Parallel收集器 **,同时它也是server模式下默认的新生代搜集器

Parallel Scavenge参数 描述
MaxGCPauseMillis (毫秒数) 收集器将尽力保证内存回收花费的时间不超过设定值, 但如果太小将会导致GC的频率增加。
GCTimeRatio (整数:0 < GCTimeRatio < 100) 是垃圾收集时间占总时间的比率
-XX:+UseAdaptiveSizePolicy 启用GC自适应的调节策略: 不再需要手工指定-Xmn、-XX:SurvivorRatio、-XX:PretenureSizeThreshold等细节参数, VM会根据当前系统的运行情况收集性能监控信息, 动态调整这些参数以提供最合适的停顿时间或最大的吞吐量

串行与并行收集器的区别:

串行与并行收集器的区别
3.4 Serial Old收集器 - old

Serial Old是Serial收集器的老年代版本。

  • 收集算法:采用标记/整理
  • 内存区域:针对老年代设计
  • 执行方式:单线程、串行
  • 执行过程:当老年代内存不够用时,先暂停全部用户程序,然后开启一条GC线程使用复制算法对垃圾进行回收。
  • 特点:由于单线程运行,且整个GC阶段都要暂停用户程序,因此会造成应用程序停顿时间较长,但对于小规模的程序来说,却非常适合。
  • 适用场景:主要使用在Client模式下的虚拟机、平时的开发与调试程序使用,以及桌面应用交互程序。
    **参数控制: -XX:+UseSerialGC 使用USerial收集器 **
3.5 Parallel Old收集器 - old

Parallel Old与ParNew或者Parallel Scavenge的关系就好似Serial与Serial Old一样,相互之间的区别并不大,只不过Parallel Old是针对年老代设计的并行搜集器而已,因此它采用标记/整理算法。这个收集器是在JDK 1.6中才开始提供。

Parallel Old搜集器还有一个重要的意义就是,它是除了Serial Old以外唯一一个可以与Parallel Scavenge搭配工作的年老代搜集器,因此为了避免Serial Old影响Parallel Scavenge可控制吞吐量的名声,Parallel Old就作为了Parallel Scavenge真正意义上的搭档。
参数控制: -XX:+UseParallelOldGC 使用Parallel收集器

3.6 CMS(Concurrent Mark Sweep)收集器 - old

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它也是唯一一个在年老代采用标记/清除算法的收集器。它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark):需要暂停应用程序,快速标记存活对象。
  • 并发标记(CMS concurrent mark):恢复应用程序,并发跟踪GC Roots。
  • 重新标记(CMS remark):需要暂停应用程序,重新标记跟踪遗漏的对象。
  • 并发清除(CMS concurrent sweep):恢复应用程序,并发清除未标记的垃圾对象。

CMS

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;

并发标记阶段就是进行GC Roots Tracing的过程;耗时较长

重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。

3.6.1 优点

并发收集、低停顿

3.6.2 缺点
  1. 并发阶段会降低吞吐量
    CMS默认启动的回收线程数=(CPU数目+3)/4
    当CPU数>4时, GC线程最多占用不超过25%的CPU资源, 但是当CPU数<=4时, GC线程可能就会过多的占用用户CPU资源, 从而导致应用程序变慢, 总吞吐量降低)
  2. CMS收集器无法处理浮动垃圾
    CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
    由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”
    也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
  3. 产生大量空间碎片
    CMS是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。
    空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

参数控制:-XX:+UseConcMarkSweepGC 使用CMS收集器
-XX:+ UseCMSCompactAtFullCollection FullGC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
-XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
-XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

3.7 G1收集器 - young & old
简介

G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:

  1. 空间整合,G1收集器采用标记-整理算法,不会产生内存空间碎片。这是因为,G1在回收过程中,会适当的移动对象,减少空间碎片。而CMS只是“标记-清除”对象,多次GC后,CMS需要进行碎片整理。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
  2. 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
    上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

如上述所说,G1算法将堆划分为若干个区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。


在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始触发收集。和CMS类似,G1收集器收集老年代对象会有短暂停顿。

对象分配策略

说起大对象的分配,我们不得不谈谈对象的分配策略。它分为3个阶段:

  1. TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区
  2. Eden区中分配
  3. Humongous区分配
    TLAB为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。如果对象在一个共享的空间中分配,我们需要采用一些同步机制来管理这些空间内的空闲空间指针。在Eden空间中,每一个线程都有一个固定的分区用于分配对象,即一个TLAB。分配对象时,线程之间不再需要进行任何的同步。

对TLAB空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。

收集步骤
  1. 初始标记(initial mark,STW)
    首先初始标记(Initial-Mark),G1 GC 对根进行标记,这个阶段是停顿的(Stop the World Event)。并且会触发一次普通Minor GC。对应GC log:GC pause (young) (inital-mark)
  2. 根区域扫描(root region scan)
    由于初始标记会产生一次新生代GC,在初始标记结束后,Eden被清空,并将存活对象移到Survivor区。在根区域扫描阶段,将扫描由Survivor区直接可达的老年代区域,并标记这些直接可达的对象。此阶段可以和应用程序并发执行,但不能喝新生代GC同时执行(Survivor区域竞争)。
  3. 并发标记(Concurrent Marking)
    在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  1. 重新标记(Remark,STW)
    会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1 GC 清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
  2. 独占清理垃圾(Cleanup,STW),多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。
Remark

6、复制/清除过程。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域。

G1并发标记周期的流程

4、垃圾收集器组合

垃圾收集器

上面有7种收集器,分为两块,上面为新生代收集器下面是老年代收集器。如果两个收集器之间存在连线,就说明它们可以搭配使用。

常用的收集器组合

Young GC Old GC 说明
Serial Serial Old Serial和Serial Old都是单线程进行GC,特点就是GC时暂停所有应用线程。
Serial CMS+Serial Old CMS是并发GC,实现GC线程和应用线程并发工作,不需要暂停所有应用线程。另外,当CMS进行GC失败时,会自动使用Serial Old策略进行GC。
ParNew ParNew 使用-XX:+UseParNewGC选项来开启。ParNew是Serial的并行版本,可以指定GC线程数,默认GC线程数为CPU的数量。可以使用-XX:ParallelGCThreads选项指定GC的线程数。如果指定了选项-XX:+UseConcMarkSweepGC选项,则新生代默认使用ParNew GC策略。
ParNew Serial Old 使用-XX:+UseParNewGC选项来开启。新生代使用ParNew GC策略,年老代默认使用Serial Old GC策略。
Parallel Scavenge Serial Old Parallel Scavenge策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC时间),可见这会使得CPU的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。
Parallel Scavenge Parallel Old Parallel Old是Serial Old的并行版本
G1GC G1GC -XX:+UnlockExperimentalVMOptions
-XX:+UseG1GC #开启
-XX:MaxGCPauseMillis =50 #暂停时间目标
-XX:GCPauseIntervalMillis =200 #暂停间隔目标
-XX:+G1YoungGenSize=512m #年轻代大小
-XX:SurvivorRatio=6 #幸存区比例

参考

http://my.oschina.net/hosee/blog/644618
深入理解Java虚拟机:JVM高级特性与最佳实践 pdf
https://my.oschina.net/winHerson/blog/114391
http://www.cnblogs.com/zuoxiaolong/p/jvm8.html
http://blog.jobbole.com/109170/

JVM