内存分配和垃圾回收调优

一、内存调优的目标
新生代的垃圾回收是比较简单的,Eden区满了无法分配新对象时就触发 YoungGC。而且新生代采用的复制算法效率极高,加上新生代存活的对象很少,只要迅速标记出这少量存活对象,移动到Survivor区,然后快速回收掉Eden区,速度很快。一般一次YoungGC就耗费几毫秒或几十毫秒,所以新生代GC对系统的影响基本不是很大。
但老年代的GC就不一样了,老年代GC通常都很耗费时间,尤其是频繁触发老年代GC(FullGC/OldGC)。因为无论是CMS垃圾回收器还是G1垃圾回收器,比如说CMS就要经历初始标记、并发标记、重新标记、并发清理、碎片整理几个环节,过程非常的复杂,STW的时间也会更长,G1同样也是如此。通常来说,FullGC至少比YoungGC慢10倍以上。
新生代对象进入老年代有四个时机:对象年龄超过阀值、大对象直接进入老年代,动态年龄判断规则、新生代GC后存活对象太多无法放入Survivor区。对象年龄太大进入老年代无可避免,因为这部分对象一般来说都是长期存活的对象,是需要进入老年代的。而后三个一般都是因为内存分配不合理或一些参数设置不合理导致对象进入老年代,而且基本都是生命周期较短的对象,然后占满老年代,触发老年代GC。
因此,基于JVM运行的系统最大的问题,就是因为内存分配、参数设置不合理,导致对象频繁的进入老年代,然后频繁触发FullGC,导致系统每隔一段时间就卡顿几百毫秒甚至几秒钟,这对用户体验来说将是极差的。
所以,JVM调优的目标,最重要的就是对内存分配调优,然后合理优化新生代、老年代、Eden和Survivor各个区域的内存大小。接着再尽量优化参数避免新生代的对象进入老年代,尽量让对象留在新生代里被回收掉,甚至不会出现 FullGC。
二、估算内存运转模型
在设置JVM内存的时候,是没有一个固定标准、固定参数的,但是有一套比较通用的分析和优化方法,就是根据实际业务预估这个系统未来的业务量、访问量,去推算这个系统每秒种的并发量,然后推算每秒钟的请求对内存空间的占用,进而推算出整个系统运行期间的JVM内存运转模型。然后通过各个参数调优,尽量让垃圾对象在年轻代被回收掉,避免频繁 Full GC。
下面就假定有一个每日百万交易的支付系统,来看看怎么估算一个比较合理的内存运转模型。
第1步:分析系统核心业务与核心压力
首先要分析出一个系统的核心压力集中在哪里,每日百万交易的支付系统,最核心的业务当属支付流程。每次支付请求将创建至少一个订单对象,这个订单对象包含支付的用户、渠道、金额、商品、时间等信息。
支付系统的压力有很多方面,包括高并发请求、高性能处理请求、大量订单数据存储等,但在JVM层面,这个支付系统最大的压力就是每天会在JVM中频繁的创建和销毁100万个支付订单对象。
第2步:预估每秒需处理多少次请求
要设置合理的JVM内存大小,首先要估算出核心业务每秒钟有多少次请求。假设每天100万个支付订单,一般用户交易都集中在每天的高峰期,也就是中午或晚上那3~4个小时,那么平均每秒就将近100次。
假设支付系统部署3台机器,那么平均到每台机器就30个支付请求。
第3步:估算一次请求耗时多久
用户发起一次支付请求,后端将创建一个订单对象、做一些关联校验、写入数据库等,还有一些其它操作,比如调用第三方支付平台等。假设一次支付请求耗时1秒吧,那么每秒钟就会产生30个订单对象,然后1秒后这30个对象就变为垃圾对象了。
第4步:估算每秒请求占多少内存
我们可以根据订单类中的实例变量类型来计算就可以了,比如 Integer 占4个字节,Long 占8个字节,String 类型根据长度来计算。假设一个订单类按20个字段来算,往大一点粗略估算占500字节吧。那么每秒30个支付请求就是 30 * 500B ≈ 15KB。
但实际上,每次请求的过程中,除了订单对象,往往还会创建大量其它类型的对象,比如其它的一些关联查询对象,Spring框架创建的对象等,这时一般需要对单个对象放大10~20倍。
而且支付系统还会包含其它的一些业务,比如交易记录、对账管理、结算管理等,再扩大个5~10倍。这样算下来每秒钟基本会产生1M左右的对象。
但这些也不是绝对的,对于一些特殊的系统,比如报表系统、数据计算系统,每次请求创建的对象可能超过10几M了,那么附属创建的这些对象可能影响就没那么大了,此时可以考虑忽略不计。
第5步:估算元空间大小
元空间主要是存放类型信息,也没什么太多好调优的,一般设置几百M够用就可以了,比如256M。
第6步:估算栈内存大小
线程栈主要就是运行期间存储方法的参数、局部变量等信息,一般设置1M就足够了。比如系统有100个线程,那么虚拟机栈就会至少占用100M内存。
第7步:内存分配
这个每日百万交易的支付系统部署3台机器,每台机器每秒扛30个请求。假设部署的机器是2核4G,但是机器本身运行还需要一些内存,那么JVM就只分2G,考虑到要给元空间、虚拟机栈预留空间,那假设堆内存只分1G,新生代给500M,老年代给500M,那 Eden 区就占400M,两个 Survivor 区各占50M。
这样估算下来,就是如下的内存参数设置:

-Xms1G -Xmx1G -Xmn500M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8

第8步:系统运转模型
经过上面的分析,再结合机器配置,我们就能大致估算这个系统的内存运转模型了。使用上面的内存设置,那么每秒接收30个请求,在Eden区创建30个订单相关的对象;将产生1M新对象,1秒后请求处理完成,将产生1M的垃圾对象;将在400秒后,也就几分钟的时间,Eden 区就占满了,然后触发 Young GC;YoungGC时会把存活对象复制到FromSurvivor区,然后回收掉新生代的垃圾对象,如此往复。如果Survivor区分配不合理,导致存活对象进入老年代,还可以估算出多久触发一次FullGC/OldGC。主要就是估算出GC的频率,然后就可以对内存进行调优了。
第9步:瞬时压力增加时的模型估算
如果遇到搞大促活动或一些突发的性能抖动,压力可能瞬间增加10倍甚至更多,那每秒可能就是上千笔支付请求,每秒内存占用至少10M以上了。这个时候每次支付请求可能就不是1秒能处理完的了,因为压力骤增,系统内存、线程资源、CPU资源都将打满,导致系统性能下降,这样可能有些支付请求需要耗时好几秒,那可能就有几十M对象会占用堆内存几秒钟。
还是按照2核4G的机器部署,堆内存设置1G,新生代500M,Eden区400M,Survivor50M。这时Eden区只需几十秒就满了,然后触发YoungGC。但是,因为压力增加,有些请求需要好几秒,就会有几十M对象会将无法被回收,就被复制到 Survivor 区。
这时就有多种情况了,首先存活几十M的对象可能大于Survivor区50M的内存,那么就会直接复制到老年代。然后如果小于Survivor区,也大于了Survivor区50%的空间了,下一次通过动态年龄规则判断也可能会将部分对象复制到老年代。
然后经过大概10几次YoungGC,也就几百秒后老年代也快满了,这时可能就会触发FullGC,FullGC时要暂停系统运行,无法处理任何请求,而且这种情况下老年代大部分都是垃圾对象,回收性能是很低的。

三、YoungGC 调优
1、合理分配内存降低YoungGC频率
根据前面的估算,在正常的情况下如果给堆分配1G的空间,会频繁触发 YoungGC,新生代回收虽然效率高,但也会 Stop The World,暂停系统运行,如果频繁YoungGC,就会频繁暂停系统。
我们可以考虑增大新生代内存,同时使用内存大一点的机器,比如使用4核8G,那么JVM分4G,给堆空间分配3G,新生代给1.5G,老年代给1.5G,Eden 区差不多1.2G,Survivor区150M,这个时候Eden区差不多要半个小时才会占满,然后触发一次YoungGC,而其中99%都是垃圾对象,采用标记-复制算法基本上很能就能完成YoungGC,这就大大降低了YoungGC的频率。
如果业务量更大,还可以考虑横向多部署几台机器,这样分到每台机器的请求就更少了,压力也更小。
2、保证Survivor空间足够
如果遇到大促活动,瞬时压力增大,每秒就会有10M以上的对象产生,然后有几十兆甚至上百兆的对象会存活几秒以上。按照前面的内存模型来分析下,那 Eden 区2分钟左右就会占满,然后将存活的几十兆对象复制到 Survivor 区;如果这批存活对象大于150M,将直接进入老年代;如果小于150M但大于 75M,那么由于动态年龄判断也有可能频繁导致部分生命周期短的对象进入老年代。老年代如果快速占满将频繁触发FullGC。
新生代调优最重要的一个就是尽量保证 Surivivor 空间足够,避免因为 YoungGC 时Survivor空间不够导致大批对象进入老年代,这样就能极大减少甚至不会FullGC了。
这种业务系统其实绝大多数对象的生命周期都很短,长时间存活的对象占不了多少内存,我们应该尽量让对象都留在新生代里。因此我们可以把新生代的内存占比调高一点,比如新生代给2G,老年代给1G,这样 Eden 区就占了1.6G,Survivor 占200M,这样就基本能保证每次YoungGC时存活的对象都能放进 Survivor 区了。或者再可以用 -XX:SurvivorRatio 参数调整下 Eden 区和 Survivor 区的比例,让 Survivor 区尽可能装下每次 YoungGC 后存活的对象。
3、优化对象年龄阀值
还有一种情况会导致新生代对象进入老年代,就是有些对象连续躲过15次回收后,就会晋升到老年代。这个我们也可以结合实际的业务模型做调整,比如大促的场景中,新生代分2G,Eden区分1.6G,差不多每隔3分钟就触发一次YoungGC,那么在新生代来回复制15次就是45分钟左右的时间才会进入老年代,对于这个系统来说,绝大多数对象的生命周期都是很短的,能存活几分钟以上的对象应该都是程序中的 Controller、Service、Repository 之类的需要长期存活的业务核心组件。
所以对于这种类型的系统,应尽快让长期存活的对象进入老年代,而不是在新生代来回复制15次后再进入老年代。可以通过 -XX:MaxTenuringThreshold 参数降低年龄阀值,比如设置为 5。
4、优化大对象阀值
还有一种情况就是大对象将直接进入老年代,大对象阀值一般设置1M就够了,一般来说很少有一个对象超过1M的。如果我们确定系统中会频繁创建生命周期短的大对象,我们可以适当调大这个阀值,避免其进入老年代。
可以通过参数 -XX:PretenureSizeThreshold=1M 来设置大对象阀值。
5、选择垃圾回收器
新生代垃圾回收器有 Serial、ParNew、ParallelScavenge,一般来说老年代要用性能较好的 CMS 垃圾回收器,那么新生代就只能指定 ParNew 回收器。
使用 ParNew 回收器,调优的思路基本就是前面4点,合理分配新生代内存,保证对象能放入 Survivor 区,避免进入老年代,基本 YoungGC 就没啥问题了。
6、JVM参数
调优后的JVM参数如下:

-Xms3G
-Xmx3G
-Xmn2G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC

四、FullGC 调优
老年代主要使用CMS垃圾回收器,我们就主要结合上面的业务模型来看看CMS回收器的各个参数优化。
1、多久触发一次 FullGC
在前面年轻代的优化基础之上,我们还需要估算系统多久会触发一次 Full GC,这将决定我们是否要重点优化下老年代。比如估算下来每隔一两个小时或更久执行一次 Full GC,这时候高峰期那一个小时已经过了,这时候执行 Full GC 对系统的影响来说其实是很小的了。
首先看下触发 Full GC 的条件:

  • ① JDK6 之前有个 -XX:HandlePromotionFailure 分配担保失败的参数,就是每次 YoungGC 前都会判断老年代的可用空间大小是否大于新生代对象总大小,按前面的配置,新生代最多会有 1.8G 的对象,老年大最大才 1G,那岂不是每次 YoungGC 都会担保失败。不过JDK1.6之后就没有这个参数了,也没有这个判断了。
  • ② 每次 YoungGC 前检查老年代可用空间是否大于历次YoungGC后进入老年代的平均对象大小,按照前面的配置,基本上对象在新生代就被回收了,历次进入老年代的平均对象大小其实是很小的,这个条件基本不会触发。
  • ③ 可能某次 YoungGC 后存活对象大于 Survivor 区大小了,要复制到老年代,但发现老年代空间不足也放不下了,这时就会触发FullGC,但年轻代优化好之后,这种概率是非常小的了。
  • ④ CMS 有个 92% 的阀值,就是老年代超过 92% 的时候,会自动触发老年代垃圾回收,这个参数可以通过 -XX:CMSInitiatingOccupancyFraction 设置。

系统运行时,可能会有部分对象慢慢进入老年代,但是新生代优化好之后,对象晋升到老年代的速度是很慢的,可能需要几个小时才触发一次 FullGC。错过高峰期,FullGC 的影响也不会太大。
2、CMS并发失败
触发老年代GC后,基本就是老年代快满了,CMS有个92%的阀值,那么1G的老年代,就还剩100M左右空间,如果老年代在并发回收时,新晋升到老年代的对象超过100M了,就会导致并发失败(Concurrent Model Failure)。并发失败后,就会进入 Stop The World 的状态,老年代切换为 Serial Old 回收器,Serial Old 回收器是单线程回收,效率非常低的。
但是经过年轻代的调优后,对象升入老年代的速度是很慢的,而且每次升入老年代的平均对象大小是很小的,所以一般在并发回收时还有超过100M的对象升入老年代的概率也是很小的。这种情况下我们一般也不用去调整 -XX:CMSInitiatingOccupancyFraction 参数的值。
3、CMS回收后碎片整理频率
CMS完成FullGC后,默认是每次都会进行一次内存碎片整理,这个过程也会 Stop The World。但是按照前面的分析,其实我们也没必须要调整这部分参数。
CMS 通过 -XX:+UseCMSCompactAtFullCollection 参数开启GC后内存碎片整理的过程,通过 -XX:CMSFullGCsBeforeCompaction 设置多少次FullGC后进行内存碎片整理,默认0,就是每次FullGC后都整理。
一般不用调整 CMSFullGCsBeforeCompaction 的值,提高这个值,意味着要多次 FullGC 后才会进行内存碎片整理,那么前几次FullGC会导致很多内存碎片产生,不整理就会导致更频繁的触发FullGC,因为虽然FullGC后可用空间很多,但可用的连续空间并不多。所以一般是设置为0,每次FullGC后整理内存碎片。
4、CMS提升FullGC的性能
CMS还有两个参数可以进一步优化FullGC的性能,降低FullGC的时间。
-XX:+CMSParallelInitialMarkEnabled:开启这个参数会在CMS垃圾回收器的“初始标记”阶段开启多线程并发执行,减少STW的时间,进一步降低FullGC的时间。
-XX:+CMSScavengeBeforeRemark:这个参数会在CMS的重新标记阶段之前,先尽量执行一次YoungGC。CMS的重新标记也会STW,所以如果在重新标记之前,先执行一次YoungGC,就会回收掉一些年轻代里没有被引用的对象,那么在CMS的重新标记阶段就可以少扫描一些对象,此时就可以提升CMS的重新标记阶段的性能,减少这个阶段的耗时。(注意:无论是并发标记还是重新标记,都会扫描整个堆的对象,因为就算对象在老年代,也可能被新生代对象引用着)
5、禁用System.gc
在代码中,我们可以通过 System.gc() 建议JVM执行一次 FullGC,但JVM不一定会执行。但这个方法不能随便调用,基本上来说是禁止手动 GC 的,因为使用不当很有可能会频繁触发 FullGC。
针对这个,我们一般可以通过加入 -XX:+DisableExplicitGC 参数来禁止显示执行GC,就是不允许通过代码 System.gc 来触发GC。
6、元空间优化
FullGC 不只老年代满了会触发,元空间配置不当或动态加载的类过多也有可能频繁触发 FullGC。

  • 一般可能有如下情况会动态生成类放入Metaspace区域:
  • 比如通过 ASM、CGLib、javassist 等字节码框架创建代理类

还有通过反射调用时,如 Method method = XXX.class.getDeclaredMethod(); method.invoke(target, args);,在反射调用一定次数后就会动态生成一些类
如果由于元空间导致了 FullGC,我们可以加上 -XX:+TraceClassLoading、-XX:+TraceClassUnloading 来观察有哪些类频繁的被加载和卸载,然后分析出根源问题。
有两个参数可控制元空间的大小:

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是 -1,即不限制,只受限于本地内存大小
  • -XX:MetaspaceSize:指定元空间的初始空间大小,达到该值就会触发垃圾回收进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过 -XX:MaxMetaspaceSize的情况下,适当提高该值。

7、JVM参数

-Xms3G
-Xmx3G
-Xmn2G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92
-XX:CMSWaitDuration=2000
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:+CMSParallelInitialMarkEnabled
-XX:+CMSScavengeBeforeRemark
-XX:+DisableExplicitGC

五、大内存机器GC调优
1、使用大内存机器的场景
前面通过对支付系统的优化,YoungGC 的频率为几分钟一次,Full GC 基本不会发生。但是像遇到双十一这样的大促场景,可能就凌晨那几分钟就会增加平时数十倍甚至上百倍的压力,这个时候如果还是按照4核8G的内存来部署,那可能需要上百台机器。这个时候就可以考虑提升机器的配置,比如提升到16核32G,每台机器每秒可以扛几千次请求,这样就只需要部署十多台机器可能就够了。
其实还有类系统比如报表系统、BI系统、数据计算系统、大数据系统,这类系统的核心业务如数据报表,一次请求可能会查询几十上百兆数据在内存中做计算,如果还是使用小内存机器,那么Eden区将迅速填满,然后触发 YoungGC,而且随着并发压力增加,需要加更多机器。这种情况下我们一般就可以提高机器配置,使用大内存机器来部署了。
总的来说使用大内存机器的场景一般就是由于并发量高或每次请求内存占用高导致频繁YoungGC,然后需要增加很多台机器的时候,为了减少机器的数量,我们就可以使用大内存机器来部署。
2、大内存机器的问题
比如使用16核32G的内存,假设新生代给20G,那么Eden区就是16G,Survivor 区各占2G。按每秒产生50M对象来计算,5分钟左右就会触发一次YoungGC。内存比之前扩大了10倍,这时如果还是使用 ParNew+CMS这样的垃圾回收器组合,YoungGC 的停顿时间就需要几百毫秒甚至一两秒,这个时候就是每隔几分钟卡个几百毫秒。而且由于长时间卡顿,还会导致请求积压排队,严重的时候还会导致有些请求超时返回。如果再提高配置,比如使用32核64G,那每次YoungGC就需要停顿几秒钟了,这对系统的影响就非常大了。
这个时候就可以使用G1回收器来解决大内存YoungGC过慢的问题。我们可以给G1设置一个预期的GC停顿时间,比如100毫秒,这样G1会保证每次YoungGC停顿时间不超过100毫秒,避免影响用户的体验。
不过对于一些后台运行不直接面向用户的系统,就算一次GC耗时1秒或几秒其实影响也不大,这个时候就没必要用G1回收器了。
3、G1回收器调优
1)G1内存布局
G1 可以使用 -XX:G1NewSizePercent 设置新生代Region初始占比,默认是5%;使用 -XX:G1MaxNewSizePercent 设置新生代Region最大占比,默认是 60%。这两个参数一般不用去设置,使用默认值就可以了。
默认情况下,G1 每个 Region 大小为堆内存大小除以2048,取2的N次幂。也可以通过 -XX:G1HeapRegionSize 参数设置每个 Region 的大小。
2) GC停顿时间
G1 有一个非常重要的参数会影响到G1回收器的表现:-XX:MaxGCPauseMillis,用来设置一次GC最大的停顿时间。这个参数一般需要结合系统压测工具、GC日志、内存分析工具来综合参考,要尽量让GC的频率别太高,同时每次GC停顿时间也别太长,达到一个理想的合理值。
G1会随着系统的运行,不断给新生代分配Region,但并不是非要到60%时才触发YoungGC。其实G1到底会分配多少个Region给新生代,多久触发一次YoungGC,每次耗费多长时间,这些都是不确定的。它整个都是动态的,它会根据预设的停顿时间,给新生代分配一些内存,然后到一定程度就触发YoungGC,把GC时间控制在预设的时间内,避免一次回收过多的Region导致GC停顿时间超出预期,又避免一次回收过少的Region导致频繁GC。
3)MixedGC 优化
G1 默认在老年代占比超过45%时,就会触发 MixedGC。其实优化 MixedGC 最重要的还是优化内存分配,尽量避免对象进入老年代,尽量避免频繁触发 MixedGC 就行了。
然后还是最核心的 -XX:MaxGCPauseMillis 参数,如果这个参数设置过高,导致系统运行很久,然后新生代占比达到60%了,这个时候可能存活下来的对象放不进Survivor区或者触发Survivor区动态年龄判断,就会导致有些对象进入老年代,进而触发MixedGC。所以就需要合理设置这个参数,保证YoungGC别太频繁的同时,还得考虑每次GC过后存活的对象大小,避免大量对象进入老年代而触发 MixedGC。
4)JVM参数

-Xms24G
-Xmx24G
-Xmn20G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseG1GC 
-XX:+UnlockExperimentalVMOptions 
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60
-XX:G1HeapRegionSize=4M
-XX:MaxGCPauseMillis=200 
-XX:ParallelGCThreads=4

 

posted @ 2021-06-06 15:14  郭慕荣  阅读(328)  评论(0编辑  收藏  举报