JVM系列之第一篇:调优实战总结

2020/2/19 17:09:42

本文主要是介绍JVM系列之第一篇:调优实战总结,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言

   本篇文章主要在狸猫技术窝中有关JVM中调优的一些实战基础上进行总结,可以算是自己的一篇学习总结。主要以目前主流的两种垃圾回收组合方式,ParNew +CMS及G1垃圾回收器为基础,梳理下调优思路、GC日志如何阅读及引发OOM的区域和原因。

ParNew +CMS组合

   ParNew一般用在新生代的垃圾回收器,CMS用在老年代的垃圾回收器,他们都是多线程并发机制,性能更好,现在一般是线上生产系统的标准组合。

Minor GC

   Minor GC又称年轻代垃圾回收,年轻代垃圾回收主要采用复制算法,由于年轻代对象大都“朝生夕死”,为降低内存使用率瓶颈,设置了Eden区和2个Survior区,1个Eden区占80%内存空间,每一块Survivor区各占10%内存空间。当前Minor GC主要采用ParNew垃圾回收器。

什么时候尝试触发Minor GC?

   新生代剩余内存空间放不下新对象,此时需要触发GC。

触发Minor GC情况有:

  • 新生代现有存活对象小于老年代剩余内存,即老年代空间足以支撑可能晋升的对象
  • 情况1不成立时,设置空间担保并且可以担保成功(当前JDK版本下都有默认开启了空间担保),即老年代空间大于历次Minor GC后进入老年代的平均大小。

Minor GC之前做了什么?

    判断老年代的可用内存是否已经小于了新生代的全部对象大小了,如果是,判断-XX:HandlePromotionFailure参数是否设置,如果有这个参数,那么就会继续尝试进行下一步判断:看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。如果判断失败,或者空间分配担保没有设置,就会直接触发一次FullGC,对老年代进行垃圾回收,尽量腾出来一些空间,然后再执行Minor GC。

Minor GC结果

1.Minor GC过后,剩余的存活对象,小于Survivor区域大小,存活对象进入Survivor区。

2.Minor GC过后,存活对象大于Survivor区域大小,小于老年代可用空间大小,直接进入老年代

3.Minor GC过后,存活对象大于Survivor区域大小,也大于老年代可用空间大小,此时,就会发生Handle Promotion

Old GC

   Old GC又称老年代垃圾回收,针对老年代进行垃圾的回收器主要有Serial Old及CMS。如果Minor GC后存活对象大于老年代里的剩余空间,这个时候触发一次Old GC, 将老年代里的没人引用的对象给回收掉,然后才可能让Minor GC过后剩余的存活对象进入老年代里面。

对象如何进入老年代?

   当对象躲过15次Minor GC后、符合动态对象判断规则、大对象及Minor GC后的对象太多无法放入Survivor区域等场景,都会触发对象进入老年代,下面将逐一分析每种场景。

1.躲过15次GC之后进入老年代
  • 对象每次在新生代躲过一次GC被转移到一块Survivor区域只能够,此时他的年龄就会增长一岁
  • 默认的设置下,当对象的年龄达到15岁的时候,也就是躲过15次GC的时候,他就会转移到老年代里区。具体多少岁进入老年代,可以通过JVM参数-XX:MaxTenuringThreshold来设置,默认是15岁。
2.动态对象年龄判断
  • 如果一次新生代gc过后,发现当前放对象的Survior区域里,几个年龄的对象的总大小大于了这块Survior区域的内存大小的50%,比如说age1 + age2 + age3的对象大小总和,超过了Survivor区域的50%,那么就把age3年龄以上的对象都放入老年代。
  • 动态年龄判断规则,也会让一些新生代的对象进入老年代。

   无论15次GC之后进入老年代,还是动态年龄判断规则,都是希望可能长期存活的对象,尽早进入老年代。

3.大对象直接进入老年代
  • 通过参数-XX:PretenureSizeThreshold可以设置对象直接进入老年代的阀值,可以把他的值设置为字节数,比如1048576字节,就是1MB。
  • 如果创建一个大于这个大小的对象,比如一个超大的数组或者别的大对象,此时就直接把这个大对象放到老年代里,压根不会经过新生代。
4.Minor GC后的对象太多无法放入Survivor区域

   这里需要考虑一个问题,就是老年代空间不够放这些对象。如果老年代的内存大小是大于新生代所有对象的,此时就可以对新生代触发一次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回收过程

   CMS在执行一次垃圾回收的过程一共分为4个阶段。

1.初始标记

   标记出来所有GC Roots直接引用的对象,会让系统的工作线程全部停止,进入“Stop the World”状态。

2.并发标记

   追踪老年代所有存活对象,老年代存活对象很多,这个过程就会很慢。

3.重新标记

   这个过程会标记整堆,包括年轻代和老年代。

4.并发清理

   找到零零散散分散再各个地方的垃圾对象,速度较慢。最后可能还要执行一次内存碎片整理,把大量的存活对象挪在一起,空出来连续空间,这个过程仍然要STW,那就更慢了。

concurrent mode failure是什么?

   CMS垃圾收集器特有的错误,CMS的垃圾清理和引用线程是并行进行的,如果在并行清理的过程中老年代的空间不足以容纳应用产生的垃圾,则会抛出“concurrent mode failure”。

concurrent mode failure影响

  老年代的垃圾收集器从CMS退化为Serial Old,所有应用线程被暂停,停顿时间变长。

可能原因及方案
  • 原因1:CMS触发太晚

    方案:将-XX:CMSInitiatingOccupancyFraction=N调小;

  • 原因2:空间碎片太多

    方案:开启空间碎片整理,并将空间碎片整理周期设置在合理范围;

-XX:+UseCMSCompactAtFullCollection (空间碎片整理) -XX:CMSFullGCsBeforeCompaction=n,执行多少次Full GC之后再执行一次内存碎片整理工作,默认是0,意思就是每次Full GC之后都会进行一次内存整理。

  • 原因3:垃圾产生速度超过清理速度 晋升阈值过小; Survivor空间过小,导致溢出; Eden区过小,导致晋升速率提高; 存在大对象;

为什么说老年代的Full GC要比新生代的Minor GC慢?

  • 新生代执行速度快,因为直接从GC Roots出发就追踪哪些对象是活的即可,新生代存活对象是很少的,这个速度是很快的,不需要追踪多少对象,最后直接把存活对象放入Survivor中,就一次性直接回收Eden和之前使用的Survivor了。

  • 在老年代回收并发标记阶段,他需要追踪所有存活对象,老年代存活对象很多,这个过程就很慢。

  • 重新标记这个过程要标记整堆,并发清理阶段并不是一次性回收一大片内存,而是找到零零散散在各个地方的垃圾对象,速度也很慢。

  • 最后还需要执行一次内存碎片整理,把大量的存活对象给挪在一起,空来联系内存空间,这个过程还得STW。

  • 并发清理时,如果剩余内存空间不足以存放要进入老年代的对象,会引发”Concurrent Mode Failure“问题,这时会采用”Serial Old“垃圾回收器,STW之后会从新进行一次Old GC,这就更耗时了。

G1垃圾回收器

   JDK8后出现了G1垃圾回收器,通过-XX:+UseG1GC来指定G1垃圾回收器,是当下比较先进的垃圾回收器。G1可以做到让你来设定垃圾回收对系统的影响,他自己通过把内存拆分为大量小Region,以及追踪每个Region中可以回收的对象大小和预估时间,最后在垃圾回收的时候,尽量把垃圾回收对系统造成的影响控制在你指定的时间范围内,同时在有限的时间内尽量回收尽可能躲的垃圾对象。

G1垃圾回收器特点

  • 把java堆内存分为多个大小相等的Region
  • 逻辑上,也会有新生代和老年代的概念
  • 可以设置一个垃圾回收的预期停顿时间

region设置问题

  • -XX:+UseG1GC来指定G1垃圾回收器,此时会自动用堆大小除以2048,jvm最多可以有2048个Region,然后Region的大小必须是2的倍数,比如说1MB、2MB、4MB之类的。可以通过-XX:G1HeapRegionSize指定。
  • 默认新生代对堆内存的占比是5%,也可以通过-XX:G1NewSizePercent来设置新生代初始占比的,其实维持这个默认值即可。系统运行中,JVM其实会不停地给新生代增加更多的Region,但是新生代占比最多不超过60%,可以通过-XX:G1MaxNewSizePercent来设置。

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对象什么时候从新生代进入老年代呢?

  • 对象在新生代躲过来很多次的垃圾回收,达到来一定的年龄了,-XX:MaxTenuringThreshold参数可以设置这个年龄,他就会进入老年代
  • 动态年龄判定规则,如果一旦发现某次新生代GC过后,存活对象超过了Survivor的50%,此时判断,比如年龄为1岁,2岁,3岁,4岁的对象的大小总和超过了Survivor的50%,此时4岁以上的对象全部进入老年代,这就是动态年龄判定规则

大对象Rregion

   对于G1内存模型来说,G1提供了专门的Region来存放大对象,而不是让大对象进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个region的50%,比如一个region是2MB,只要一个大对象超过了1MB,就会被放入大对象专门的region中,而且一个大对象如果太大,可能会横跨多个region来存放。在新生代、老年代在回收的时候,会顺带着大对象一起回收。

发新生代+老年代混合垃圾回收

   G1有一个参数,-XX:InitiatingHeapOccupancyPercent,默认值是45%,如果老年代占据了堆内存45%的Region的时候,此时就会尝试触发一个新生代+老年代一起回收的混合回收阶段。

G1垃圾回收过程

  • 初始标记 仅仅标记一下GC Roots直接能引用的对象,这个过程速度是很快的,需要进入STW状态。
  • 并发标记 这个阶段会允许系统程序的运行,同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象,并发阶段还是很耗时的,因为要追踪全部的存活对象。但是这个阶段是可以和系统程序并发运行,所以对系统程序的影响不太大。而且JVM会对并发标记阶段对对象做出的一些修改记录起来,比如哪个对象被新建了,哪个对象失去了引用。
  • 最终标记 这个阶段会进入STW,系统程序是禁止运行的,但是会根据并发标记阶段记录的那些对象修改,最终标记一下有哪些存活对象,有哪些是垃圾对西那个。
  • 混合回收 这个阶段会计算老年代中每个Region中的存活对象数量,存活对象的占比,还有执行垃圾回收的预期性能和效率。接着会停止系统程序,然后全力以赴尽快进行垃圾回收,此时会选择部分Region进行回收,因为必须让垃圾回收的停顿时间控制在我们指定的范围内。混合回收,会从新生代、老年代、大对象里各自挑选一些Region,保证指定的时间(比如200ms)回收尽可能多的垃圾。

G1垃圾回收器的一些参数

  • -XX:G1MixedGCCountTarget,就是在一次混合回收的过程中,最后一个阶段执行几次混合回收,默认值是8次。意味着最后一个阶段,先停止系统运行,混合回收一些Region,再恢复系统运行,接着再次禁止系统运行,混合回收一些Region,反复8次。反复回收多次的意义在于,尽可能让系统不要停顿时间过长,可以在多次回收的间隙,也运行一下。
  • -XX:G1HeapWastePercent,默认值是5%,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉。这样的话在回收过程不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了,也就是说进行4次混合回收后,发现空闲Region达到了5%,就不会进行后续的混合回收。从这里也能看出G1整体是基于复制算法进行Region垃圾回收的,不会出现内存碎片的问题,不需要像CMS那样标记-清理之后,再进行内存碎片的整理。
  • -XX:G1MixedGCLiveThresholdPercent,默认值85%,当一个Region的存活对象多余85%,这个时候就不会回收。因为copy到别的Region的成本也是很高的。

回收失败时的Full GC

如果在进行Mixed回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象copy到别的Region里去,万一出现copy的过程中发现没有空闲Region可以承载自己的存活对象了,就会触发一次失败。一旦失败,立马就会切换为停止系统程序,然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region,这个过程是极慢极慢的。

如何对JVM参数进行优化?

G1垃圾回收器

G1新生代优化

  • 给整个JVM堆区域足够的内存
  • 合理设置-XX:MaxGCPauseMills参数,参数设置小,每次gc停顿时间可能特别短,gc频率提高。参数设置过大,可能G1会运去不停地在新生代分配新的对象,然后积累了很多对象,再一次性回收几百个Region。

mixed gc优化

   老年代在堆内存里占比超过45%触发mixed gc 优化的思路还是尽量避免对象过快进入老年代,尽量避免频繁触发mixed gc。优化的核心点是:避免老年代达到InitiatingHeapOccupancyPercent设置的值,即避免对象过快进入老年代。

  • 1.让垃圾对象尽量在新生代就被回收掉,尽量让短命对象不进老年代。也就是合理设置—XX:SurvivorRatio值。
  • 2.提高触发mixed gc时InitiatingHeapOccupancyPercent的值,这样mixed gc概率降低,但这样做会加大gc回收时计算负担。
  • 3.合理调节-XX:MaxGCPauseMills参数的值,保证他的新生代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多,快速进入老年代,频发触发mixed gc。

parnew+cms垃圾回收器

   合理分配堆内存,通过调整s区和e区大小来控制进入老年代对象速度,从而减少频繁old gc。

两种垃圾回收器对比

  1. parnew+cms和g1回收的最大区别是是否会进行整堆回收。
  2. g1可以设置预估停顿时间,适用于低延迟应用
  3. g1从整体上看采用复制算法,适合会产生大量碎片的应用。
  4. parnew+cms回收器比较适合内存小,对象在新生代存活周期短的应用;g1适合内存较大的计算应用,因为整堆回收会比较耗时。

gc日志解读

   学会解读gc日志可以很好地分析堆使用情况,是进行调优及解决频繁full gc必备技能。下面我们以parnew+cms垃圾回收器为例,分析下gc日志。

新生代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

  • 0.268表示系统运行268毫秒后触发了本次gc
  • GC (Allocation Failure) 表示对象分配失败触发GC
  • [ParNew: 4030K->512K(4608K), 0.0015734 secs] ParNew标示年轻代GC,4608k标示年轻代可用空间4.5MB, 即eden 区 + 1个survivor区大小。4030K->512K表示GC之前使用了4030K,GC之后只有512K的对象。
  • 4030K->574K(9728K);4030K表示gc前整堆的使用了4030K,gc后使用了574K,整堆大小是9728K。
  • 0.0015734 secs 表示这次gc耗时1.5ms。 注意:在GC之前,Eden区里放里3个1MB的数组,一共3MB,也就是3072KB的对象,但为里存储这个数组,JVM 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]

JVM退出时打印当前堆内存的使用情况,分析如下:

  • par new generation total 4608K, used 2601K;说明ParNew垃圾回收器负责的年轻代共有4608KB可用内存,目前使用 了2601KB。
  • from space,100% used;表明之前gc后存活下来的512KB的未知对象将from space占满
  • to space,0% used; from space与to space两个区域不能同时被使用,其中一个存放前一次gc存活对象后,另一个就是闲置的。
  • concurrent mark-sweep generation total 5120K, used 62K;表明使用Concurrent Mark-Sweep垃圾回收器,即CMS垃圾回收器,老年代内存空间一共是5MB,此时使用了62KB的空间。

解析Metaspace的使用情况

   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.

  • used表示加载的类的空间量,capacity表示当前分配块(非空闲块)的空间,其小于commited的空间量;committed表示当从虚拟空间分配新块时,相应的内存将会被提交,即已经申请提交的可用分配块,其大小要大于used的;reserved指元空间的总大小,空间被分成块,每个块只能包含与某一个类加载器关联的类元数据。关于这几个参数的定义解释可以参考一篇文章: www.jianshu.com/p/cd34d6f3b…
  • class space是指实际上被用于放class的那块内存的和,关于这块,今后会进行详细分析。

Full GC日志分析

   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

  • ParNew (promotion failed): 7260K->7970K(9216K), 0.0048975 secs;Eden区回收前有7000多KB的对象,回收之后发现一个都回收不掉,主要由于上述几个数组被变量引用了;出现promotion 失败的原因主要是Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成的。
  • CMS: 8194K- >6836K(10240K), 0.0049920 secs;表明old gc前老年代对象大小是8194KB,old gc后存活对象大小是6836K,老年代总空间大小是10240K。看到这里可能会有一个疑惑,为什么old gc前会有8194K的对象呢?这主要是young gc后,往老年代放入了2个2MB的对象。后续继续存放1个2MB和1个128KB的数组到老年代中,放不下,触发Full GC。
  • 11356K->6836K(19456K);表明old gc前整堆使用了11356K,old gc后整堆使用了6836K。

下面分析full gc后堆内存的使用情况

  • par new generation total 9216K, used 2130K;表明Full GC后,剩余的2MB存放在enden区了。
  • from space 1024K, 0% used;young gc时没有存活对象对象;
  • to space 1024K, 0% used;to space本身没有参与到这次gc中,不存在使用情景;
  • concurrent mark-sweep generation total 10240K, used 6836K;表明使用CMS垃圾回收器,新生代中的6836K全部对象进入了老年代;

   尽量让每次Young GC后的存活对象⼩于Survivor区域的50%,都留存在年轻代⾥。尽量别让对象进 ⼊⽼年代。尽量减少Full GC的频率,避免频繁Full GC对JVM性能的影响。

上线时如何确定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等。

常用GC参数

-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分析

   发生OOM的区域主要有三块,一个Metaspace区域,一个是虚拟机栈内存,一个是堆内存空间。

Metaspace内存溢出

   Full GC时,必然会尝试回收Metaspace区域中的类,当然回收条件是比较苛刻的,如这个类的类加载器先要被回收,类的所有对象实例都要被回收等,一旦Metaspace区域满类,未必能回收掉里面很多的类,JVM没有回收太多空间,随着程序运行,还要继续往Metaspace区域中塞入更多的类,直接就会引发内存溢出问题。 引起Metaspace内存溢出的原因

  • Metaspace设置多小;
  • 大量使用cglib之类的技术动态生成一些类,导致生成的类过多,将Metaspace塞满,引起内存溢出;

栈内存溢出

   每个线程的虚拟机栈的大小是固定的,线程调用一个方法,都会将本次方法调用的栈桢压入虚拟机栈里,这个栈枕里是有方法的局部变量的。导致栈内存溢出的主要原因是出现类递归调用。

堆内存溢出

   堆内存溢出主要是eden区不断有存活对象进入老年代,触发full gc后发现老年代回收对象较少,老年代仍然有大量存活对象,年轻代仍然有一批对象等着放进老年代,但是放不下,这时候抛出内存溢出异常。 一般来说,引起内存溢出主要有两种场景:

  • 系统承载⾼并发请求,因为请求量过⼤,导致⼤量对象都是存活的,所以要继续放⼊新的对象实在是不⾏了,此时就会引发OOM系统崩溃。
  • 系统有内存泄漏的问题,就是莫名其妙弄了很多的对象,结果对象都是存活的,没有及时取消对他们的引⽤,导致触发GC还是⽆法回收,此时只能引发内存溢出,因为内存实在放不下更多对象了。 因此总结起来,⼀般引发OOM,要不然是系统负载过⾼,要不然就是有内存泄漏的问题。


这篇关于JVM系列之第一篇:调优实战总结的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程