【从浅入深JVM】4、内存分配和垃圾回收调优

前文回顾:

【从浅入深JVM】1、JVM是什么,JVM整体结构
【从浅入深JVM】2、JVM内存模型和类加载运行机制
【从浅入深JVM】3、垃圾收集器和回收策略

一、内存调优的目标

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

六、OOM 内存溢出问题

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

1、元空间溢出

1)元空间溢出原因

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

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

下面通过 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。

图片

2、栈溢出

1)栈溢出原因

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

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

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

① StackOverflowError

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

② OutOfMemoryError

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

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

2)模拟栈溢出

运行如下这段代码:递归调用 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 异常:

图片

3、堆溢出

1)堆溢出原因

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

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

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

运行如下代码:不断的创建 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。

图片

4、堆外内存溢出

1)堆外内存

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

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

2)模拟堆外内存溢出

如果创建了很多的 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 参数了。

5、OOM 问题如何解决

1)OOM 分析思路

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

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

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

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

2)堆转储快照

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

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

七、性能调优总结

1、调优过程总结

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

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

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

图片

2、频繁 FullGC 的几种表现

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

  • 机器 CPU 负载过高

  • 频繁 FullGC 报警

  • 系统无法处理请求或者处理过慢
    CPU 负载过高一般就两个场景:

  • 在系统里创建了大量的线程,这些线程同时并发运行,而且工作负载都很重,过多的线程同时并发运行就会导致机器 CPU 负载过高。

  • 机器上运行的 VM 在执行频繁的 FullGC,FullGC 是非常耗费 CPU 资源的。而且频繁的 FullGC 会导致系统时不时的卡死。

    3、频繁 FullGC 的几种常见原因

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

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

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

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

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

4、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

    1、JVM 标准参数(-)

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

图片

2、JVM 非标准参数(-X)

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

图片

常用参数:

图片

3、JVM 非 Stable 参数(-XX)

JVM 非 Stable 参数分为三类:

  • 功能开关参数:一些功能的开关,用于改变 jvm 的一些基础行为
  • 性能调优参数:用于 jvm 的性能调优
  • 调试参数:一般用于打开跟踪、打印、输出等 jvm 参数,用于显示 jvm 更加详细的信息
    注意:带有加号“+”、减号“-”的参数一般为开关参数,加号就是启用,减号就是禁用,如 -XX:+/-UseAdaptiveSizePolicy。不带加减号的就需要通过等号“=”带上参数值,如 -XX:SurvivorRatio=8。

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

1)功能开关参数

① 垃圾回收器相关参数

图片

② 其它的一些参数

图片

2)性能调优参数

图片

3)调试参数

图片

4、即时编译调优参数

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

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

图片

参考

本文是学习、参考了如下课程,再通过自己的总结和实践总结而来。如果想了解更多深入的细节,建议阅读原著。

从 0 开始带你成为JVM实战高手

极客时间:Java性能调优实战

《深入理解 Java 虚拟机:JVM 高级特性与最佳实践 第三版》

作者:bojiangzhou
出处:http://www.cnblogs.com/chiangchou/

发表回复