本篇文章主要在狸猫技术窝中有关JVM中调优的一些实战基础上进行总结,可以算是自己的一篇学习总结。主要以目前主流的两种垃圾回收组合方式,ParNew +CMS及G1垃圾回收器为基础,梳理下调优思路、GC日志如何阅读及引发OOM的区域和原因。
ParNew一般用在新生代的垃圾回收器,CMS用在老年代的垃圾回收器,他们都是多线程并发机制,性能更好,现在一般是线上生产系统的标准组合。
Minor GC又称年轻代垃圾回收,年轻代垃圾回收主要采用复制算法,由于年轻代对象大都“朝生夕死”,为降低内存使用率瓶颈,设置了Eden区和2个Survior区,1个Eden区占80%内存空间,每一块Survivor区各占10%内存空间。当前Minor GC主要采用ParNew垃圾回收器。
新生代剩余内存空间放不下新对象,此时需要触发GC。
触发Minor GC情况有:
判断老年代的可用内存是否已经小于了新生代的全部对象大小了,如果是,判断-XX:HandlePromotionFailure参数是否设置,如果有这个参数,那么就会继续尝试进行下一步判断:看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。如果判断失败,或者空间分配担保没有设置,就会直接触发一次FullGC,对老年代进行垃圾回收,尽量腾出来一些空间,然后再执行Minor GC。
1.Minor GC过后,剩余的存活对象,小于Survivor区域大小,存活对象进入Survivor区。
2.Minor GC过后,存活对象大于Survivor区域大小,小于老年代可用空间大小,直接进入老年代
3.Minor GC过后,存活对象大于Survivor区域大小,也大于老年代可用空间大小,此时,就会发生Handle Promotion
Old GC又称老年代垃圾回收,针对老年代进行垃圾的回收器主要有Serial Old及CMS。如果Minor GC后存活对象大于老年代里的剩余空间,这个时候触发一次Old GC, 将老年代里的没人引用的对象给回收掉,然后才可能让Minor GC过后剩余的存活对象进入老年代里面。
当对象躲过15次Minor GC后、符合动态对象判断规则、大对象及Minor GC后的对象太多无法放入Survivor区域等场景,都会触发对象进入老年代,下面将逐一分析每种场景。
无论15次GC之后进入老年代,还是动态年龄判断规则,都是希望可能长期存活的对象,尽早进入老年代。
这里需要考虑一个问题,就是老年代空间不够放这些对象。如果老年代的内存大小是大于新生代所有对象的,此时就可以对新生代触发一次Minor GC,因为即使所有对象都存活,Survivor区放不下了,也可以转移到老年代去。如果Minor GC前,发现老年代的可用内存已经小于新生代的全部大小了,这个时候如果Minor GC后新生代的对象全部存活下来,都转移到老年代去,老年代空间不够,理论上,是有这种可能的。所以假如Minor GC之前,发现老年代的可用内存已经小于了新生代的全部对象大小了,就会看一个-XX:HandlePromotionFailure的参数是否设置了。如果有这个参数,那么就会继续尝试进行下一步判断:看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。如果判断失败,或者空间分配担保没有设置,就会直接触发一次FullGC,对老年代进行垃圾回收,尽量腾出来一些空间,然后再执行Minor GC。
如果老年代回收后,仍然没有足够的空间存放Minor GC过后的剩余存活对象,那么此时就会导致OOM内存溢出
标记老年代当前存活对象,这些对象可能是零散分布在内存中,然后将这些存活对象在内存里移动,将存活对象尽量挪动到一边,将存活对象集中放置,避免回收后出现过多内存碎片。然后一次行把垃圾对象都回收掉。
先通过追踪GC Roots的方法,看看各个对象是否被GC Roots给引用了,如果是的话,那就是存活对象,否则就是垃圾对象。先将垃圾对象标记出来,然后一次性把垃圾对象都回收掉,这种方法其实最大的问题就是会造成很多内存碎片。
老年代存活对象太多了,如果采用复制算法,每次挪动可能90%的存活对象,这就不合适了。所以采用先把存活对象挪到一起紧凑一些,然后回收垃圾对象的方式。
1.Minor GC之前,老年代内存空间小于历次Minor GC后升入老年代对象的平均大小,判断Minor GC有风险,可能就会提前触发老年代GC回收老年代垃圾对象。
2.Minor GC后的对象太多了,都要升入老年代,发现空间不足,触发一次老年代的Old GC。
3.设置了-XX:CMSInitiatingOccuancyFaction参数,比如设置为92%,比如说老年代空间使用超过92%了,此时就会自行触发Old GC.
CMS在执行一次垃圾回收的过程一共分为4个阶段。
标记出来所有GC Roots直接引用的对象,会让系统的工作线程全部停止,进入“Stop the World”状态。
追踪老年代所有存活对象,老年代存活对象很多,这个过程就会很慢。
这个过程会标记整堆,包括年轻代和老年代。
找到零零散散分散再各个地方的垃圾对象,速度较慢。最后可能还要执行一次内存碎片整理,把大量的存活对象挪在一起,空出来连续空间,这个过程仍然要STW,那就更慢了。
CMS垃圾收集器特有的错误,CMS的垃圾清理和引用线程是并行进行的,如果在并行清理的过程中老年代的空间不足以容纳应用产生的垃圾,则会抛出“concurrent mode failure”。
老年代的垃圾收集器从CMS退化为Serial Old,所有应用线程被暂停,停顿时间变长。
原因1:CMS触发太晚
方案:将-XX:CMSInitiatingOccupancyFraction=N调小;
原因2:空间碎片太多
方案:开启空间碎片整理,并将空间碎片整理周期设置在合理范围;
-XX:+UseCMSCompactAtFullCollection (空间碎片整理) -XX:CMSFullGCsBeforeCompaction=n,执行多少次Full GC之后再执行一次内存碎片整理工作,默认是0,意思就是每次Full GC之后都会进行一次内存整理。
新生代执行速度快,因为直接从GC Roots出发就追踪哪些对象是活的即可,新生代存活对象是很少的,这个速度是很快的,不需要追踪多少对象,最后直接把存活对象放入Survivor中,就一次性直接回收Eden和之前使用的Survivor了。
在老年代回收并发标记阶段,他需要追踪所有存活对象,老年代存活对象很多,这个过程就很慢。
重新标记这个过程要标记整堆,并发清理阶段并不是一次性回收一大片内存,而是找到零零散散在各个地方的垃圾对象,速度也很慢。
最后还需要执行一次内存碎片整理,把大量的存活对象给挪在一起,空来联系内存空间,这个过程还得STW。
并发清理时,如果剩余内存空间不足以存放要进入老年代的对象,会引发”Concurrent Mode Failure“问题,这时会采用”Serial Old“垃圾回收器,STW之后会从新进行一次Old GC,这就更耗时了。
JDK8后出现了G1垃圾回收器,通过-XX:+UseG1GC来指定G1垃圾回收器,是当下比较先进的垃圾回收器。G1可以做到让你来设定垃圾回收对系统的影响,他自己通过把内存拆分为大量小Region,以及追踪每个Region中可以回收的对象大小和预估时间,最后在垃圾回收的时候,尽量把垃圾回收对系统造成的影响控制在你指定的时间范围内,同时在有限的时间内尽量回收尽可能躲的垃圾对象。
G1垃圾回收器特点
新生代也是有eden和survivor划分的,也是通过-XX:SurvivorRatio可以划分eden和survivor各自大小。触发垃圾回收的机制也是类似的,随着不停地在新生代eden对应的region中放对象,jvm会不停地给新生代加入更多的region,直到新生代占堆大小的最大比例60%,比如说新生代1200个region了,里面的eden可能占据了1000个region,每个survivor是100个region,而且eden区还占满了对象,这时会触发新生代gc,g1采用之前说过的复制算法进行垃圾回收,进入一个STW状态,并发eden对应的region中的存活对象放入S1的region中,接着回收掉eden对应的region中的垃圾对象。 g1是可以设定目标gc停顿时间的,也就是g1执行gc的时候最多可以让系统停顿多长时间,可以通过-XX:MaxGCPauseMills参数来设定,默认值是200ms。
对于G1内存模型来说,G1提供了专门的Region来存放大对象,而不是让大对象进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个region的50%,比如一个region是2MB,只要一个大对象超过了1MB,就会被放入大对象专门的region中,而且一个大对象如果太大,可能会横跨多个region来存放。在新生代、老年代在回收的时候,会顺带着大对象一起回收。
G1有一个参数,-XX:InitiatingHeapOccupancyPercent,默认值是45%,如果老年代占据了堆内存45%的Region的时候,此时就会尝试触发一个新生代+老年代一起回收的混合回收阶段。
如果在进行Mixed回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象copy到别的Region里去,万一出现copy的过程中发现没有空闲Region可以承载自己的存活对象了,就会触发一次失败。一旦失败,立马就会切换为停止系统程序,然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region,这个过程是极慢极慢的。
老年代在堆内存里占比超过45%触发mixed gc 优化的思路还是尽量避免对象过快进入老年代,尽量避免频繁触发mixed gc。优化的核心点是:避免老年代达到InitiatingHeapOccupancyPercent设置的值,即避免对象过快进入老年代。
合理分配堆内存,通过调整s区和e区大小来控制进入老年代对象速度,从而减少频繁old gc。
学会解读gc日志可以很好地分析堆使用情况,是进行调优及解决频繁full gc必备技能。下面我们以parnew+cms垃圾回收器为例,分析下gc日志。
public class JvmTest { public static void main(String[] args) { byte[] array1 = new byte[1024*1024]; array1 = new byte[1024*1024]; array1 = new byte[1024*1024]; array1 = null; byte[] array2 = new byte[2*1024*1024]; } } 复制代码0.268: [GC (Allocation Failure) 0.269: [ParNew: 4030K->512K(4608K), 0.0015734 secs] 4030K->574K(9728K), 0.0017518 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
par new generation total 4608K, used 2601K [0x00000000ff600000, 0x00000000ffb00000, 0x00000000ffb00000)
eden space 4096K, 51% used [0x00000000ff600000, 0x00000000ff80a558, 0x00000000ffa00000)
from space 512K, 100% used [0x00000000ffa80000, 0x00000000ffb00000, 0x00000000ffb00000)
to space 512K, 0% used [0x00000000ffa00000, 0x00000000ffa00000, 0x00000000ffa80000)
concurrent mark-sweep generation total 5120K, used 62K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)
Metaspace used 2782K, capacity 4486K, committed 4864K, reserved 1056768K class space used 300K, capacity 386K, committed 512K, reserved 1048576K
JVM退出时打印当前堆内存的使用情况,分析如下:
Metaspace是从JVM进程的虚拟地址空间中分离出来的,用以保存类元数据。JVM在启动时根据-XX:MetaspaceSize保留初始大小,该大小具有特定于平台的默认值。
Metaspace由一个或多个虚拟空间组成。虚拟空间是由操作系统获得的连续地址空间。他们是按需分配的。在分配时,虚拟空间预留(reserves)了操作系统的内存,但还没有提交。Metaspace reserved是所有虚拟空间的总大小。虚拟空间中的分配单元是Metachunk,当从虚拟空间分配新块时,相应的内存将committed, Metaspace committed是所有块的总大小。 从 docs.oracle.com/javase/8/do… 中可以对used,committed,reserved,capacity有了概述解释;
In the line beginning with Metaspace, the used value is the amount of space used for loaded classes. The capacity value is the space available for metadata in currently allocated chunks. The committed value is the amount of space available for chunks. The reserved value is the amount of space reserved (but not necessarily committed) for metadata. The line beginning with class space line contains the corresponding values for the metadata for compressed class pointers.
Full GC有以下表象,如机器CPU负载过高,系统无法处理请求或者处理过慢。引起Full GC的原因有很多,主要有JVM参数设置不合理和代码层面问题两大类。JVM参数设置不合理,如新生代堆内存大小设置不合理、Eden与Survivor比例设置不合理,抑或是metaspace设置过小等。代码层面问题,主要是程序员自己的问题,比如说对外提供查询接口没有做限制,一次查询太多对象;应用中存在频繁大量导出,且查询没有限制条件;代码中显示调用gc等。
public class FullGCTest { public static void main(String[] args) { byte[] array1 = new byte[4*1024*1024]; array1 = null; byte[] array2 = new byte[2*1024*1024]; byte[] array3 = new byte[2*1024*1024]; byte[] array4 = new byte[2*1024*1024]; byte[] array5 = new byte[128*1024]; byte[] array6 = new byte[2*1024*1024]; } } 复制代码
结合上述配置,我们可以发现,数组array1这个大对象会直接进入老年代;之后连续分配了4个数组,其中3个是2MB的数组,1个是128KB的数组,会全部进入eden区。当再分配array6时,会发现eden区空间不够,需要触发一次minor gc,但是由于array2,array3,array4,array5都被变量引用了,会直接进入老年代,因为老年代里已经存在4MB的数据了,难以存放这么大的数据,因此会触发一次Full GC。Full GC会对老年代进行Old GC,同时一般会跟一次Young GC关联,还会触发一次Metaspace的GC。下面我们分析下GC日志。
0.308: [GC (Allocation Failure) 0.308: [ParNew (promotion failed): 7260K->7970K(9216K), 0.0048975 secs]0.314: [CMS: 8194K- >6836K(10240K), 0.0049920 secs] 11356K->6836K(19456K), [Metaspace: 2776K->2776K(1056768K)], 0.0106074 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
par new generation total 9216K, used 2130K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 26% used [0x00000000fec00000, 0x00000000fee14930, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
concurrent mark-sweep generation total 10240K, used 6836K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
Metaspace used 2782K, capacity 4486K, committed 4864K, reserved 1056768K class space used 300K, capacity 386K, committed 512K, reserved 1048576K
下面分析full gc后堆内存的使用情况
尽量让每次Young GC后的存活对象⼩于Survivor区域的50%,都留存在年轻代⾥。尽量别让对象进 ⼊⽼年代。尽量减少Full GC的频率,避免频繁Full GC对JVM性能的影响。
系统经过单测、集测及测试环境后,进入预发环境进行压测,观察内存使用、Young GC的触发频率,Young GC的耗时,每次YoungGC后有多少对象是存活下来的,每次Young GC过后有多少对象进⼊了⽼年代,⽼年代对象增长的速率,Full GC的触发频率。
通过ps -ef | grep java获取java进程pid,利用jstat工具查看gc情况;
[tian~]$ jstat -gc 2236 S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 20480.0 20480.0 269.9 0.0 163840.0 97683.3 319488.0 271892.4 673268.0 661182.8 78048.0 75954.8 508 9.526 18 1.737 11.263 复制代码
S0C:这是From Survivor区的⼤⼩ S1C:这是To Survivor区的⼤⼩ S0U:这是From Survivor区当前使⽤的内存⼤⼩ S1U:这是To Survivor区当前使⽤的内存⼤⼩ EC:这是Eden区的⼤⼩ EU:这是Eden区当前使⽤的内存⼤⼩ OC:这是⽼年代的⼤⼩ OU:这是⽼年代当前使⽤的内存⼤⼩ MC:这是⽅法区(永久代、元数据区)的⼤⼩ MU:这是⽅法区(永久代、元数据区)的当前使⽤的内存⼤⼩ YGC:这是系统运⾏迄今为⽌的Young GC次数 YGCT:这是Young GC的耗时 FGC:这是系统运⾏迄今为⽌的Full GC次数 FGCT:这是Full GC的耗时 GCT:这是所有GC的总耗时 复制代码
可以利用jstat -gc PID 1000 10命令,每隔1s更新出来最新的一行jstat统计信息,一共执行10次统计,观察每隔一段时间jvm中eden区对象占用变化。如果系统访问量较低,可以适当延长观察时间长度,这样就可以大致推测出每次gc停顿时间长度。现在也有比较好的可视化监测工具如JVisualVM和Cat等。
-Xmx8g -Xms8g -Xmn2g -Xss256k Xms、Xmx表示堆的大小,Xmn表示年轻代大小,Xss表示线程栈擦小,默认1M -XX:SurvivorRatio=2 新生代中Eden与Survivor比值,调优的关键,也就是调节新生代堆大小及SurvivorRatio的值,尽量让新生代垃圾对象存放在Survivor中; -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m 元空间大小 -XX:+UseParNewGC 用并行收集器 ParNew 对新生代进行垃圾回收 -XX:+UseConcMarkSweepGC 并发标记清除收集器 CMS 对老年代进行垃圾回收。 -XX:ParallelGCThreads=2 Young GC工作时的并行线程数 -XX:ParallelCMSThreads=3 CMS GC 工作时的并行线程数 -XX:+CMSParallelRemarkEnabled 并行运行最终标记阶段,加快最终标记的速度 -XX:+CMSParallelInitialMarkEnabled 初始阶段开启多线程并发执行,减少STW时间 -XX:+CMSScavengeBeforeRemark 在CMS重新标记阶段之前,执行一次Young GC,因为重新标记是整堆标记的,执行一次Young GC,回收调年轻代里没人引用的对象,减少扫描对象。 -XX:MaxTenuringThreshold=15 对象从新生代晋升到老年代的年龄阈值(每次 Young GC 留下来的对象年龄加一),默认值15 -XX:+UseCMSCompactAtFullCollection 开启碎片整理 -XX:CMSFullGCsBeforeCompaction=2 与-XX:+UseCMSCompactAtFullCollection配合使用,表示进行2次Full GC后进行整理 -XX:+UseCMSInitiatingOccupancyOnly 只根据老年代使用比例来决定是否进行CMS -XX:CMSInitiatingOccupancyFraction=80 设置触发CMS老年代回收的内存使用率占比,达到80%时触发old gc -XX:+CMSClassUnloadingEnabled 默认开启,表示开启 CMS 对元空间的垃圾回收,避免由于元空间耗尽带来 Full GC -XX:-DisableExplicitGC 禁止代码中显示调用GC -XX:+HeapDumpOnOutOfMemoryError OOM时dump内存快照 -verbose:gc 表示输出虚拟机中GC的详细情况 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:/app/log/xxx.log gc文件未知 复制代码
发生OOM的区域主要有三块,一个Metaspace区域,一个是虚拟机栈内存,一个是堆内存空间。
Full GC时,必然会尝试回收Metaspace区域中的类,当然回收条件是比较苛刻的,如这个类的类加载器先要被回收,类的所有对象实例都要被回收等,一旦Metaspace区域满类,未必能回收掉里面很多的类,JVM没有回收太多空间,随着程序运行,还要继续往Metaspace区域中塞入更多的类,直接就会引发内存溢出问题。 引起Metaspace内存溢出的原因
每个线程的虚拟机栈的大小是固定的,线程调用一个方法,都会将本次方法调用的栈桢压入虚拟机栈里,这个栈枕里是有方法的局部变量的。导致栈内存溢出的主要原因是出现类递归调用。
堆内存溢出主要是eden区不断有存活对象进入老年代,触发full gc后发现老年代回收对象较少,老年代仍然有大量存活对象,年轻代仍然有一批对象等着放进老年代,但是放不下,这时候抛出内存溢出异常。 一般来说,引起内存溢出主要有两种场景: