JVM性能调优(4) —— 内存分配和垃圾回收调优

2,056 阅读37分钟

系列文章专栏:JVM系列专栏

系列文章:

内存调优的目标

新生代的垃圾回收是比较简单的,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 调优

合理分配内存降低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的频率。

如果业务量更大,还可以考虑横向多部署几台机器,这样分到每台机器的请求就更少了,压力也更小。

保证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 后存活的对象。

优化对象年龄阀值

还有一种情况会导致新生代对象进入老年代,就是有些对象连续躲过15次回收后,就会晋升到老年代。这个我们也可以结合实际的业务模型做调整,比如大促的场景中,新生代分2G,Eden区分1.6G,差不多每隔3分钟就触发一次YoungGC,那么在新生代来回复制15次就是45分钟左右的时间才会进入老年代,对于这个系统来说,绝大多数对象的生命周期都是很短的,能存活几分钟以上的对象应该都是程序中的 Controller、Service、Repository 之类的需要长期存活的业务核心组件。

所以对于这种类型的系统,应尽快让长期存活的对象进入老年代,而不是在新生代来回复制15次后再进入老年代。可以通过 -XX:MaxTenuringThreshold 参数降低年龄阀值,比如设置为 5。

优化大对象阀值

还有一种情况就是大对象将直接进入老年代,大对象阀值一般设置1M就够了,一般来说很少有一个对象超过1M的。如果我们确定系统中会频繁创建生命周期短的大对象,我们可以适当调大这个阀值,避免其进入老年代。

可以通过参数 -XX:PretenureSizeThreshold=1M 来设置大对象阀值。

选择垃圾回收器

新生代垃圾回收器有 Serial、ParNew、ParallelScavenge,一般来说老年代要用性能较好的 CMS 垃圾回收器,那么新生代就只能指定 ParNew 回收器。

使用 ParNew 回收器,调优的思路基本就是前面4点,合理分配新生代内存,保证对象能放入 Survivor 区,避免进入老年代,基本 YoungGC 就没啥问题了。

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回收器的各个参数优化。

多久触发一次 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 的影响也不会太大。

CMS并发失败

触发老年代GC后,基本就是老年代快满了,CMS有个92%的阀值,那么1G的老年代,就还剩100M左右空间,如果老年代在并发回收时,新晋升到老年代的对象超过100M了,就会导致并发失败(Concurrent Model Failure)。并发失败后,就会进入 Stop The World 的状态,老年代切换为 Serial Old 回收器,Serial Old 回收器是单线程回收,效率非常低的。

但是经过年轻代的调优后,对象升入老年代的速度是很慢的,而且每次升入老年代的平均对象大小是很小的,所以一般在并发回收时还有超过100M的对象升入老年代的概率也是很小的。这种情况下我们一般也不用去调整 -XX:CMSInitiatingOccupancyFraction 参数的值。

CMS回收后碎片整理频率

CMS完成FullGC后,默认是每次都会进行一次内存碎片整理,这个过程也会 Stop The World。但是按照前面的分析,其实我们也没必须要调整这部分参数。

CMS 通过 -XX:+UseCMSCompactAtFullCollection 参数开启GC后内存碎片整理的过程,通过 -XX:CMSFullGCsBeforeCompaction 设置多少次FullGC后进行内存碎片整理,默认0,就是每次FullGC后都整理。

一般不用调整 CMSFullGCsBeforeCompaction 的值,提高这个值,意味着要多次 FullGC 后才会进行内存碎片整理,那么前几次FullGC会导致很多内存碎片产生,不整理就会导致更频繁的触发FullGC,因为虽然FullGC后可用空间很多,但可用的连续空间并不多。所以一般是设置为0,每次FullGC后整理内存碎片。

CMS提升FullGC的性能

CMS还有两个参数可以进一步优化FullGC的性能,降低FullGC的时间。

  • -XX:+CMSParallelInitialMarkEnabled:开启这个参数会在CMS垃圾回收器的“初始标记”阶段开启多线程并发执行,减少STW的时间,进一步降低FullGC的时间。

  • -XX:+CMSScavengeBeforeRemark:这个参数会在CMS的重新标记阶段之前,先尽量执行一次YoungGC。CMS的重新标记也会STW,所以如果在重新标记之前,先执行一次YoungGC,就会回收掉一些年轻代里没有被引用的对象,那么在CMS的重新标记阶段就可以少扫描一些对象,此时就可以提升CMS的重新标记阶段的性能,减少这个阶段的耗时。(注意:无论是并发标记还是重新标记,都会扫描整个堆的对象,因为就算对象在老年代,也可能被新生代对象引用着)

禁用System.gc

在代码中,我们可以通过 System.gc() 建议JVM执行一次 FullGC,但JVM不一定会执行。但这个方法不能随便调用,基本上来说是禁止手动 GC 的,因为使用不当很有可能会频繁触发 FullGC。

针对这个,我们一般可以通过加入 -XX:+DisableExplicitGC 参数来禁止显示执行GC,就是不允许通过代码 System.gc 来触发GC。

元空间GC优化

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 的情况下,适当提高该值。

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调优

使用大内存机器的场景

前面通过对支付系统的优化,YoungGC 的频率为几分钟一次,Full GC 基本不会发生。但是像遇到双十一这样的大促场景,可能就凌晨那几分钟就会增加平时数十倍甚至上百倍的压力,这个时候如果还是按照4核8G的内存来部署,那可能需要上百台机器。这个时候就可以考虑提升机器的配置,比如提升到16核32G,每台机器每秒可以扛几千次请求,这样就只需要部署十多台机器可能就够了。

其实还有类系统比如报表系统、BI系统、数据计算系统、大数据系统,这类系统的核心业务如数据报表,一次请求可能会查询几十上百兆数据在内存中做计算,如果还是使用小内存机器,那么Eden区将迅速填满,然后触发 YoungGC,而且随着并发压力增加,需要加更多机器。这种情况下我们一般就可以提高机器配置,使用大内存机器来部署了。

总的来说使用大内存机器的场景一般就是由于并发量高或每次请求内存占用高导致频繁YoungGC,然后需要增加很多台机器的时候,为了减少机器的数量,我们就可以使用大内存机器来部署。

大内存机器的问题

比如使用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回收器了。

G1回收器调优

G1内存布局

G1 可以使用 -XX:G1NewSizePercent 设置新生代Region初始占比,默认是5%;使用 -XX:G1MaxNewSizePercent 设置新生代Region最大占比,默认是 60%。这两个参数一般不用去设置,使用默认值就可以了。

默认情况下,G1 每个 Region 大小为堆内存大小除以2048,取2的N次冥。也可以通过 -XX:G1HeapRegionSize 参数设置每个 Region 的大小。

GC停顿时间

G1 有一个非常重要的参数会影响到G1回收器的表现:-XX:MaxGCPauseMillis,用来设置一次GC最大的停顿时间。这个参数一般需要结合系统压测工具、GC日志、内存分析工具来综合参考,要尽量让GC的频率别太高,同时每次GC停顿时间也别太长,达到一个理想的合理值。

G1会随着系统的运行,不断给新生代分配Region,但并不是非要到60%时才触发YoungGC。其实G1到底会分配多少个Region给新生代,多久触发一次YoungGC,每次耗费多长时间,这些都是不确定的。它整个都是动态的,它会根据预设的停顿时间,给新生代分配一些内存,然后到一定程度就触发YoungGC,把GC时间控制在预设的时间内,避免一次回收过多的Region导致GC停顿时间超出预期,又避免一次回收过少的Region导致频繁GC。

MixedGC 优化

G1 默认在老年代占比超过45%时,就会触发 MixedGC。其实优化 MixedGC 最重要的还是优化内存分配,尽量避免对象进入老年代,尽量避免频繁触发 MixedGC 就行了。

然后还是最核心的 -XX:MaxGCPauseMillis 参数,如果这个参数设置过高,导致系统运行很久,然后新生代占比达到60%了,这个时候可能存活下来的对象放不进Survivor区或者触发Survivor区动态年龄判断,就会导致有些对象进入老年代,进而触发MixedGC。所以就需要合理设置这个参数,保证YoungGC别太频繁的同时,还得考虑每次GC过后存活的对象大小,避免大量对象进入老年代而触发 MixedGC。

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

OOM内存溢出问题

在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(OOM)异常的可能。通常而言,内存溢出问题对系统是毁灭性的,它代表VM内存不足以支撑程序的运行,所以—旦发生这个情况,就会导致系统直接停止运转,甚至会导致VM进程直接崩溃掉。OOM是非常严重的问题,这节就来看下通常有哪些原因导致OOM。

元空间溢出

元空间溢出原因

Metaspace 这块区域一般很少发生内存溢出,如果发生内存溢出—般都是因为两个原因:

  • Metaspace 参数设置不当,比如 Metaspace 内存给的太小,就很容易导致 Metaspace 不够用
  • 代码中用 CGLib、ASM、javassist 等动态字节码技术动态创建一些类,如果代码写的有问题就可能导致生成过多的类而把 Metaspace 塞满

模拟元空间溢出

下面通过CGLib来不断创建类来模拟塞满 Metaspace。

首先在 pom.xml 添加 cglib 的依赖:

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.2.4</version>
</dependency>

下面这段程序通过CGLib不断地创建代理类:

public class GCMain {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(IService.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }

    static class IService {    }
}

设置如下的JVM参数:元空间固定10M,还添加了追踪类加载和卸载的参数:

-Xms200M
-Xmx200M
-Xmn150M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
-XX:+UseConcMarkSweepGC
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log

运行程序一会就报OOM错误,然后直接退出运行。

Caused by: java.lang.OutOfMemoryError: Metaspace 可以看出是由于 Metaspace 引起的OOM。而且从上面类加载的追踪可以看到,程序一直在加载CGLIB动态创建的代理类。

再看下GC日志:可以看出由于元空间满了触发了一次 FullGC。

栈溢出

栈溢出原因

通过前两篇文章可以知道,每个线程都会有一个线程栈,线程栈的大小是固定的,比如设置的1MB。这个线程每调用一个方法,都会将调用方法的栈桢压入线程栈里,方法调用结束就弹出栈帧。栈桢会存储方法的局部变量、异常表、方法地址等信息,也是会占用一定内存的。

如果这个线程不停的调用方法,不停的压入栈帧,而没有弹出栈帧,比如递归调用没有写好结束条件,那线程栈迟早都会被占满,然后导致栈内存溢出。一般来说,引发栈内存溢出,往往都是代码里写了一些bug导致的,正常情况下很少发生。

关于虚拟机栈和本地方法栈,《Java虚拟机规范》中描述了两种异常:StackOverflowError 和 OutOfMemoryError

1、StackOverflowError

如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。栈深度在大多数情况下到达1000~2000是完全没有问题,对于正常的方法调用,这个深度应该完全够用了。

2、OutOfMemoryError

如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError 异常。而HotSpot虚拟机是不支持扩展的,而且栈深度是动态变化的,在设置线程栈大小时(-Xss),如果设置小一些,相应的栈深度就会缩小。

所以 HotSpot 虚拟机栈溢出只会因为栈容量无法容纳新的栈帧而导致 StackOverflowError 异常,而不会出现 OutOfMemoryError 异常。

模拟栈溢出

运行如下这段代码:递归调用 recursion 方法,没有结束条件,所以必定会导致栈溢出

public class GCMain {

    public static void main(String[] args) {
        recursion(1);
    }

    public static void recursion(int count) {
        System.out.println("times: " + count++);
        recursion(count);
    }
}

设置如下JVM参数:线程栈设置为256K

-Xms200M
-Xmx200M
-Xmn150M
-Xss256K
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M

运行一会就出现了 StackOverflowError 异常:

堆溢出

堆溢出原因

堆内存溢出主要就是因为有限的内存中放了过多的对象,而且大多数都是存活的,即使GC过后还是大部分都存活,然后堆内存无法在放入对象就导致堆内存溢出。

—般来说堆内存溢出有两种主要的场景:

  • 系统负载过高,请求量过大,导致大量对象都是存活的,无法继续放入对象后,就会引发OOM系统崩溃
  • 系统有内存泄漏的问题,莫名其妙创建了很多的对象,而且都是存活的,GC时无法回收,最终导致OOM

模拟堆溢出

运行如下代码:不断的创建 String 对象,而且都被 datas 引用着无法被回收掉,最终必然会导致OOM。

public static void main(String[] args) {
    Set<String> datas = new HashSet<>();
    while (true) {
        datas.add(UUID.randomUUID().toString());
    }
}

设置如下JVM参数:新生代、老年代各100M

-Xms200M
-Xmx200M
-Xmn100M
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
-XX:+UseParNewGC

OutOfMemoryError:可以看到由于Java heap space 不够了导致OOM。

堆外内存溢出

堆外内存

Java中还有一块区域叫直接内存 Direct Memory,也叫堆外内存,它的的容量大小可通过 -XX:MaxDirectMemorySize 参数来指定,如果不指定,则默认与Java堆最大值(-Xmx)一致。

如果想在Java代码里申请使用一块堆外内存空间,可以使用 DirectByteBuffer 这个类,然后构建一个 DirectByteBuffer 对象,这个对象本身是在JVM堆内存里的。但是在构建这个对象的同时,就会在堆外内存中划出来一块内存空间跟这个对象关联起来。当 DirectByteBuffer 对象没地方引用了,成了垃圾对象之后,就会在某一次YoungGC或FullGC的时候把 DirectByteBuffer 对象回收掉,然后就可以释放掉 DirectByteBuffer 关联的堆外内存了。

模拟堆外内存溢出

如果创建了很多的 DirectByteBuffer 对象,占用了大量的堆外内存,而这些 DirectByteBuffer 对象虽然成为了垃圾对象,如果没有被GC回收掉,那么就不会释放堆外内存,久而久之,就有可能导致堆外内存溢出。

但是NIO实际上有个机制是当堆外内存快满了的时候,就调用一次 System.gc() 来建议JVM去执行一次 GC,把垃圾对象回收掉,进而释放堆外内存。

运行如下代码:通过 ByteBuffer.allocateDirect 循环分配1M的堆外内存,allocateDirect 内部会构建 DirectByteBuffer 对象。

public class GCMain {
    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) {
        ByteBuffer byteBuffer;
        for (int i = 0; i < 40; i++) {
            byteBuffer = ByteBuffer.allocateDirect(_1M);
        }
    }
}

设置如下JVM参数:新生代300M,堆外内存最大20M,这样不会触发YoungGC。

-Xms500M
-Xmx500M
-Xmn300M
-XX:MaxDirectMemorySize=20M
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log

运行程序后看GC日志:可以看到由于堆外内存不足,NIO调用了两次 System.gc(),这样就没有导致OOM了。

如果我们再加上 -XX:+DisableExplicitGC 参数,禁止调用 System.gc()

-Xms500M
-Xmx500M
-Xmn300M
-XX:MaxDirectMemorySize=20M
-XX:+DisableExplicitGC
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log

这时就会发现抛出了堆外内存溢出的异常了:

所以一般来说,如果程序中使用了堆外内存时,为了保险起见,就不要设置 -XX:+DisableExplicitGC 参数了。

OOM问题如何解决

OOM分析思路

一般来说解决OOM问题大致的思路是类似的,出现OOM时,首先从日志中分析是哪块区域内存溢出了,然后分析下OOM的线程栈,如果是自己编写的代码通过线程栈基本就能看出问题所在。

然后先检查下内存是否分配合理,是否存在频繁YoungGC和FullGC,因为如果内存分配不合理就会导致年轻代和老年代迅速占满或长时间有大量对象存活,那必然很快占满内存,也有可能导致OOM。

最后可以结合MAT工具分析下堆转储快照,堆转储包含了堆现场全貌和线程栈信息,可以知道是什么对象太多导致OOM的,然后分析对象引用情况,定位是哪部分代码导致的内存溢出,找出根源问题所在。

但是分析OOM问题一般来说是比较复杂的,一般线上系统OOM都不是由我们编写的代码引发的,可能是由于使用的某个开源框架、容器等导致的,这种就需要了解这个框架,进一步分析其底层源码才能从根本上了解其原因。

堆转储快照

加入如下启动参数就可以在OOM时自动dump内存快照:

  • -XX:+HeapDumpOnOutOfMemoryError:OOM时自动dump内存快照
  • -XX:HeapDumpPath=dump.hprof:快照文件存储位置

有了内存快照后就可以使用 MAT 这类工具来分析大量创建了哪些对象。但是对于堆外内存溢出来说,dump的快照文件不会看见什么明显的异常,这个时候就要注意检查下程序是不是使用了堆外内存,比如使用了NIO,然后从这方面入手去排查。

性能调优总结

调优过程总结

一般来说GC频率是越少越好,YoungGC的效率很快,FullGC则至少慢10倍以上,所以应尽可能让对象在年轻代回收掉,减少FullGC的频率。一般一天只发生几次FullGC或者几天发生一次,甚至不发生FullGC才是一个比较良好的JVM性能。

从前面的调优过程可以总结出来,老年代调优的前提是年轻代调优,年轻代调优的前提是合理分配内存空间,合理分配内存空间的前提就是估算内存使用模型。

因此JVM调优的大致思路就是先估算内存使用模型,合理分配各代的内存空间和比例,尽量让年轻代存活对象进入Survivor区,让垃圾对象在年轻代被回收掉,不要进入老年代,减少 FullGC 的频率。最后就是选择合适的垃圾回收器。

频繁FullGC的几种表现

当出现如下情况时,我们就要考虑是不是出现频繁的FullGC了:

  • 机器 CPU 负载过高
  • 频繁 FullGC 报警
  • 系统无法处理请求或者处理过慢

CPU负载过高一般就两个场景:

  • 在系统里创建了大量的线程,这些线程同时并发运行,而且工作负载都很重,过多的线程同时并发运行就会导致机器CPU负载过高。
  • 机器上运行的VM在执行频繁的FullGC,FullGC是非常耗费CPU资源的。而且频繁的FullGC会导致系统时不时的卡死。

频繁FullGC的几种常见原因

① 系统承载高并发请求,或者处理数据量过大,导致YoungGC很频繁,而且每次YoungGC过后存活对象太多,内存分配不合理,Survivor区域过小,导致对象频繁进入老年代,频繁触发FullGC

② 系统一次性加载过多数据进内存,搞出来很多大对象,导致频繁有大对象进入老年代,然后频繁触发FullGC

③ 系统发生了内存泄漏,创建大量的对象,始终无法回收,一直占用在老年代里,必然频繁触发FullGC

④ Metaspace 因为加载类过多触发FullGC

⑤ 误调用 System.gc() 触发 FullGC

JVM参数模板

通过前面的分析总结,JVM参数虽然没有固定的标准,但对于一般的系统,我们其实可以总结出一套通用的JVM参数模板,基本上保证JVM的性能不会太差,又不用一个个系统去调优,在某个系统遇到性能问题时,再针对性的去调优就可以了。

对于一般的系统,我们可能使用4核8G的机器来部署,那么总结一套模板如下:

  • 堆内存分配4G,新生代3G,老年代1G,Eden区2.4G,Survivor区各300M,一般来说YoungGC后存活的对象小于150M就没太大问题
  • 元空间给个 512M 一般就足够了,如果系统会运行时创建很多类,可以调大这个值
  • -XX:MaxTenuringThreshold 对象GC年龄调整为5岁,让长期存活的对象更快的进入老年代
  • -XX:PretenureSizeThreshold 大对象阀值设置为1M,如果有超过1M的大对象,可以调整下这个值
  • -XX:+UseParNewGC、-XX:+UseConcMarkSweepGC,垃圾回收器使用 ParNew + CMS 的组合
  • -XX:CMSFullGCsBeforeCompaction 设置为0,每次FullGC后都进行一次内存碎片整理
  • -XX:+CMSParallelInitialMarkEnabled,CMS初始标记阶段开启多线程并发执行,降低FullGC的时间
  • -XX:+CMSScavengeBeforeRemark,CMS重新标记阶段之前,先尽量执行一次Young GC
  • -XX:+DisableExplicitGC,禁止显示手动GC
  • -XX:+HeapDumpOnOutOfMemoryError,OOM时导出堆快照便于分析问题
  • -XX:+PrintGC,打印GC日志便于出问题时分析问题
-Xms4G
-Xmx4G
-Xmn3G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=512M
-XX:MaxMetaspaceSize=512M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSWaitDuration=2000
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:+CMSParallelInitialMarkEnabled
-XX:+CMSScavengeBeforeRemark
-XX:+DisableExplicitGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=dump.hprof
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log

JVM参数

前面已经提到过很多JVM的参数了,这节再简单汇总下,以及部分不常用的参数。

Java启动参数共分为三类:

  • 标准参数(-):所有的JVM实现都必须实现这些参数的功能,而且向后兼容,如 -version、-classpath
  • 非标准参数(-X):默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容,如 -Xms、-Xmx
  • 非Stable参数(-XX):此类参数各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用,如 -XX:UseParNewGC-XX:MetaspaceSize

JVM标准参数(-)

通过 java -help 命令可以看到JVM的标准参数

JVM非标准参数(-X)

通过 java -X 命令可以看到JVM非标准参数

常用参数:

JVM非Stable参数(-XX)

JVM非Stable参数分为三类:

  • 功能开关参数:一些功能的开关,用于改变jvm的一些基础行为
  • 性能调优参数:用于jvm的性能调优
  • 调试参数:一般用于打开跟踪、打印、输出等jvm参数,用于显示jvm更加详细的信息

注意:带有加号“+”、减号“-”的参数一般为开关参数,加号就是启用,减号就是禁用,如 -XX:+/-UseAdaptiveSizePolicy。不带加减号的就需要通过等号“=”带上参数值,如 -XX:SurvivorRatio=8

可以通过设置 -XX:+PrintFlagsFinal 在启动时打印所有JVM的参数及其值。

功能开关参数

1、垃圾回收器相关参数

2、其它的一些参数

性能调优参数

调试参数

即时编译调优参数

类初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。最初,虚拟机中的字节码是由解释器Interpreter完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中。如果没有 JIT 即时编译,每次运行相同的代码都会使用解释器编译。

与编译优化有关的主要有即时编译器的选择、热点探测计数阀值的优化、方法内联、逃逸分析、锁消除、标量替换等,一般来说也不用对编译进行调优,这里就不展开说了,下面先列举下编译优化相关的一些JVM参数。