美文网首页
JVM常见垃圾回收器介绍

JVM常见垃圾回收器介绍

作者: 为爱放弃一切 | 来源:发表于2020-08-01 15:12 被阅读0次

    垃圾回收器简介

    在新生代和老年代进行垃圾回收的时候,都是要用垃圾回收器进行回收的,不同的区域用不同的垃圾回收器。\color{green}{Serial和Serial Old垃圾回收器:}分别用来回收新生代和老年代的垃圾对象。工作原理就是单线程运行,垃圾回收的时候会停止我们自己写的系统的其它工作线程,让我们系统直接卡死不动,然后让它们垃圾回收,这个现在一般写后台java系统几乎不用。

    \color{green}{ParNew和CMS垃圾回收器:}ParNew现在一般都是用在新生代的垃圾回收器,CMS是用在老年代的垃圾回收器,它们都是多线程并发的机制,性能更好,现在一般是线上生产系统的标配组合。

    \color{green}{G1垃圾回收器:}统一收集新生代和老年代,采用了更加优秀的算法和设计机制。

    Stop the world 问题分析

    java系统在运行期间能不能继续在新生代里创建新的对象?当然不能,\color{green}{why?}
    如果一边垃圾回收器在想办法把Eden区和Survivor1区里的存活对象标记出来转移到Survivor2区域里,然后还在想办法把Eden区和Survivor1里的垃圾对象都清理掉,结果这个时候系统程序还在不停地在Eden区里创建新的对象,这些新的对象有的很快就成了垃圾对象,有的还有人引用是存活对象,那现在怎么办?全部乱套了,对于程序新创建的这些对象,你怎么让垃圾回收器去持续追踪这些新对象的状态?怎么想办法把新创建的对象中的垃圾都给回收了?有的同学可能会说,那就想办法让垃圾回收器做到啊!但是说着容易,做着难,jvm也只能说一句\color{green}{:臣妾做不到啊!}

    因此在垃圾回收器工作的时候jvm会在后台直接进入“Stop the world”状态,也就是说,它会直接停止java系统的所有工作线程,让我们写的代码不再运行,这样的话就不会再创建新的对象,让垃圾回收线程尽快完成垃圾回收的工作。一旦垃圾回收完毕,就可以恢复java系统的工作线程的运行了,然后那些代码就可以继续运行,继续在Eden中创建新的对象。

    现在大家都很清晰“Stop the world”会对系统造成的影响了,假设我们的Minor GC要运行100ms,那么就会导致我们的系统直接停顿100ms不能处理任何请求。 在这100ms内所有的请求都会出现短暂的卡顿。有些极端情况,一次垃圾回收可能需要几十秒,系统就会出现几十秒的卡顿,造成大量请求超时,这会让用户体验极差。
    所以说,无论是新生代GC还是老年代GC,都尽量不要让频率过高,也要避免持续时间过长,影响系统正常运行。

    不同的垃圾回收器的不同影响

    比如对新生代的回收,Serial垃圾回收器就是用一个线程进行垃圾回收,然后此时暂停系统工作线程,所以一般我们在服务器程序中很少用这种方式。我们平时常用的新生代垃圾回收器是ParNew,它针对的一般都是多核CPU服务器,它是多线程工作的,可以大幅度提升回收的性能,缩短回收的时间。

    新生代垃圾回收器ParNew

    在G1垃圾回收器没有出现之前,线上系统通常都是ParNew作为新生代的垃圾回收器。当然现在即使有了G1垃圾回收器,其实很多线上系统还是用的ParNew垃圾回收器。至于Serial垃圾回收器,它和ParNew的唯一区别就是单线程和多线程。在系统启动的时候我们可以指定使用ParNew垃圾回收器,很简单,使用 “-XX:+UseParNewGC”选项即可。ParNew垃圾回收器默认情况下的线程数量和CPU的核数是一样的,比如我们用的是4核CPU,它的垃圾回收线程数就是4个线程。我们一般使用默认的即可,但如果你想自己调节ParNew的垃圾回收线程数量,可以使用“-XX:ParallelGCThreads”参数,通过它设置线程的数量。

    老年代垃圾回收器CMS

    一般老年代我们选择的垃圾回收器是CMS,它采用的是\color{green}{标记清理算法},就是用之前文章里讲过的标记方法找出哪些对象是垃圾对象,然后把这些垃圾对象清理掉。标记的算法就是之前讲过的GC Roots,这里就不再重复。
    现在有两个问题:
    1、如果停止一切工作线程,然后再去执行“标记-清理”算法,会导致系统卡死时间过长,很多响应无法处理。
    2、这种方法有一个很大的问题,就是会造成很多的内存碎片。

    \color{green}{CMS如何实现系统一边工作一边进行垃圾回收?}
    CMS垃圾回收器采取的是“垃圾回收线程和系统工作线程尽量同时执行的模式来处理”的,在执行一次垃圾回收的过程一共分为4个阶段:
    1、初始标记
    2、并发标记
    3、重新标记
    4、并发清理

    首先,CMS要进行垃圾回收时会先执行\color{green}{初始标记}阶段,这个阶段会让系统的工作线程全部停止,进入“stop the world”状态。所谓的“初始标记”是说标记出来所有GC Roots直接引用的对象,这是什么意思呢?比如下面的代码:

        public class User {
            private static Role role = new Role();
        }
    
        public classs Role {
            private Power power = new Power();
        }
    

    在初始标记阶段仅仅会通过“role”这个类的静态变量代表的GC Roots,去标记出来它直接引用的Role对象,这就是初始标记的过程。它不会去管Power这种对象,因为Power对象是被Role类的power实例变量引用的,之前说过,方法的局部变量和类的静态变量是GC Roots,但是类的实例变量不是GC Roots。所以第一个阶段,初始标记,虽然说要造成“stop the world”暂停一切工作线程,但是影响不大,因为他的速度很快,仅仅标记GC Roots直接引用的那些对象罢了。

    接着第二个阶段,是并发标记,这个阶段会让系统线程可以随意创建各种新对象,在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用,变成垃圾对象。在这个过程中,垃圾回收线程会尽可能的对已有的对象进行GC Roots追踪。其实就是对类似Power之类的全部老年代里的对象进行追踪,比如这里是被Role对象的实例变量引用了,接着会看Role对象被谁引用了,会发现被User类的静态变量引用了。那么此时可以认定Power对象是被GC Roots间接引用的,所以此时就不会需要回收它了。但是这个过程中,在进行并发标记的时候,系统程序会不停地工作,它可能会创建出来新的对象,部分对象可能会成为垃圾对象。

    第二个阶段就是对老年代所有对象进行GC Roots追踪,其实是最耗时的。它需要追踪所有对象是否从根源上被GC Roots引用了,但是这个最耗时的阶段是跟系统程序并发运行的,所以其实这个阶段不会对系统运行造成影响。

    接着进入第三个阶段,重新标记阶段
    因为第二个阶段里,一边标记存活对象和垃圾对象,一边系统在不停运行创建新对象,让老对象变成垃圾。所以第二阶段结束之后,绝对会有很多存活对象和垃圾对象,是之前第二阶段没标记出来的。所以此时进入第三阶段,要继续让系统程序停下来,再次进入“stop the world”阶段,然后重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾对象的情况。
    这个重新标记的阶段,速度是很快的,其实就是对在第二阶段中被系统程序运行变动过的少数对象进行标记,所以运行速度很快。

    接着重新恢复系统程序的运行,进入第四阶段:并发清理。这个阶段就是让系统程序随意运行,然后它来清理掉之前标记为垃圾的对象即可。这个阶段其实是很耗时的,因为需要进行对象的清理,但是它也是跟系统程序并发运行的,所以其实也不影响系统程序的执行。

    大家看完CMS的垃圾回收机制之后,就会发现,它已经尽可能的进行了性能优化了,因为最耗时的就是对老年代全部对象进行GC Roots追踪,标记出来到底哪些对象可以回收,然后就是把各种垃圾对象从内存里清理掉,这是最耗时的。但是它的第二阶段和第四阶段都是和系统程序并发执行的,所以基本这两个最耗时的阶段对性能影响不大。只有第一个阶段和第三个阶段是需要“stop the world”的,但是这两个阶段都是简单的标记而已,速度非常快,所以基本上对系统运行影响不大。

    深入分析CMS

    \color{red}{CMS并发回收垃圾导致CPU资源紧张}
    CMS垃圾回收器有一个最大的问题,就是垃圾回收线程和系统工作线程同时工作,会导致有限的CPU资源被垃圾回收线程占用一部分。CMS默认启动的垃圾回收线程的数量是(CPU核数 + 3)/ 4。假如是4核8G的服务器,CMS占用的线程数是(4 + 3) /4 = 1,所以CMS这个并发垃圾回收的机制,第一个问题就是会消耗CPU资源。

    \color{red}{Concurrent Mode Failure问题}
    在并发清理阶段,CMS只不过是回收之前标记好的垃圾对象,但是这个阶段系统一直在运行,可能会随着系统运行让一些对象进入老年代,同时还变成垃圾对象,这种垃圾对象是“浮动垃圾”。虽然它成为了垃圾,但是CMS只能回收之前标记出来的垃圾对象,不会回收它们,需要等到下一次GC的时候才会回收它们。所以为了保证CMS垃圾回收期间还有一定的内存空间让一些对象可以进入老年代,一般会预留一些空间。CMS垃圾回收的触发时机,其中有一个就是当老年代内存占用达到一定比例了,就自动执行GC。“-XX:CMSInitiatingOccupancyFaction”参数可以用来设置老年代占用多少比例的时候触发CMS垃圾回收,比如占用90%就自动进行CMS垃圾回收。那么如果CMS垃圾回收期间,系统程序要放入老年代的对象大于了可用内存空间,此时会如何?
    这个时候会发生Concurrent Mode Failure,就是说并发垃圾回收失败了,此时就会自动用“Serial Old”垃圾回收器替代CMS,就是直接强行把系统程序“stop the world”,重新进行长时间的GC Roots追踪,标记出来全部垃圾对象,不允许新的对象产生。然后一次性把垃圾对象都回收掉,再恢复系统线程。所以在生产实践中,这个自动触发CMS垃圾回收的比例需要合理优化一下,避免Concurrent Mode Failure问题。

    \color{red}{内存碎片问题}
    之前我们已经说过内存碎片的问题,就是老年代的CMS采用“标记-清理”算法,每次都是标记出来垃圾对象,然后一次性回收掉,这样会导致大量的内存碎片产生。如果内存碎片太多,会导致后续对象进入老年代找不到可用的连续内存空间,然后触发Full GC。所以CMS不是完全就仅仅用“标记-清理”算法的,因为太多的内存碎片实际上会导致更加频繁的Full GC。
    CMS有一个参数是“-XX:+UseCMSCompactAtFullCollection”,默认就打开了,意思是在Full GC之后要再次进行“stop the world”,停止工作线程,然后进行碎片整理,就是把存活对象挪到一起,空出来大片连续内存空间,避免内存碎片。还有一个参数是“-XX:CMSFullGCsBeforeCompaction”,它的意思是执行多少次Full GC之后再执行一次内存碎片整理的工作,默认是0,意思是每次Full GC之后都会进行一次内存整理。

    案例实战

    假设我们有一个电商订单系统,每秒钟大概有300个下单请求,每个订单咱们就按1kb的大小来估算,单是300个订单就会有300kb的内存开销,然后算上订单对象连带的订单条目对象、库存、商品、优惠券等等一系列的其它业务对象,一般需要对单个对象开销放大10~20倍。除了下单之外,这个订单系统还会有很多订单相关的其它操作,比如订单查询之类的,所以连带算起来,可以往大了估算,再扩大10倍的量,那么每秒钟会有大概300kb * 20 * 10 = 60MB的内存开销。但是一秒钟过后,可以认为这60MB的对象就是垃圾了,因为300个订单处理完了,所有对象都失去了引用。

    \color{green}{内存到底该如何分配?}
    假设我们有4核8G的机器,那么给jvm的内存一般会到4G,剩下几个G会留点空余给操作系统之类的来使用,不要想着把机器内存一下子都耗尽,其中堆内存我们可以给3G,新生代我们可以给到1.5G,老年代也是1.5G。然后每个线程的java虚拟机栈有1M,那么jvm里如果有几百个线程大概会有几百MB,然后再给永久代256MB内存,基本上这4G内存就差不多了。如果大家用的是jdk1.7或jdk1.8,配置参数如下:

    -Xmx3072m -Xms3072m -Xmn1536m -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -Xss1M
    

    接着就很明确了,订单系统的系统程序在不停的运行,每秒钟处理300个订单,都会占据新生代60MB的内存空间,但是1秒过后这60MB对象都会变成垃圾,那么新生代1.5G的内存空间大概需要25秒就会占满,所以Minor GC直接运行,一下子可以回收掉99%的新生代对象,因为除了最近一秒的订单请求还在处理,大部分订单早就处理完了,所以此时可能存活的对象就100MB左右。
    如果-XX:SurvivorRatio参数默认值为8,那么此时新生代里Eden区大概占据1.2GB内存,每个Survivor区是150MB的内存。所以Eden区1.2GB满了就要进行Minor GC了,因此大概只需要20秒,就会把Eden区塞满,就要进行Minor GC。GC后存活对象在100MB左右,会放入S1区域内。如下图:

    cms.jpg
    然后再次运行20秒,把Eden区占满,再次垃圾回收Eden区和S1中的对象,存活对象可能还是在100MB左右会进入S2区。此时参数如下:
    -Xmx3072M -Xms3072M -Xmn1536M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512m -Xss1M
    -XX:SurvivorRatio=8
    

    \color{green}{Survivor空间不够?}
    首先在进行jvm优化的时候,第一个要考虑的问题就是你通过估算,你的新生代的Survivor区内存到底够不够,按照上述逻辑,首先每次新生代垃圾回收在100MB左右,有可能会突破150MB,那么岂不是经常会出现Minor GC过后的对象无法放入Survivor中?然后岂不是频繁会让对象进入老年代?还有,即使Minor GC后的对象少于150MB,但是即使是100MB的对象进入Survivor区,因为这是一批同龄对象,直接超过了Survivor区空间的50%,此时也可能会导致对象进入老年代。所以按照这个模型来说,Survivor区是明显不足的。这里其实建议的是调整新生代和老年代的大小,因为老年代没必要维持过大的内存空间,应该让对象尽量留在新生代里。所以此时可以考虑把新生代调整为2G,老年代为1G,那么此时Eden区为1.6G,每个Survivor区为200MB。其实,对于任何系统,首先类似上文的内存使用模型预估以及合理的内存分配,尽量让每次Minor GC后的对象都留在Survivor里,不要进入老年代,这是首先要进行优化的一个地方。

    \color{green}{新生代对象躲过多少次垃圾回收后进入老年代?}
    大家都知道,除了Minor GC后对象无法放入Survivor区会导致一批对象进入老年代之外,还有就是有些对象连续躲过15次垃圾回收后会自动升入老年代。按照上述内存运行模型,基本上20多秒触发一次Minor GC,那么如果按照“-XX:MaxTenuringThreshold”参数的默认值15次来说,你要是连续躲过15次GC,就是一个对象在新生代停留超过了几分钟了,此时它进入老年代也是应该的。这个参数一般用默认值就好,但也可以根据你具体的业务来设置,不要人云亦云。

    \color{green}{多大的对象直接进入老年代?}
    另外有一个逻辑是说大对象可以直接进入老年代,因为大对象说明是要长期存活和使用的。比如在jvm里可能会缓存一些数据,例如数据字典之类的,这个一般可以结合自己系统中到底有没有创建大对象来决定。但是一般来说,设置1MB足以,因为一般很少有超过1MB的大对象,如果有,可能是你提前分配了一个大数组、大list之类的东西用来放缓存的数据。此时jvm参数如下:

    -Xmx3072M -Xms3072M -Xmn1536M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -Xss1M
    -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
    

    \color{green}{别忘了指定垃圾回收器}
    同时大家别忘了要指定垃圾回收器,新生代使用ParNew,老年代使用CMS,如下jvm参数:

    -Xmx3072M -Xms3072M -Xmn1536M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -Xss1M
    -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M 
    -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
    

    ParNew垃圾回收器的核心参数,其实就是配套的新生代内存大小、Eden区和Survivor区的比例,只要你设置合理,避免Minor GC后对象放不下Survivor区进入老年代,或者是动态年龄判断之后进入老年代,给新生代里的Survivor区充足的空间,那么Minor GC一般就没什么问题。这样基本上一个初步的优化好的jvm参数就结合你的业务出来了。

    这里我们也可以设置一下老年代占用比例达到多少之后进行Full GC,比如达到92%之后进行回收,如下jvm参数:

    -Xmx3072M -Xms3072M -Xmn1536M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -Xss1M
    -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M 
    -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92
    

    \color{green}{CMS垃圾回收之后进行内存碎片整理的频率应该多高?}
    在CMS完成Full GC之后,一般需要执行内存碎片的整理,可以设置多少次Full GC之后执行一次内存碎片整理,但是我们有必要修改这些参数吗?其实没有必要,因为Full GC 执行一次的间隔时间一般都很长,比如一个小时才一次,所以就保持默认的设置,每次Full GC之后都执行一次内存碎片整理就可以,目前jvm参数如下:

    -Xmx3072M -Xms3072M -Xmn1536M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -Xss1M
    -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M 
    -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92
    -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0
    

    其实现在我们可以看到,Full GC优化的前提是Minor GC的优化,Minor GC的优化前提是合理分配内存空间,合理分配内存空间的前提是对系统运行期间的内存使用模型进行预估。

    下篇文章再单独介绍G1垃圾回收器。

    相关文章

      网友评论

          本文标题:JVM常见垃圾回收器介绍

          本文链接:https://www.haomeiwen.com/subject/fnmtlktx.html