当前位置: 首页 > 科技观察

JVM垃圾收集算法与CMS垃圾收集器

时间:2023-03-12 03:28:30 科技观察

本文核心内容主要是:JVM中的几种垃圾收集算法理论,以及各种垃圾收集器,以及CMS垃圾收集器实现、优缺点等详细参数,最后还会解释三色符号和读/写屏障。垃圾收集算法CollectionAlgorithm.png分代收集理论(GenerationalCollection)目前商用虚拟机的垃圾收集采用“分代收集”(GenerationalCollecting)算法,根据对象不同的生命周期将内存分成多个块。将Java堆分为新生代和老年代,这样就可以根据每个年代的特点采用最合适的收集算法。比如newgeneration每次GC都会有大量对象死亡,只有少数存活下来。收集是以复制少量幸存对象为代价完成的。基于前面GC算法的优缺点,针对不同生命周期的对象采用不同的GC算法。新一代使用Copying。老年代使用Mark-Sweep或Mark-Compact标记复制算法(Copying)。为了解决效率问题,“复制”聚类算法应运而生。他可以把内存分成大小相同的两块,每次使用其中一块。当这块内存用完后,将存活的对象复制到另一边,然后一次性清理掉已用的空间。这样每次内存回收就是回收一半的内存范围。Mark-SweepAlgorithm.png标记-清除算法(Mark-Sweep)算法分为“标记”和“清除”两个阶段,先标记所有需要回收的对象,然后回收所有需要回收的对象。缺点效率问题,标记和清理两个过程效率不高。空间问题。标记和清除后,会产生大量不连续的内存碎片。过多的空间碎片可能会导致后续使用时无法找到足够的连续内存而提前触发垃圾回收动作。MarkClearingAlgorithm.pngMark-CompactAlgorithm(Mark-Compact)标记过程还是一样的,只是后面的步骤不是直接清理,而是将所有存活的对象移到一端,然后直接清理内存在这一端的边界之外。没有内存碎片。Mark-Sweep(标记去除)需要更多时间来执行紧凑(组织)标记清除算法。是内存回收的具体实现。虽然我们比较了各种收集器,但并不是为了选出最好的收集器,因为目前还没有最好的垃圾收集器,更不用说万能的垃圾收集器了。应用场景选择适合自己的垃圾收集器,试想一下:如果有一个完美适用于所有场景的垃圾收集器,那我们的Java虚拟机就不会实现那么多的垃圾收集器了。查询当前使用的JVM信息查询命令java-XX:+PrintCommandLineFlags-version?~java-XX:+PrintCommandLineFlags-version-XX:InitialHeapSize=134217728-XX:MaxHeapSize=2147483648-XX:+PrintCommandLineFlags-XX:+UseCompressedClassPointers-:+UseCompressedOops-XX:+UseParallelGCjavaversion"1.8.0_281"Java(TM)SERuntimeEnvironment(build1.8.0_281-b09)JavaHotSpot(TM)64-BitServerVM(build25.281-b09,mixedmode)SerialThe收集器是一个单线程收集器。在收集时,它会暂停所有工作线程(StopTheWorld,简称STW)。使用副本收集算法,虚拟机以客户端模式运行。默认的新生代收集器。最早的收集器,单线程的GCNew和TheOldGeneration都可以在新生代使用,使用copy算法;在oldgeneration中使用Mark-Compact算法是因为它使用的是单线程GC,没有额外的多线程切换开销,简单实用。HotspotClient模式默认收集器Safepoint安全点JVM参数:-XX:+UseSerialGC-XX:+UseSerialOldGCSerial收集器.pngPerNew收集器ParNew收集器是Serial的多线程版本,除了使用多线程收集外,其他行为包括算法、STW、对象分配规则、回收策略等与Serial收集器完全相同。此收集器是在服务器模式下运行的虚拟机的默认新一代收集器。在单CPU环境下,ParNew收集器的效果不会比Serial收集器有更好的效果。新生代Serial收集器的多线程版本使用的是复制算法(因为是针对新生代的)。只有在多CPU环境下效率才会比Serial收集器高。可以通过-XX:ParallelGCThreads来控制GC线程数。Server模式下新生代的默认收集器需要结合CPU数量。JVM参数:-XX:UseParNewGCParNewcollector.pngParallelScavenge收集器(默认1.8)ParallelScavenge收集器也是多线程收集器,采用复制算法,但其对象分配规则和收集策略与ParNew收集器不同,它是以最大化吞吐量(即总运行时间中的最小GC时间)为目标的收集器实现,它允许更长的STW来换取最大化总吞吐量。默认收集器线程数与CPU核数相同,也可以通过参数指定JDK1.8默认垃圾收集器JVM参数:`-XX:UseParallelGC(年轻代)-XX:UseParallelOldGC(老年代)SerialOldcollectorSerial老收集器是单线程收集器,使用mark-collat??ion算法。它是旧时代的收藏家。ParallelOldcollector(1.8default)老年代版本的throughput-firstcollector使用多线程和mark-collat??ion算法。JVM1.6规定,在此之前,如果新生代使用PS收集器,老年代只能选择SerialOld,因为PS无法与CMS收集器一起工作。ParallelScavenge在老年代的实现只出现在JVM1.6中。ParallelOld采用多线程,Mark-Compact算法更注重吞吐量。ParallelScavenge+ParallelOld=高吞吐量,但GC暂停可能并不理想。ParallelOldCollector.pngCMS(ConcurrentMarkSweep)CollectorCMS是一个以停顿时间最短为目标的收集器。使用CMS并没有达到最高的GC效率(整体GC时间是最小的),但是可以尽可能的减少GC服务的停顿时间。CMS收集器使用标记-清除算法。CMScollector.pngCMS垃圾收集器步骤CMS收集器是基于mark-sweep算法实现的。它的操作过程比以前的收集器更加复杂。整个过程分为四个步骤,包括:1)初始标记(CMSinitialmark)挂起所有其他线程(STW)。记录GCROOT直接引用对象,速度快。2)并发标记(CMSconcurrentmark)并发标记阶段是从GCROOT行的直接关联对象开始遍历整个对象的过程。这个过程耗时较长但不需要停止用户线程,并且可以与垃圾收集器并发运行。因此,用户程序继续运行,可能会导致被标记对象的状态发生变化。3)重标记(CMSremark)重标记阶段是对并发标记期间由于用户程序继续运行而改变标记的那部分对象的标记记录进行修正。这个阶段的停顿时间一般比初始标记阶段稍长,比并发标记阶段短很多。主要是用到了三色标记中的增量更新算法4)**并发清扫(CMSconcurrentsweep)**启动用户线程,同时GC线程开始清理未标记区域。如果这个阶段有新的物体,它会被标记为黑色,不做任何处理(见下面三色标记算法的详细解释)。CMS垃圾收集器的优缺点从名字就可以看出是一款优秀的垃圾收集器,主要优点:并发收集,低暂停。但它有以下明显的缺点:对CPU资源敏感(会与服务竞争资源);它无法处理浮动垃圾(垃圾是在并发标记和并发清理阶段产生的,而这种浮动垃圾只能等到下一次gc清理后)它使用的收集算法“mark-clear”算法会导致大量的空间碎片在最后。当然参数-XX:UseCMSCompactAtFullCollection可以让JVM在mark-clear完成后进行整理。执行过程中的不确定性,会出现垃圾回收还没有完成,再次触发垃圾回收的情况,尤其是在并发标记和并发清理阶段。回收的时候,系统在运行,回收完成之前可能还没有完成。再次触发FullGC,即“并发模式失败”。这时候就会进入stoptheworld。使用串行旧垃圾收集器进行回收。CMS垃圾收集器的参数三色标记和读写屏障三色标记算法说到并发标记,就不得不了解并发标记的三色标记算法。它是描述跟踪收集器的有效方式,可以用来推断收集器的正确性。因为应用线程在并发标记的过程中一直在运行,对象之间的引用可能会发生变化,也会出现**多标记**和漏标记的情况。三色标记法.gif我们把对象分为三种:黑色:根对象,或者对象及其子对象已经被扫描过(对象被标记,其所有字段也被标记)灰色:对象本身被扫描,对象中的子对象还没有被扫描(它的字段没有被标记或标记)。白色:未扫描的对象。扫描完所有物体后,最后的白色物体是不可达物体,即Garbageobjects(没有被标记的物体)三色标记过程的第一步,三色标记算法,如果设置了根对象为黑色,则下层节点为灰色,下层节点为白色步骤二,灰色扫描完成后,其余白色变为灰色。第三步,灰色扫描完后,全部标记为黑色,无法到达的还是白色。三色标记算法的对象丢失,那么指向对象的指针可能会改变。在这种情况下,我们会遇到一个问题:对象丢失了。例子:A.c=CB.c=null第一步,初始Root(黑色)->A(黑色)Root(黑色)->B(灰色)->C(白色)第二步,在当前场景中,执行如下操作Root(black)->A(black)->C(white)Root(black)->B(black)第三步,如果内存被回收,C也会被回收,这会导致C丢失的对象。SATB原始快照(Snapshot-At-The-Beginning)SATB是一种维护并发GC的手段。G1并发的基础是SATB。SATB可以理解为在GC开始之前对堆内存中的对象进行快照。这时,存活的对象就被认为是存活的,从而形成对象图。GC收集时,新生代中的对象也被认为是活对象,其他不可达的对象也被认为是垃圾对象。如何找到GC进程中分配的对象?每个区域记录两个top-at-mark-start(TAMS)指针,即prevTAMS和nextTAMS。TAMS之上的独占访问是新分配的,因此被视为隐式标记。这样,我们在GC过程中找到了新分配的对象,并认为这些对象是存活对象。在解决了GC过程中对象分配的问题之后,如何解决GC过程中引用频繁变化的问题呢?G1给出的解决方案是通过WriteBarrier。WriteBarrier是堆引用字段的赋值,用于额外的处理。通过WriteBarrier,可以知道哪些引用对象发生了什么样的变化。mark的过程就是遍历堆来标记存活对象的过程。采用三色标记算法。三种颜色分别是白色(表示未被访问过)、灰色(访问过但其使用的引用仍被完整扫描)、黑色(已访问且其使用的引用被完全扫描)整个三色标记算法是从GCRoots开始遍历堆,将可达对象Gray先标记为白色,再将灰色标记为黑色;遍历完成后,所有可达的对象都是黑色的,所有白色的对象都可以回收。SATB只是针对标记开始时的“快照”(标记allreachableatmarkstart),但并发修改可能会导致对象漏标。为什么G1要用STAB?CMS使用增量更新?我的理解:STAB是相对增量的定量更新效率会很高(当然,STAB可能会造成更多的浮动垃圾),因为不需要对删除的引用对象再次进行remark和deepscan,CMS会对其进行deepscan增量引用的根对象,G1因为很多对象都位于不同的区域,而CMS是老年代区域。如果对物体重新进行深度扫描,G1的成本会比CMS高。所以G1选择STAB不对对象进行深度扫描,只是简单的标记一下,等待下一轮GC做深度扫描。写屏障(WriteBarrier)voidoop_field_store(oop*field,oopnew_value){*field=new_value;//赋值操作}所谓writebarrier其实就是指在赋值操作前后增加一些处理(可以参考AOP的概念):voidoop_field_store(oop*field,oopnew_value){pre_write_barrier(field);//写屏障-预写操作*field=new_value;post_write_barrier(字段,值);//writebarrier-写后操作}writebarrier和SATB当对象E的成员变量引用发生变化时(objE.fieldG=null;),我们可以使用writebarrier来记录原来的引用对象GE的成员变量:voidpre_write_barrier(oop*field){oopold_value=*field;//获取旧值remark_set.add(old_value);//记录原始引用对象}这种做法的思路是:尽量保留最开始的对象图,即原始快照(SnapshotAtTheBeginning,SATB),当在某个GCRoots之后momentisdetermined=,此时的对象图已经确定。比如此时D引用了G,那么后面的标记也应该跟随此时的对象图(D引用G)。如果期间有变化,可以记录下来,保证标记还是按照原来的看法。值得一提的是,扫描所有GCRoots(即初始标记)的操作通常需要STW,否则可能永远扫描不完,因为并发期间可能会加入新的GCRoots。一点优化:如果不在垃圾回收的并发标记阶段,或者已经被标记了,就不用再记录了,可以加个简单的判断:voidpre_write_barrier(oop*field){//inGCconcurrent标记阶段并且对象还没有被标记(访问过)if($gc_phase==GC_CONCURRENT_MARK&&!isMarkd(field)){oopold_value=*field;//获取旧值remark_set.add(old_value);//记录原始Reference对象}}writebarrier和增量更新当对象D的成员变量的引用发生变化时(objD.fieldG=G;),我们可以使用writebarrier记录新的成员变量引用对象GD:voidpost_write_barrier(oop*field,oopnew_value){if($gc_phase==GC_CONCURRENT_MARK&&!isMarkd(field)){remark_set.add(new_value);//记录新引用的对象}}这种方式的思路是:不需要保留原来的快照,但是对于新添加的引用,记录下来等待遍历,即增量更新(IncrementalUpdate)读取屏障oop_field_load(oop*field){pre_load_barrier(field);//readbarrier-读取前的操作return*field;}readbarrier直接针对第一步:varG=objE.fieldG;,读取成员变量时,记录所有记录:voidpre_load_barrier(oop*field,oopold_value){if($gc_phase==GC_CONCURRENT_MARK&&!isMarkd(field)){oopold_value=*field;remark_set.add(old_value);//记录读取到的对象}}现代跟踪(可达性分析)垃圾收集器几乎都借用了三种颜色标记的算法思想,虽然实现方式它们的不一样:比如white/black集合一般不会出现(但也有其他地方会体现颜色),gray集合可以通过栈/队列/缓存日志等方式实现,遍历方式可以是广度/深度遍历等。对于Read和writebarrier,以JavaHotSpotVM为例,并发标记时缺失标签的处理方案如下:CMS:writebarrier+incrementalupdateG1:writebarrier+SATBZGC:readbarrierleaks——读写barrier泄漏会导致被引用的对象被当成垃圾被误删除。这是一个严重的错误。解决方案有两种:增量更新(IncrementalUpdate)和原始快照(SnapshotAtTheBeginning,STAB)。增量更新是当一个黑色对象插入一个新的指向白色对象的引用时,并发扫描结束后,将记录的引用关系中的黑色对象作为根重新扫描。这可以简化为一旦新插入黑色对象并引用白色对象,它就会变回灰色对象。原来的快照是当灰色对象要删除指向白色对象的对象时,直接将白色对象标记为黑色(目的是让这个对象在本轮GC清理中存活下来,等待下一次roundGC重新扫描。可能是浮动垃圾)不管是上面引用关系记录的插入还是删除,虚拟机的记录操作都是通过writebarrier实现的。内存集和卡表(**RememberSet**&Cardtable)在新生代的GCRootsreachabilityscan中可能会遇到跨代引用对象。再次扫描年老代效率太高了。低的。为此,可以在新生代中引入记忆集(RememberSet)的数据结构(记录从非收集区到收集区的指针集合),从而避免将整个老年代加入到GCRoots扫描范围。其实不仅仅是新生代和老年代之间跨代引用的问题。所有涉及局部区域收集(PartialGC)的垃圾收集器,如G1、ZC和Shenandoah收集器,都会面临同样的问题。问题。在垃圾回收场景下,回收器只需要通过内存集判断某个非回收区中是否存在指向回收区的指针,而无需了解跨代引用指针的所有细节。hotspot使用一种叫做“卡表”(Cardtable)的方式来实现内存集,也是目前最常用的方式。关于卡表和内存集合的关系,可以类比Java语言中Hashmap和Map的关系。卡表是使用一个字节数组:CARDTABLE[]实现的,每个元素对应一个由它标识的特定大小的内存区域。块,称为“卡片页”。hotspot使用的卡片页大小为2^9,即512字节的卡片表和卡片页.png一个卡片页可以包含多个对象,只要一个字段中有跨代指针即可object,对应的cardtable元素标志变为1,说明该元素是脏的,否则为0。GC时,只要过滤掉这个收集区的cardtable中的脏元素,加入GCRoots即可。卡表的维护上面已经说了,但是需要注意的是如何让卡表变脏,也就是发生引用字段赋值时如何更新卡表《深入理解 Java 虚拟机第三版》周志明的三-颜色标记方法及通过读写障碍的猪