ParNew垃圾回收器如果一旦在合适的时机执行Minor GC的时候,就会把系统程序的工作线程全部停掉,禁止程序继续运行创建新的对象,然后自己就用多个垃圾回收线程去进行垃圾回收,回收的机制和算法就跟之前说的是一样的。我们启动系统的时候可以指定垃圾回收器,使用-XX:+UseParNewGC
选项,只要加入这个选项,JVM启动之后对新生代进行垃圾回收的,就是ParNew垃圾回收器了。
不管是老年代回收还是新生代回收,都要Stop the World
,因为必须让程序停止创建新对象,才能回收垃圾对象,新生代只需要一次stop the world
的时间,在此期间完成标记清除并把存活对象转到survivor或老年代。
产生跟CPU核数一样的线程数量,比如我们线上机器假设用的是4核CPU,或者8核CPU,或者16核CPU,那么此时ParNew的垃圾回收线程数就会分别是4个线程、8个线程、16个线程,这个东西一般不用我们手动去调节。
启动系统的时候是可以区分服务器模式和客户端模式的,如果你启动系统的时候加入-server
就是服务器模式,如果加入-cilent
就是客户端模式。他们俩的区别就是,如果你的系统部署在比如4核8G的Linux服务器上,那么就应该用服务器模式,如果你的系统是运行在比如Windows上的客户端程序,那么就应该是客户端模式
服务器模式通常运行我们的网站系统、电商系统、业务系统、APP后台系统之类的大型系统,一般都是多核CPU,所以此时如果要垃圾回收,那么肯定是用ParNew更好,因为多线程并行垃圾回收,充分利用多核CPU资源,可以提升性能。
如果你的Java程序是一个客户端程序,比如类似百度云网盘的Windows客户端,或者是印象笔记的Windows客户端,运行在Windows个人操作系统上呢?这种操作系统很多都是单核CPU,此时你如果要是还是用ParNew来进行垃圾回收,就会导致一个CPU运行多个线程,反而加重了性能开销,因为单CPU运行多线程会导致频繁的线上上下文切换,有效率开销,可能最后效率还不如单线程好。所以如果是类似于那种运行在Windows上的客户端程序,建议采用Serial垃圾回收器。
一般老年代我们选择的垃圾回收器是CMS,他采用的是标记清理算法(不是标记整理)。之前提到过Stop the World
状态,就是垃圾回收时停止一切线程的工作,如果在这个状态下再去慢慢执行标记清理算法,会导致系统卡死时间过长,所以CMS垃圾回收器采取的是垃圾回收线程和系统工作线程尽量同时执行的模式来处理的。
工作原理:为了避免长时间Stop the World
,CMS采用了4个阶段来垃圾回收,分别是初始标记、并发标记、重新标记和并发清理。其中初始标记和重新标记,耗时很短,虽然会导致Stop the World
,但是影响不大,然后并发标记和并发清理,两个阶段耗时最长,但是是可以跟系统的工作线程并发运行的,所以对系统没太大影响。
在这个阶段让系统的工作线程全部停止,进入Stop the World
状态。同时标记所有GC Roots直接引用的对象,是直接引用!比如下面这段代码,仅仅会通过replicaManager
这个类的静态变量代表的GC Roots,去标记出来他直接引用的ReplicaManager
对象,不会去管ReplicaFetcher
这种对象,因为ReplicaFetcher
对象是被ReplicaManager
类的replicaFetcher
实例变量引用的。(之前说过,方法的局部变量和类的静态变量是GC Roots。但是类的实例变量不是GC Roots。)
public class Kafka {
private static ReplicaManager replicaManager = new ReplicaManager();
}
public class ReplicaManager {
private ReplicaFetcher replicaFetcher = new ReplicaFetcher();
}
初识标记如图所示
这个阶段会让系统线程可以随意创建各种新对象,继续运行。在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用,变成垃圾对象。在这个过程中,垃圾回收线程,会尽可能的对已有的对象进行GC Roots追踪。GC Roots追踪,意思就是对类似ReplicaFetcher
之类的全部老年代里的对象,他会去看他被谁引用了,认定为是被GC Roots间接引用后,就不需要回收它。因为老年代里存活对象是比较多的,这个过程会追踪大量的对象,所以耗时较高。
这里我看的时候有一个问题,为什么要经过初始标记而不直接进入并发标记呢?
因为初始标记是用来标记GC Roots直接关联的对象,如果不在初始标记时找到哪些是GC Roots直接关联的对象的话,并发标记的GC Root Tracing没办法进行啊
第二阶段里,你一边标记存活对象和垃圾对象,一边系统在不停运行创建新对象,让老对象变成垃圾,所以第二阶段结束之后,绝对会有很多存活对象和垃圾对象,是之前第二阶段没标记出来的。在这个阶段,要再次进入Stop the World
阶段,重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾的情况。重新标记的阶段只是对变动过的少数对象进行标记,是速度很快的
这个阶段就是让系统程序随意运行,然后清理掉之前标记为垃圾的对象即可,也是很耗时的。
CMS的第二阶段和第四阶段,都是很耗时的,但都和系统程序是并发执行的,所以基本这两个最耗时的阶段对性能影响不大。只有第一个阶段和第三个阶段是需要Stop the World
的,但是这两个阶段都是简单的标记而已,速度非常的快,所以基本上对系统运行响应也不大。
并发标记和并发清理两个最耗时的阶段,使垃圾回收线程和系统工作线程同时工作,导致有限的CPU资源被垃圾回收线程占用了一部分。在这两个阶段,CMS的垃圾回收线程是比较耗费CPU资源的。CMS默认启动的垃圾回收线程的数量是(CPU核数 + 3)/ 4,比如的2核4G机器,就会占用(2+3)/4 = 1个CPU被用来垃圾回收。
在并发清理阶段,CMS只不过是回收之前标记好的垃圾对象,但这个时候系统一直在运行,先把某些对象分配在新生代,然后可能触发了一次Minor GC,一些对象进入了老年代,在短时间内又没人使用这些对象,这种垃圾对象就是浮动垃圾,虽然它是垃圾,但是不会回收他们,要等到下一次才能回收。
CMS垃圾触发的时机是当老年代内存占用到达一定比例时,就会自动GC,-XX:CMSInitiatingOccupancyFaction
这个参数可以设置老年代内存占用到多少比例时触发垃圾回收。JDK 1.6默认是92%。预留8%的空间给并发回收期间,系统程序把一些新对象放入老年代中。如果垃圾回收期间,要放入的对象大于可用内存空间,就会发生Concurrent Mode Failure
,即并发垃圾回收失败了,我一边回收,你一边把对象放入老年代,内存都不够了。此时就会自动用Serial Old
垃圾回收器替代CMS,就是直接强行把系统程序Stop the World
,重新进行长时间的GC Roots追踪,标记出来全部垃圾对象,不允许新的对象产生。
老年代的CMS采用标记清理
算法(不是标记整理
),每次都是标记出来垃圾对象,然后一次性回收掉,这样会导致大量的内存碎片产生,太多的内存碎片实际上会导致更加频繁的Full GC。
CMS有一个参数是-XX:+UseCMSCompactAtFullCollection
,默认是打开的,意思是在Full GC之后要再次进行Stop the World
,停止工作线程,然后进行碎片整理,就是把存活对象挪到一起,空出来大片连续内存空间,避免内存碎片。
还有一个参数是-XX:CMSFullGCsBeforeCompaction
,这个意思是执行多少次Full GC之后再执行一次内存碎片整理的工作,默认是0,意思就是每次Full GC之后都会进行一次内存整理,存活对象都放在一起,然后空出来大片连续内存空间可供使用。
假设我们的背景是每日上亿请求量的一个订单系统,按照每个用户每日访问次数为20次来算,大致有500万个用户(1亿/20),对这五百万个用户,假设付费转化率为10%,也就是有50万人会去下单,我们把这50万订单集中在4个小时的高峰期内,平均每秒钟也就几十个订单,感觉也没什么大的压力,因为几十个订单根本不需要对JVM做太多关注。
但是如果到了双十一这种活动,就会出问题了。硬件方面来说,如果我们部署到足够的机器上以及机器内存充裕,也不是问题,但就JVM的参数来说,如果我们不能合理的去设置这个参数,就会导致机器资源浪费,硬件成本的增加。
为什么要去调JVM参数?我们的目的就是对JVM有限的内存资源做好合理分配和优化,当然包括垃圾回收的优化,要让GC次数尽可能的少。
假设双十一期间一台机器1秒要处理300个订单(处理订单比较耗时,工作经验上是每秒处理100~300个订单),对于每个订单对象我们按1KB来算,那1秒就是300KB内存开销了,但是这时订单连带对象如库存、促销、优惠券等一系列业务对象,这些对象从经验上来讲好要比订单单个对象的开销再放大10倍,同时还有很多与订单相关操作,比如查询等,往大估算就再扩大十倍,**所以1秒钟,我们要处理60MB(300KB x 20 x 10 = 60000KB)对象。**每1秒过后,这个对象就变成垃圾了。
假设我们使用4核8G的机器,JVM分配4G,其中3G给堆内存,1G给方法区和每个线程的虚拟机栈。虚拟机栈一般都是1MB,假设我们有几百个线程,就是几百MB,这里我们给永久代256MB,给虚拟机栈总共768MB。至于堆内存,我们给新生代1.5G,老年代1.5G。得到如下参数。(注意这里不写-XX:HandlePromotionFailure
,我们使用JDK 1.8)。每1秒有60MB的垃圾,1.5G的内存大概25秒就满了。此时就要Minor GC
,明显老年代能够存放新生代所有对象,可以放心GC,由于最后一秒订单还在处理,假设存活的对象就100MB,这里来问题了,如果-XX:SurvivorRatio
参数默认值为8,那么此时新生代里Eden区大概占据了1.2GB内存,每个Survivor
区是150MB的内存,如下图。
-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
所以启动JVM后,大概20秒左右,Eden
区就满了,然后Minor GC
,把存活对象放在Survivor1
中,再过20s,再次回收Eden
和Survivor1
中的对象,存活的如果还是100MB就放入Survivor2
中。
以上就是总体的背景,此时的JVM参数为:
-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8
JVM优化时,首先就得考虑Survivor
空间够不够。就上述案例,一种情况是,Survivor
中分配了150MB,如果来的对象大于150MB,就会频繁进入老年代,第二种情况是,即使100MB对象能够放入Survivor
区,但是100/150 = 0.67,超过了Survivor
区空间的50%,这样同一批年龄对象也进入老年代了,这种1秒就变成垃圾的短生命周期对象根本不需要进入老年代。我们得让它们留在新生代里。
方案:给Survivor
区更大的容量。如果你的业务都是这种短生命周期的,老年代可以分配少一点的内存,我们可以考虑把新生代调整为2G,老年代为1G,如果-XX:SurvivorRatio=8
那么此时Eden
为1.6G,每个Survivor
为200MB,如图。这时候上述两个问题就同时解决了。
针对任何系统,我们要预估内存并合理分配内存,首要做的就是**尽量让每次Minor GC后的对象都留在Survivor里,不要进入老年代。**此时参数如下
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8
有些对象是可能躲过15次垃圾回收进入老年代的,就上述背景,有些对象在新生代躲了几分钟进入老年代很应该,那为了不让这种数据进入老年代要怎么做?我们需要调-XX:MaxTenuringThreshold
这个参数。这个参数并不是一昧地去调高,一定要结合系统的运行模型,看看Minor GC频率,把这个参数从15调高到20、30,让一个垃圾多在Survivor
中停留几分钟,根本没用,对于我们上述业务场景就要把这个参数调低,比如调到5.记住,一定要结合系统运行的模型。此时参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5
大对象是可以直接进入老年代的,但是多大呢?一般来说很少有超过1MB的对象,如果有,那就是你提前分配了一个大数组、大List之类的来存放缓存数据,一般这种数据是要用一段时间的,所以我们可以放到老年代。我们一般把这个参数设置为1。此时JVM参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
针对具体的客户端、服务端来设置垃圾回收器,之前讲过。我们这个系统新生代使用ParNew
,老年代使用CMS
。设置如下的参数:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
老年代参数优化主要就是减少Full GC的次数。首先我们得要分析对象进入老年代的几个原因
第一种:就是-XX:MaxTenuringThreshold
这个参数设置的太低了,就是之前新生代的案例,这种对象一般是@Service
或Controller
等注解标识的业务逻辑组件,这种对象一般全局有一个实例就行,是要一直用的,所以应该让他进。
第二种:就是大对象,但是这种再上述案例中一般没有,可以忽略。
第三种:就是Minor GC后存活的对象超过了Survivor
区的50%,就直接进入了老年代
对于此案例触发Full GC的几个情况:
情况一:没有打开-XX:HandlePromotionFailure
选项。**我们知道如果老年代剩余内存大于新生代对象总大小就直接Minor GC的,但是老年代剩余内存总大小小于新生代对象总大小时,就要看这个参数了,如果没有打开这个参数,老年代空间小于新生代所有对象大小就直接Full GC,如果打开了,就看平均。**这个参数就是看老年代剩余内存总大小是否大于之前每一次Minor GC进入老年代的对象的平均大小,按照之前项目案例,要很多次Minor GC之后才可能有一两次碰巧会有200MB对象升入老年代,所以这个“历次Minor GC后升入老年代的平均对象大小”,基本是很小的。(JDK 1.6之后就不看了)
情况二:某次升入老年代的对象很大,但是老年代空间不够了。
情况三:和-XX:CMSInitiatingOccupancyFaction
参数有关,默认值是92%,超过这个值就会GC。
针对大促销场景,由于我们之前在新生代优化了参数,所以对象进入老年代较慢,经验上来说,很可能是在系统运行半小时~1小时之后,才会有接近1GB的对象进入老年代。在大促期间,订单系统运行1小时之后,大促下单高峰期几乎都快过了,此时才可能会触发一次Full GC。这个高峰期过后,基本订单系统访问压力就很小了,那么GC的问题几乎就更不算什么了。
当然老年代也会触发Concurrent Mode Failure
问题。假设系统,运行1小时之后,老年代大概有900MB的对象了,剩余可用空间仅仅只有100MB了,然后CMS进行垃圾回收,垃圾回收期间是和系统程序并发的,如果系统此时还在创建对象,比如说很不巧有200MB对象要进来了,而老年代又放不下,那么此时就会进入Stop the World
,然后切换CMS为Serial Old,直接禁止程序运行,然后单线程进行老年代垃圾回收,回收掉900MB对象过后,再让系统继续运行。当然这个概率非常的小,我们没必要特意去优化它。
此时参数为:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=9
没必要特意去修改这个频率,对于上述大促销场景,在大促高峰期,Full GC可能也就1小时执行一次,然后大促高峰期过去之后,就没那么多的订单了,此时可能几个小时才会有一次Full GC。所以就保持默认的设置,每次Full GC之后都执行一次内存碎片整理就可以。要针对特定的业务场景来设定。仅仅针对这个参数来说:
首先和垃圾收集器没什么关系,不同的垃圾收集器,只是它们的性能、吞吐量不同,并不影响垃圾回收的时机。只要在新生代根据对象的存活特征,合理的去分配Eden区和s1、s2区域的大小,尽量让垃圾在新生代被回收就好了,注意这边开启内存担保(jdk 1.6),如果eden区超过了老年代大小,不开担保的话每次MGC前都要FGC的。
新生代存活对象小,并且采用复制算法,速度很快,复制过去直接就删除,而老年代对象量较大,遍历标记、遍历清除,然后还要整理好腾出空间来,很耗时,耗时的就是步骤二和步骤四。初始标记是从GC Roots查找直接引用的对象,并发标记也是从GC Roots出发,通过每个对象的引用地址来看哪些对象活着的,活着的又很多,就很耗时。