说在前面:本文的篇幅较长,看本文的时候最好先去上个厕所,先准备好一杯枸杞茶,慢慢品,本文将会讲解三种垃圾收集算法:标记-清除、复制、标记-整理算法,以及各种成熟度较高的垃圾收集器:Serial、Serial Old、ParNew、Parallel Scavenge、Parallel Old、CMS以及G1(Garbage First)
在讨论垃圾收集算法之前,需要先了解针对不同区域进行收集的名词:
Minor GC
(新生代收集)、Major GC
(老年代收集)、Full GC
(整个Java堆和方法区的收集)、Mixed GC
(新生代收集和部分老年代的收集,目前只有G1收集器有这种行为)
前一篇文章介绍了可达性分析算法后,了解了虚拟机会利用可达性分析来识别哪些对象是垃圾,可以回收,那么是如何进行回收的呢?下面就会介绍这三种垃圾收集算法
可以看到,在对象可以被回收的区域上,JVM会直接把这些垃圾对象占用的内存直接清除掉。
这个算法的优点很明显:简单
这个算法也有许多缺点:
执行效率不稳定,如果Java堆中包含大量对象,某一次回收时无用的对象非常多,这时候会花费很多时间进行内存的清除。
有可能造成内存空间碎片,上图只是一个理想的删除过程,正好没有内存碎片产生,而实际上在内存中待清除的内存有可能不是连续的,导致会产生许多内存碎片,如果某个大对象无法找到一块连续的内存进行存放时,会误以为堆内存不足,提前触发
Full GC
所以为了解决内存碎片问题,科学家们研制出了一种新的算法:标记-复制算法
由上面的动图可以看出,标记-复制算法将原本的堆内存划分了两个区域,采用了“半区复制”算法,将一半的内存省出来,当发生垃圾收集行为时,将存活的对象复制到另外一半保留区域中连续存放。
标记-复制算法的优点是解决了大对象分配内存的内存碎片问题
,也解决了标记-清除算法中大量垃圾对象导致的清除效率问题
。
缺点也非常的明显,那就是可分配的内存空间少了整整一半,而且如果某次存活的对象较多,甚至全部存活,那么复制的效率将会非常低。
为了提升内存的利用率,科学家提出了标记-整理算法,该算法的起始过程和标记-清除
算法相同,先标记处待回收对象的内存区域,但是在清除时不是对所有可回收对象清除,而是让所有存活对象往内存空间的一边移动,把存活对象边界外的内存直接清空掉。
标记-整理算法提高了内存的利用率、解决了大对象分配时的内存碎片问题,看似完美的垃圾收集算法,也有它的弊端
在移动存活对象的过程中,需要全程暂停用户程序的执行,被设计者称为“Stop The World”。
分代收集算法本质上标记-复制算法,它把堆内存中较大的一块区域作为新生代区域,新生代区域中分为一个Eden区域和两个Survivor区域,Eden和Survivor的比例默认是8:1,因为在Eden区域,绝大数对象都熬不过第一轮GC(98%),所以每个Survivor区域只需要10%的空间就足矣了,每一次触发Minor GC
时,就会将Eden区和Survivor区存活的对象复制到另外一个Survivor区域中,然后清除掉被回收的对象,每次都依据这样的步骤进行垃圾收集。
不知道你有没有注意到每个对象有一个数字的标记,这个标记是对象的年龄,当对象到了15岁以后(默认情况)就会被晋升为老年代
如图所示,当对象在Survivor区存活了15次以后,就会晋升为老年代对象。
还有以下情况会晋升为老年代对象:
大对象。当对象所占连续内存非常大时,不会分配在Eden区,如果分配在Eden区,那么对象存活时产生的复制操作将导致效率大大降低。
如果在Survivor区,相同年龄的对象总大小大于Survivor区空间的一半时,也会将这些年龄相同的对象直接晋升到老年代,原因也是防止对象的复制操作导致的效率问题。
在对象无法分配到Eden区时,会触发一次Minor GC
,JVM会首先检查老年代最大的可用连续空间是否大于新生代所有对象的总和,如果大于,那么这次Minor GC
是安全的,如果不大于的话,JVM就需要判断HandlePromotionFailure
是否允许空间分配担保。
如果允许担保,则证明老年代的连续可用内存空间大于历次晋升到老年代对象的平均大小,此时触发一次Minor GC
,如果小于,那么证明老年代并没有把握放得下Survivor区有可能晋升的对象,此时发生一次Full GC
。
发生GC
(MinorGC或者FullGC)时,都会将用户线程停顿并进行垃圾收集,在Minor GC
中,STW
的时间较短,只涉及Eden
和survivor
区域的对象清除和复制操作,而Full GC
则是对整个堆内存进行垃圾收集,对象的扫描、标记和清除操作工作量大大提高,所以Full GC
会导致用户线程停顿较长时间,如果频繁地发生Full GC
,那么用户线程将无法正常执行。
或者通俗的理解:
你给你妈妈打扫房间时,你是希望她坐在一旁静静等你扫完地再继续活动,还是想你一边扫地,她一边丢垃圾呢?
既然要用户线程停顿下来,那么要在什么地方停顿呢?JVM采用主动式中断方式告诉Java线程需要停顿了,JVM在特定的位置设置了这些安全点(Safe point),让线程可以在这些安全点主动挂起。
方法调用、循环跳转、异常跳转
这些安全点的特征是令程序有可能进行某一段长时间执行的特征。
在这些安全点上存有对象引用信息的OopMap
数据结构,这种数据结构你可以理解为HashMap
这种数据结构,它内部存储了什么位置上存储了对象引用信息,这些信息在类加载完成时就确定下来了。所以JVM在垃圾收集时不需要从一个个方法的GC Roots
去扫描,从OopMap
中可以快速准确地定位到这些GC Roots
。
如果用户线程本身处于停顿状态,例如阻塞(Blocked)、睡觉(Sleep),那么此时触发GC时,用户线程无法响应JVM的中断(我听不见你喊我,我睡着了~),用户线程无法主动地跑去安全点中断挂起,此时该怎么办呢?
对于这种情况,必须引入Safe Region来解决。
安全区域是指,用户线程进入某一段代码区域中时,引用关系不会发生变化,那么在这片代码区域的任何地方开始GC都不会受到影响。实现的方式是,用户线程进入安全区域时会标识自己已经进入安全区域,在JVM发起GC时不必理会那些已经标识为进入安全区域的线程,当用户线程需要离开安全区域时,会主动检查JVM是否已经完成了需要停顿线程的工作,如果已完成则可以离开,如果未完成则必须一直等待,直到JVM发送可以离开安全区域的信号为止。
垃圾收集器分为新生代收集器与老年代收集器,各种不同的收集器之间如果符合标准则可以相互搭配使用
Serial收集器是一款单线程的垃圾收集器,“单线程”的意义不仅仅是指它只能用一条线程或占用一个处理器去完成垃圾收集操作,更重要的是它进行垃圾收集时,**需要暂停其它所有线程,直到垃圾收集结束。**它身为最古老的一款垃圾收集器,在当今依旧广泛受用,它有以下优点:
对于内存受限的环境,它是所有收集器里额外内存消耗最小的
没有线程交互的开销,Serial收集器可以很好地专注于收集垃圾,把用户线程都停掉
在用户桌面的应用场景和近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般不会特别大,收集几十兆、一两百兆的新生代(桌面应用的新生代甚至少于这个容量),垃圾收集完全可以控制在十几、几十毫秒,最多一百毫秒,这点停顿时间对用户来说是十分友好的。
ParNew是一款并行新生代收集器,parNew收集器除了支持多线程并行收集以外,其余的行为与Serial收集器完全一致,包括收集算法、STW(Stop The World)、对象分配规则、回收策略等等。
parNew是不少运行在服务器端模式下的HotSpot虚拟机中首选的新生代收集器,其中一个与性能、功能无关但很重要的原因是:除了Serial收集器,只有ParNew能够与CMS收集器配合工作。
CMS收集器与Parallel Scavenge收集器不能配合工作的一个原因是:Parallel Scavenge收集器内部并没有按照分代收集的框架进行设计垃圾回收,在之后的G1收集器也同样没有按照分代回收的框架设计。
Parallel Scavenge收集器同样是基于标记-复制算法实现的收集器,也是能够并行收集的一款新生代收集器,那它与ParNew收集器的差别在哪里呢?
Parallel Scavenge收集器的特别之处在于它与其它收集器的关注点不一样,其它垃圾收集器关注如何最大限度地减少STW的时间,而Parrel Scavenge关注的是如何达到一个可控制的吞吐量(Throughput),由于与吞吐量关系密切,所以也被称作“吞吐量优先收集器”。
Parallel Scavenge收集器可以实现自适应策略,这是另外一个与ParNew收集器的差别,可以通过指定-XX:UseAdaptiveSizePolicy
参数,虚拟机就会根据系统当前的运行情况收集监控信息,并且自动调整系统的相关JVM参数以提供最高的吞吐量和最合适的停顿时间。
使用标记-整理
算法,是一个单线程收集器,它有另外两个用途:
它作为CMS收集器发生失败后的后备预案,在CMS收集器并发收集发生Concurrent Mode Failure使用
作为Parallel Scavenge的老年代收集器
这个时候就有疑惑了,Parallel Scavenge
收集器不是没有按分代收集框架实现吗,为什么能够搭配Serial Old
收集器使用
《深入理解Java虚拟机》:Parallel Scavenge
收集器架构中含有PS MarkSweep
收集器进行老年代收集,并非直接调用Serial Old
收集器,但是PS MarkSweep
与Serial Old
的实现几乎是一样的,所以官方很多地方用Serial Old
代替它进行讲解。
Parallel Old
是Parallel Scavenge
的老年代版本,支持多线程并发收集,基于标记-整理
算法设计,自从JDK6以后,Parallel Old
和Parallel Scavenge
成为了最好的搭档,在注重吞吐量或者处理器资源比较紧缺的情况下,都可以采用这个组合。
CMS收集器是基于获取最短回收停顿时间为目标的收集器,CMS收集器适合追求服务的响应速度的应用,例如基于浏览器的B/S系统的服务端上。
CMS是基于标记-清除
算法设计的,它支持用户线程与GC线程并发执行,如下图所示
运作过程分为4个阶段:
初始标记、并发标记、重新标记、并发清除
初始标记的过程就是扫描GC Roots;
并发标记是扫描GC Roots链上所有的对象,此时会出现一些对象标记的变动,因为用户线程仍然在执行;
重新标记的过程是修正并发标记期间产生引用变动的那一部分对象的标记记录
并发清除是删除掉标记阶段判断已经死亡的对象,由于不用移动存活对象,此时也是可以并发执行的。
CMS收集器有三个缺点:
对处理器资源特别敏感,由于是并发执行,所以CMS收集器工作时会占用一部分CPU资源而导致用户程序变慢,降低总吞吐量,建议具有四核处理器以上的服务器使用CMS收集器
CMS无法清除浮动垃圾,有可能出现Concurrent Mode Failure
失败而导致另一次STW
的Full GC
产生。由于并发清理过程中用户线程与GC线程并发执行,就一定会产生新的垃圾对象,但是无法在本次GC中处理这些垃圾对象,不得不推迟到下一次GC中处理,这些垃圾对象就称为“浮动垃圾”,到JDK6的时候,CMS收集器启动阈值达到92%
,也就是老年代占了92%
的空间后会触发GC,但是如果剩余的内存8%
不足以分配新对象时,就会发生“并发失败”,进而冻结用户线程,使用Serial Old
收集器进行一次Full GC
,所以触发CMS收集器的阈值还是根据实际场景来设置,参数为-XX:CMSInitiatingOccu-pancyFraction
。
基于标记-清除
算法会导致内存碎片不断增多,在分配大对象时有可能会提前触发一次Full GC
。所以CMS提供两个参数可供开发者指定在每次Full GC
时进行碎片整理,由于碎片整理需要移动对象,所以是无法并发收集的,-XX:+UseCMSCompactAtFullCollection
(JDK9开始废弃),-XX:CMSFullGCsBeforeCompaction
(JDK9开始废弃,默认值是0,每次Full GC都进行碎片整理)。
这是一个在垃圾收集器技术发展历史上的里程碑式的成果,它取代了Parallel Scavenge + Parallel Old
的组合,并取代了CMS
,作为它们的继承者和替代者,G1到底有什么魔力呢?
G1是一种“停顿时间模型”收集器,也就是说可以指定在时间片段为
M
毫秒时,垃圾收集所占用的时间不会超过N
毫秒。G1颠覆了之前的所有垃圾收集器的垃圾收集行为:要么新生代收集(Minor GC)、要么老年代收集(Major GC)、要么整堆收集(Full GC),而G1可以面向堆内存任何部分组成回收集(Collection Set , CSet),衡量标准不再是它属于哪个分代,而是哪块内存存放的垃圾数量较多,这就是G1所特有的Mixed GC模式。
可以看到上图中每一个方块就是一个Region,每个Region可以存放1~32MB大小的对象,使用参数-XX:G1HeapRegionSize
指定,Region中可以存放Eden
/Survivor
/Humongous
/Old
,G1中新生代和老年代并不是连续存放的,而是一个动态的集合。
注意在G1中专门用Region
存放一个Humongous
大对象,当对象容量大于Region的一半时就认为它是大对象,按照“大对象优先在老年代中分配”,Humongous
也是老年代的一部分对象。
G1收集器将Region
单元看出是最小的内存回收单元,每次发生GC时,G1收集器都会评估各个Region
的价值大小,根据用户所指定的收集停顿时间来优先处理那些回收价值最大的Region
,这也是Garbage First
的由来。
G1收集器的运作过程可以分为4个步骤:
初始标记:仅记录GC Roots对象,需要停顿用户线程,但时间很短,借助
Minor GC
同步完成。并发标记:从GC Roots开始遍历扫描所有的对象进行可达性分析,找出要回收的对象,由于是并发标记,有可能在扫描过程中出现引用变动。
最终标记:将并发标记过程中出现变动的对象引用给纠正过来。
筛选回收:对各个Region的回收价值和成本进行排序,根据用户所希望的停顿时间来制定回收计划,选取任意多个Region区域进行回收,把回收的Region区域中的存活对象复制到空的Region区域中,然后清空掉原来的Region区域,涉及对象的移动,所以需要暂停用户线程,由多条GC线程并行完成。
如何设置G1的停顿时间?
G1的停顿时间不能过短,如果停顿时间过短,那么每次GC收集都只会回收占用Region内存区域很小的一部分,而随着内存不断分配,堆上的垃圾越来越多,GC的速度低于分配的速度,就会触发Full GC
,所以,只要我们把停顿时间设置后的效果为垃圾回收的速度与内存分配的速度大致相同,那么在理论上来说就永远不会发生Full GC
,这也是G1被称为很牛逼的一个地方。
G1从整体上看是“标记-整理”算法,从局部(两个Region之间)上看是“标记-复制”算法,不会产生内存碎片,而CMS基于“标记-清除”算法会产生内存碎片。
G1在垃圾收集时产生的内存占用和程勋运行时的额外负载都比CMS高
G1支持动态指定停顿时间,而CMS无法指定
两者都利用了并发标记这个技术
本文主要介绍了各种垃圾收集算法以及当前较为成熟的垃圾收集器,其中G1和CMS这两款垃圾收集器是最受关注的,解释了为什么在垃圾收集时需要Stop The World
,本文篇幅较长,能读到这里是非常不容易的,之后也要多加复习!
参考资料:
《深入理解Java虚拟机》
https://www.infoq.com/articles/G1-One-Garbage-Collector-To-Rule-Them-All/
https://www.cnblogs.com/yangchunchun/p/7405502.html
https://blog.csdn.net/ladymorgana/article/details/82352100