【JVM】调优案例
调优问题的笔记整理顺序
(1)上线前JVM评估的时候如何设置合适的参数,多少是合适的,具体比例是多少?使用什么命令来设置?
(2)怎么判断JVM出问题了?具体要看哪些指标的异常?
OOM、CPU load高、GC频繁
(3)怎么使用Arthas工具查看那些有问题的数据?
(4)怎么查看Dump文件,快照,GC日志等等?
(5)代码中常会出现的问题有哪些?怎么排查出来?
1、排查CPU过高问题(死循环)
用户线程CPU过高——一般是出现了死循环——查看线程堆栈、结合arthas的watch命令等找出问题根源——gc线程过高一般是Full GC频繁——Full GC频繁一般是因为老年代或方法区(元空间)内存不足——对gc日志分析、对堆转储文件分析,确定是哪些对象实例占用了过多内存、反射的类是否过多、被多个类加载器加载的类是否过多等——决定是增加内存大小还是对源码进行处理
2、排查内存泄漏问题
3、排查GC频繁的问题
可能是死循环,可能是内存泄漏,可能是大对象
(6)需要调整JVM内存大小的时候如何进行设置?
(一)案例一:新生代小了,大对象直接进入了老年代
【1】JVM内存相关参数
(1)JVM内存参数的权衡
JVM最重要最核心的参数就是去评估内存和分配。
第一步需要指定堆内存整体的大小,这个是系统上线必须要做的。-Xms初始堆大小,-Xmx最大堆大小,后台Java服务中一般都指定为系统内存的一半,过大会占用服务器的系统资源,过小则无法发挥JVM的最佳性能。
第二步需要指定-Xmn新生代的大小,这个参数非常关键,灵活度很大,虽然sun官方推荐为3/8大小,但是要根据业务场景来定,针对于无状态或者轻状态服务(现在最常见的业务系统如Web应用)来说,一般新生代甚至可以给到堆内存的3/4大小;而对于有状态服务(常见如IM服务、网关接入层等系统)新生代可以按照默认比例1/3来设置。服务有状态,则意味着会有更多的本地缓存和会话状态信息常驻内存,应为要给老年代设置更大的空间来存放这些对象。
最后是设置-Xss栈内存大小,设置单个线程栈大小,默认值和JDK版本、系统有关,一般默认512~1024kb。一个后台服务如果常驻线程有几百个,那么栈内存这边也会占用了几百M的大小。
(2)如何设置JVM参数
如果在IDEA中调试JVM参数,只需要打开项目的Configuration中,对VM Options进行设置即可:
如果在Linux环境中调试JVM参数,需要在启动Java进程的时候,加入到启动命令中:
[root@LOCAL~]#java -Xmx512m -Xms512m -Xmn256m -Xss1m –jar hello.jar
服务启动后,在日志的第一行就会打印jvm参数相关信息,可以验证启动后的jvm参数是否设置成功!
【2】新系统上线如何规划容量
(1)套路总结
任何新的业务系统在上线以前都需要去估算服务器配置和JVM的内存参数,这个容量与资源规划并不仅仅是系统架构师的随意估算的,需要根据系统所在业务场景去估算,推断出来一个系统运行模型,评估JVM性能和GC频率等等指标。
建模步骤:
(1)对象创建速度
计算业务系统每秒钟创建的对象会占用多大的内存空间,然后计算集群下的每个系统每秒的内存占用空间。
(2)设置一个机器配置,估算新生代的空间
比较不同新生代大小之下,多久触发一次MinorGC。
(3)重估
为了避免频繁GC,就可以重新估算需要多少机器配置,部署多少台机器,给JVM多大内存空间,新生代多大空间。
(4)推断JVM运行模型
根据这套配置,基本可以推算出整个系统的运行模型,每秒创建多少对象,1s以后成为垃圾,系统运行多久新生代会触发一次GC,频率多高。
(2)套路实战——以登录系统为例
推演过程:
(1)假设每天100w次登陆请求,登陆峰值在早上,预估峰值时期每秒100次登陆请求。
(2)假设部署3台服务器,每台机器每秒处理30次登陆请求,假设一个登陆请求需要处理1秒钟,JVM新生代里每秒就要生成30个登陆对象,1s之后请求完毕这些对象成为了垃圾。
(3)一个登陆请求对象假设20个字段,一个对象估算500字节,30个登陆占用大约15kb,考虑到RPC和DB操作,网络通信、写库、写缓存一顿操作下来,可以扩大到20-50倍,大约1s产生几百k-1M数据。
(4)假设2C4G机器部署,分配2G堆内存,新生代则只有几百M,按照1s1M的垃圾产生速度,几百秒就会触发一次MinorGC了。
(5)假设4C8G机器部署,分配4G堆内存,新生代分配2G,如此需要几个小时才会触发一次MinorGC。
到这里,可以粗略的推断出来一个每天100w次请求的登录系统,按照4C8G的3实例集群配置,分配4G堆内存、2G新生代的JVM,可以保障系统的一个正常负载。基本上把一个新系统的资源评估了出来,所以搭建新系统要每个实例需要多少容量多少配置,集群配置多少个实例等等这些,并不是拍拍脑袋和胸脯就行。
【3】垃圾回收器的选择
(1)吞吐量还是响应时间
吞吐量 = CPU在用户应用程序运行的时间 / (CPU在用户应用程序运行的时间 + CPU垃圾回收的时间)
延迟时间 = 平均每次的GC的耗时
通常,吞吐优先还是响应优先这个在JVM中是一个两难之选。堆内存增大,GC一次能处理的数量变大,吞吐量大;但是GC一次的时间会变长,导致后面排队的线程等待时间变长;相反,如果堆内存小,GC一次时间短,排队等待的线程等待时间变短,延迟减少,但一次请求的数量变小(并不绝对符合),无法同时兼顾。吞吐优先VS响应优先,是JVM调优过程中需要权衡的核心问题。
(2)垃圾回收器设计上的考量
垃圾回收器的底层实现机制非常复杂,但是设计者的设计目标无外乎以下几条:
(1)JVM在GC时不允许一边垃圾回收,一边还创建新对象(就像不能一边打扫卫生,还在一边扔垃圾)。
(2)基于第一条GC时需要一段Stop the world的暂停时间,而STW会造成系统短暂停顿不能处理任何请求;
(3)新生代收集频率高,性能优先,常用复制算法;老年代频次低,空间敏感,避免复制方式。
(4)所有垃圾回收器的设计目标都是要让GC频率更少,时间更短,减少GC对系统影响!
(3)CMS和G1
目前主流的垃圾回收器配置是新生代采用ParNew,老年代采用CMS组合的方式,或者是完全采用G1回收器,从未来的趋势来看,G1是官方维护和更为推崇的垃圾回收器。
业务系统,延迟敏感的推荐CMS;大内存服务,要求高吞吐的,采用G1回收器!下面单独就两款回收器的工作机制和适用场景进行一下说明:
【4】CMS回收器
(1)CMS垃圾回收器的工作机制
CMS主要是针对老年代的回收器,新生代的采用ParNew回收器,工作流程就是上文提到的经典复制算法,在三块区中进行流转回收,只不过采用多线程并行的方式加快了MinorGC速度。老年代是标记-清除,默认会在一次FullGC算法后做整理算法,清理内存碎片。
(1)优点
并发收集、主打“低延时” 。在最耗时的两个阶段都没有发生STW,而需要STW的阶段都以很快速度完成。
(2)缺点
1、消耗CPU;2、浮动垃圾;3、内存碎片
(3)适用场景
重视服务器响应速度,要求系统停顿时间最短。
(2)登录系统的压测前配置
调优场景以之前的登录系统为例,按照之前容量估算套路,引入性能压测环节,测试同学对登录接口压至1s内60M的对象生成速度,假设只配置了4C8G的机器配置,采用ParNew+CMS的组合回收器,堆内存分配4g,线程栈默认1M,初始配置如下:
-Xms4g –Xmx4g –Xmn1536m -Xss1m -XX:+UseConcMarkSweepGC
划分Eden和Surviror大小,如按照默认-XX:SurvivorRatio=8 分配规则,基于CMS的JVM运行模型粗略计算如下:
基本上,可以看到20S后Eden区就满了,此时再运行的时候对象已经无法分配,会触发MinorGC,假设在这次GC后S1装入100M,马上过20S又会触发一次MinorGC,多出来的100M存活对象再加上S1区已经存在的100M,已无法顺利放入到S2区,此时就会触发JVM的动态年龄机制,将一批100M左右的对象推到老年代保存,持续运行一段时间,当老年代也满了的情况下,系统可能不到一小时候就会触发一次FullGC。
(3)基于CMS的调优思路
首先采取上调Survior区容量策略:新生代划2g,维持E:S1:S2=8:1:1,此时Eden=1.6G,S=200M。60M/S速率,运行25s左右会触发一次MinorGC,回收的对象需要超过200M才触发进入老年代,对象进入老年代的几率大大降低,短命对象在几次minorGC后就释放掉了。此时的JVM配置如下:
-Xms4g –Xmx4g –Xmn2g -Xss1m -XX:SurvivorRatio=8 -XX:+UseConcMarkSweepGC
然后再下调晋升老年代年龄,默认为15——当躲过15次MinorGC后,可进入老年代;可适当调低改值为5~10,让长寿对象应尽快去往属于它的地方,而不是在新生代来回折腾,占用空间,这样可以优化每次MinorGC的耗时。
-Xms4g –Xmx4g –Xmn2g -Xss1m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+UseConcMarkSweepGC
再选择性的去优化老年代参数:比如老年代默认在标记清除以后会做整理,还可以在CMS的增加GC频次还是增加GC时长上做些取舍,如下是响应优先的参数调优:
那么最终我们可以得到一个比较适用于自身业务系统的、基于CMS回收器的JVM参数:
-Xms4g –Xmx4g –Xmn2g -Xss1m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:+AlwaysPreTouch
(4)总结
避免出现Minor GC后的存活对象因为存不进Survivor区而直接进入老年代,这样会导致老年代中的对象越积越多,最终导致Full GC。
可以适当提高新生代包括Survivor区的内存大小,保证每次Minor GC后存活的对象能顺利进入Survivor区轮回,这样大部分对象会在Survivor区轮回的时候被回收掉。
【5】G1垃圾回收器
(1)CMS回收器的不足
(1)服务启动前就需要指定新生代和老年代大小,启动了就不能动态调整了!
(2)新生代和老年代都必须分配独立且连续的一整块内存空间!
(3)所有针对老年代的操作必须扫描整个老年代空间,相同的老年代对象,堆空间越大扫描耗时越长!
(2)G1回收器的设计思路
G1回收天然的适用于大内存服务器,首先G1将堆内存空间拆分为多个大小相等的Region块,Region的个数默认2048个,配置4g堆内存,每个region的大小就为2M。Region动态的属于老年代或者新生代,上一秒还是分配成新生代,经过回收以后空出来,下一秒有可能被分为老年代区。
在G1回收器这里已经不需要再提前设置新生代和老年代的大小,但是新生代仍区分Eden和Survivor区。大大降低了JVM参数的调优复杂度,只需配置-XX:MaxGCPauseMillis=n(ms),设置最大GC停顿时间,剩下的交给G1回收器。G1会自动追踪每个region可以回收的大小和预估的时间,最后在真正垃圾回收的时候,尽量把垃圾回收控制在设置的时间范围内,在有限的时间内回收更多的对象。
所以综合来看,G1主打高吞吐,特别适用多核、大内存服务(如Kafka/ElasticSearch)。
(3)G1的工作机制
(1)新生代回收:对象优先分配Eden的Region,JVM不停给新生代分配更多的region,直到新生代占堆总大小的60%,触发MinorGC。
(2)进入老年代对象的条件不变:达到晋升年龄;动态年龄判定;大对象等
(3)Mix混合回收:当老年代的Region占堆内存的45%以后,触发MixGC,会分阶段多次混合回收新生代和老年代的Region。
(4)Full GC:MixGC时发现无可用的新Region块了来分配复制的存活对象,立马触发FullGC,停止系统程序,单线程标记、清除和整理,空闲出一批Region,过程很缓慢。
(4)G1的核心调优参数
G1收集器自身已经有一套预测和调整机制了,因此我们首先的选择是相信它,即调整-XX:MaxGCPauseMillis=N参数,这也符合G1的目的——让GC调优尽量简单!同时也不要自己显式设置新生代的大小(用-Xmn或-XX:NewRatio参数),如果人为干预新生代的大小,会导致目标时间这个参数失效。
针对-XX:MaxGCPauseMillis来说,参数的设置带有明显的倾向性:调低↓:延迟更低,但MinorGC频繁,MixGC回收老年代区减少,增大Full GC的风险。调高↑:单次回收更多的对象,但系统整体响应时间也会被拉长。
针对InitiatingHeapOccupancyPercent来说,调参大小的效果也不一样:调低↓:更早触发MixGC,浪费cpu。调高↑:堆积过多代回收region,增大FullGC的风险。
(5)G1调优在Kafka集群的应用
比如日志平台的Kafka集群每秒写入300M数据至内存,broker节点的配置为16C32G,假设堆内存给16g,新生代分配8g,每秒产生对象假设100M左右,差不多一分多钟就会产生一次MinorGC,CMS机制下需要等Eden满了以后,才一次性清理大约8g左右的垃圾对象,差不多会有秒级的STW停顿,如果是老年代的GC延时长则会有十秒级的STW停顿。
-Xms16g –Xmx16g –Xmn8g -Xss1m -XX:+UseConcMarkSweepGC
假设采用了G1回收器,适当调低最大耗时,设定MaxGCPauseMillis为100ms,并且适当调低堆使用率阈值,G1就会在允许的响应时间内自动的、多批次的去进行垃圾回收,保证每个STW的时间都不会太长。
-Xms16g -Xmx16g -Xss1m -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:InitiatingHeapOccupancyPercent=40
所以线上的kafka和ES集群,动辄32~64g的大内存,如果让CMS去整块回收十多G乃至几十G的垃圾对象,对于系统而言绝对不利!一般来说,堆内存超过8g的大内存服务器,都更推荐使用G1回收器!
【6】调优总结
(1)系统在上线前的综合调优思路
(1)业务预估:根据预期的并发量、平均每个任务的内存需求大小,然后评估需要几台机器来承载,每台机器需要什么样的配置。
(2)容量预估:根据系统的任务处理速度,然后合理分配Eden、Surivior区大小,老年代的内存大小。
(3)回收器选型:响应优先的系统,建议采用ParNew+CMS回收器;吞吐优先、多核大内存(heap size≥8G)服务,建议采用G1回收器。
(4)优化思路:让短命对象在MinorGC阶段就被回收(同时回收后的存活对象
到目前为止,总结到的调优的过程主要基于上线前的测试验证阶段,所以我们尽量在上线之前,就将机器的JVM参数设置到最优!
(2)一份通用的JVM参数模板
一份较为通用的JVM参数模板了,不能保证性能最佳,但是至少能让JVM这一层是稳定可控的。
基于4C8G系统的ParNew+CMS回收器模板(响应优先),新生代大小根据业务灵活调整!
-Xms4g -Xmx4g -Xmn2g -Xss1m -XX:SurvivorRatio=8-XX:MaxTenuringThreshold=10 -XX:+UseConcMarkSweepGC-XX:CMSInitiatingOccupancyFraction=70-XX:+UseCMSInitiatingOccupancyOnly -XX:+AlwaysPreTouch-XX:+HeapDumpOnOutOfMemoryError -verbose:gc -XX:+PrintGCDetails-XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps-Xloggc:gc.log
基于8C16G系统的G1回收器模板(吞吐优先):
-Xms8g -Xmx8g -Xss1m -XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:InitiatingHeapOccupancyPercent=40-XX:+HeapDumpOnOutOfMemoryError-verbose:gc-XX:+PrintGCDetails-XX:+PrintGCDateStamps-XX:+PrintGCTimeStamps-Xloggc:gc.log
以上两份模板参数,都额外增加了GC日志打印、OOM自动dump等配置内容。
(3)开发原则
JVM调优只是一个手段,但并不一定所有问题都可以通过JVM进行调优解决,大多数的Java应用不需要进行JVM优化,我们可以遵循以下的一些原则:
(1)上线之前,应先考虑将机器的JVM参数设置到最优;
(2)减少创建对象的数量(代码层面);
(3)减少使用全局变量和大对象(代码层面);
(4)优先架构调优和代码调优,JVM优化是不得已的手段(代码、架构层面);
(5)分析GC情况优化代码比优化JVM参数更好(代码层面);
(二)案例二:metaspace导致频繁FGC问题
【1】情况描述
(1)服务环境:ParNew + CMS + JDK8
(2)问题现象:服务频繁出现FGC
【2】原因分析过程
(1)查看GC日志
发现出现FGC的原因是metaspace空间不够
Full GC (Metadata GC Threshold)
进一步查看日志发现元空间存在内存碎片化现象
Metaspace used 35337K, capacity 56242K, committed 56320K, reserved 1099776K
(1)used :已使用的空间大小
(2)capacity:当前已经分配且未释放的空间容量大小
(3)committed:当前已经分配的空间大小
(4)reserved:预留的空间大小
内存存在碎片化现象就是根据 used 和 capacity 的数据得来的,上面说了元空间的分配以 chunk 为单位,即使一个 ClassLoader 只加载1个类,也会独占整个 chunk,所以当出现 used 和 capacity 两者之差较大的时候,说明此时存在内存碎片化的情况。
元空间主要适用于存放类的相关信息,而存在内存碎片化说明很可能创建了较多的类加载器,同时使用率较低。因此,当元空间出现内存碎片化时,我们会着重关注是不是创建了大量的类加载器。
(2)查看Dump堆存储文件发现存在大量DelegatingClassLoader
发现是由于反射导致创建大量 DelegatingClassLoader。核心原理如下:
在 JVM 上,最初是通过 JNI 调用来实现方法的反射调用,当 JVM 注意到通过反射经常访问某个方法时,它将生成字节码来执行相同的操作,称为膨胀(inflation)机制。如果使用字节码的方式,则会为该方法生成一个 DelegatingClassLoader,如果存在大量方法经常反射调用,则会导致创建大量 DelegatingClassLoader。
(3)分析结论
反射调用导致创建大量 DelegatingClassLoader,占用了较大的元空间内存,同时存在内存碎片化现象,导致元空间利用率不高,从而较快达到阈值,触发 FGC。
【3】优化策略
(1)适当调大 metaspace 的空间大小。
(2)优化不合理的反射调用。例如最常见的属性拷贝工具类 BeanUtils.copyProperties 可以使用 mapstruct 替换。
(三)案例三:一次Full GC导致CPU飙升
【1】情况描述
生产环境突然间大量接口超时告警,监控发现,问题发生的时间,cpu使率飙升,网络磁盘抖动大,内存使用率飙升,大约3-5分钟后系统自动恢复。
从监控看到,cpu,内存,磁盘,网络在异常发生时都有明显的抖动。
内存使用率突然飙升,应用IO也突然陡增。猜测可能是该时刻有定时任务,或者大量请求导致。问题发生时刻,细致对比变化时间,发现是首先网络IO飙升,磁盘突然增加,猜测可能是该时刻有大量请求导致。
【2】原因分析过程
(1)接口调用量异常排查
根据监控异常,我们猜测最有可能的是该时刻有大量的请求,或者定时任务,导致系统负载突然增加。我们从监控上找到对应的几个问题发生时间段,调用量明显增多的接口。排查代码后,发现没有变更,调用量突然增大是因为cpu异常导致积累了一些请求,所以会看起来调用量突然增加。
(2)内存使用率异常排查
应用异常时,根据监控发现内存使用率飙升,我们找到当时该实例的jvm相关指标监控,发现发生问题时,触发了FullGc,Full Gc次数明显增多,gc耗时增加,堆内存飙升,老年代空间飙升。这里使用的是默认的垃圾收集器 Parallel Scavenge(新生代)+ Serial Old(老年代),后续我们调整为G1垃圾收集器。
下图可见,full gc次数增加多,gc耗时增加,老年代突然增加
错误日志中也有gc oom的日志。超出了GC开销限制,GC占用大量时间为释放很小空间的时候发生的,是一种保护机制。一般是因为堆太小,导致异常的原因:没有足够的内存。
到这里我们猜测,应该是某个使用率低的接口请求,或者某种特定的条件查询,导致突然加载了大量数据,对象实例过大,内存不够用,大对象进入老年代,触发FullGc,Full Gc导致cpu飙升,造成系统卡顿。下一步是需要找到这个对象,确定是哪一块代码引起的。
【3】解决过程
因为gc后,系统自动恢复了,无法确定是哪个对象过大,无法定位到具体的问题,只能确定是内存不够,触发full gc,导致cpu飙升系统卡顿。
我们决定调整jvm参数,扩大内存,修改jvm垃圾收集器,扩大内存后,后面还是出现了该问题,不过这次只是cpu飙升,系统没有出现卡顿(也就是没有触发Full GC),出现问题后,我们使用如下命令,查看jvm堆内存快照,线程堆栈,等信息。发现系统cpu飙升的时候,占用cpu高的线程是full gc的线程。
调整参数配置后,容器部署的实例再次出现问题,不过这次出现问题cpu占用比率没有之前高,没有导致整个应用不可用,我们利用下面的命令获取堆内存,线程堆栈,堆内对象大小,进一步分析问题。
//打印内存快照 然后利用MAT工具分析是否存在内存泄漏等等
jmap -J-d64 -dump:live,format=b,file=dumpfile.hprof [pid]
//打印线程堆栈
jstack pid
//查看内存对象大小
jmap -histo pid | sort -n -r -k 3 | head -20
查看进程里面占用cpu高的线程
ps -mp pid -o THREAD,tid,time | sort -k2r
打印的线程堆栈,发现有线程正在进行垃圾回收,对应的线程id转成16进制是 14 15 16
查看线程发现占用cpu高的线程正是正在进行垃圾回收的那几个线程,对应的线程id是 14 15 16 跟进行垃圾回收的线程id一致
因为这次调整了堆内存大小,触发问题后,没有进行full gc,对象还在,查看内存对象占用比,发现相关代码问题,一次性加载了150万的实例到对象中,调整内存之前加载该对象后,导致内存飙升,触发gc,进而引起cpu使用率飙升。
最终发现是一条sql在特殊情况下,会没有带上任何条件,把整张表的数据加载出来了。后续对于这种情况,需要增加sql监控,返回条数过对的需要有对应的告警。提前暴露风险。
(四)案例四:连接未释放排查过程
【1】生产环境JVM与垃圾回收GC的一些配置建议
未来不分代的收集器是主流,配置简单,效率高。
【2】情况分析
(1)可以使用基本命令分析
可以直接使用命令查看线上的堆内存使用情况
jps
jstat -gcutil 17038 1000 10
//查看堆栈,但是这个堆栈太难阅读了
jstack 17038
出问题的时候,老年代里已经占用了97.8%的空间,并且FGC的次数非常多,说明在老年代中存在大量无法被FGC回收的垃圾对象,最后老年代被撑满导致内存溢出而频繁FGC
(2)也可以使用Arthas分析
dashboard查看系统的实时数据面板,了解当前实时线程的大概情况
可是,上面的这些信息只是一个概要,但是如果我们要进行详细的分析,例如当前堆中有哪些对象没有被正常的释放、占用的空间是多少,还有堆栈的详细信息等等,那么我们就需要查看Dump文件
//生成dump文件,底层就是使用jmap -dump命令,只不过这里更简单好用
heapdump /tmp/dump-1.hprof
接下来就是把生成的dump文件下载到客户机上,使用VisualVM等工具将dump文件生成可视化图表来分析。
导入上面下载下来的有问题的dump文件
等待VisualVM分析完成
(3)如何查看dump文件
第一部分:堆内存的情况概况,只能简单的了解一下整体的情况
注意!堆中的类数量只有19079,但是类对象却有11103777个,对象的数量远远多于类数量,说明有很多对象在堆中无法被回收
第二部分:基本的环境变量
第三部分:堆中对象的数量占比情况
注意!其中的char数组和String类对象,都是我们自己创建的,而且占比不是很大,应该不是导致堆内存占满的主要情况。但是上面的HashMap和ConcurrentHashMap,里面还封装的Node节点,这个显然不是我们自己写的,并且内存占比很大,所以等会可以从这两个对象着重排查。
第四部分:堆中对象的尺寸大小情况
(4)追踪可疑对象的详细情况
会将所有的对象进行一个罗列,对象有很多,我们只需要看第一页的就可以了
点击展开对象后,可能并不能得到太多的信息,我们需要直到到底是哪个方法调用创建的这些对象,如果追踪呢?可以点击上方的GCRoot,然后点击对象,就会自动计算调用路径
可以看到这个对象最终是被谁持有而导致的不可释放,最终定位到包名oss中,可以猜测是使用oss结束的时候资源没有释放导致的
那么究竟是在方法中的哪一块代码导致出的问题呢?我们就需要进入方法内部查看,点击Select Threads可以跳转定位到对应的线程
看到这里已经知道的对象关联的具体方法时run方法,代码行数是78,等会就可以直接到代码中实际查看了。
此外,我们根据刚才对象的调用路径的各种包名和类名也可以简单判断一下,关键字Connect、Http等等,可以猜测一下是因为调用oss创建Http连接以后没有及时关闭连接释放资源导致的对象堆积,最终占满堆内存。
(5)查看具体定位的代码
定位到具体类具体方法的第78行代码
这一行的代码是线程睡眠,但是sleep命令显然不是导致问题的原因。所以,接着往下看代码的逻辑,可以看到整段代码的逻辑就是释放过期的Http连接和闲置的http连接,这里就可以大胆的猜测一下,是不是我们之前创建Http连接之后没有加上关闭连接的操作,导致连接没有及时关闭,所以这里回收不了。于是我们就要把所有代码中创建Http连接的代码都要检查一下。
找到封装了oos连接的代码,可以看到创建了oos的连接,获取了客户端,但是没有看到对应的关闭代码
验证一下,启动应用,通过VisualVM看看能不能复现出来线上的情况
启动应用后到VisualVM中进行查看
在这里可以查看刚才产生问题的对象ConcurrentHashMap的内存状态
可以看到这两个对象的数量一直在增加,而且即使在GC之后数量也不会减少,说明确实是这两个对象回收不了导致的
现在停掉上面的应用,回到代码中,添加oos客户端的关闭代码
重启应用,再查看堆内存的情况,这个时候强制执行一下GC操作
执行完GC之后再看对象的数量情况,可以看到这两者大部分的对象都会回收掉了,说明这些对象可以被回收了
内存溢出问题至此解决
【3】总结
(1)首先使用Arthas查看线上老年代空间占用、FGC次数等等指标是不是出现了异常情况,然后使用命令创建下载dump文件
(2)在VisualVM中分析dump文件,找到可疑的不可回收对象,定位到对象的GCRoot,到具体的代码中分析
(3)分析具体的代码,根据代码功能和经验判断,是不是出现了内存泄漏,什么原因导致的内存泄漏,是不是大对象?是不是连接没关闭?等等。
(4)重启应用,复现线上问题,修改代码,重启应用,看看问题是否解决,反复实验。
(5)大部分问题在修改代码的层面就可以解决,如果实在在代码层面无法解决,最后才会修改JVM参数来解决
(五)案例五:线上OOM&FullGC问题排查过程
【1】排查的流程和参数的配置
(1)打印日志
(2)或者获取dump文件
(3)MAT分析,定位到哪个对象哪行代码出现了问题
(4)修改验证
问题
(1)获取dump文件的时候通过配置什么参数来获取的?
(2)在进行MAT分析的时候,是在哪个TAB页看到对象的实际情况的?
【2】Arthas的常用案例
(1)准备测试的案例代码(死锁、内存泄漏多种情况、死循环)
public class jvmUpdate02 {
private static HashSet hashSet = new HashSet();
public static void main(String[] args) {
//模拟死锁
deadThread();
//创建对象
addHashSetThread();
//模拟高CPU
cpuHigh();
}
/** 模拟死锁
* @MethodName: deadThread
* @Author: AllenSun
* @Date: 2022/11/9 上午12:59
*/
private static void deadThread() {
Object lock1 = new Object();
Object lock2 = new Object();
new Thread(()->{
synchronized (lock1) {
try {
System.out.println("线程1启动");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("线程1结束");
}
}
}).start();
new Thread(()->{
synchronized (lock2) {
try {
System.out.println("线程2启动");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println("线程2结束");
}
}
}).start();
System.out.println("主线程结束");
}
/**不断的向HashSet集合添加数据
* @MethodName: addHashSetThread
* @Author: AllenSun
* @Date: 2022/11/9 上午1:03
*/
public static void addHashSetThread() {
new Thread(()->{
int count = 0;
while (true) {
try {
hashSet.add("count"+count);
Thread.sleep(10000);
count++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
/** 模拟CPU飙升
* @MethodName: cpuHigh
* @Author: AllenSun
* @Date: 2022/11/9 上午1:05
*/
public static void cpuHigh() {
new Thread(()->{
while (true) {
}
}).start();
}
}
(2)运行代码,启动arthas
先运行代码,然后启动arthas:java -jar arthas-boot.jar
(3)排查定位CPU飙高问题(死循环)
(1)输入dashboard:仪表盘,内存使用情况综合界面
可以看出来下面线程id为14的线程占用的CPU较高,接下来对其进行分析
(2)thread 线程id:查看问题线程的具体情况
ctrl+c退出仪表盘
thread 14查看对应线程的情况
找到92行,果然看到方法体中有一个无限循环,导致了CPU飙高
(3)thread -n 1:列出CPU占用前1的线程
(4)排查定位死锁问题(死锁)
(1)thread -b:定位死锁问题出现的位置
定位到42行,这个lock2在下面被尝试获取失败导致产生的死锁
(2)thread,获取所有线程的情况
根据状态State的字段【BLOCKED】可以找到具体产生死锁的方法
(六)案例六:构建树结构时节点重复导致的堆内存溢出
【1】需求描述
根据维表数据和层级关系确定id和parentId,然后进行构建树结构,但是因为节点有发布和未发布的状态过滤,所以导致树关系可能从中间断掉,断掉的节点就作为一个单独的根节点。
所以逻辑就是根据节点的parentId是否为空或者parentId能否匹配到节点来判断是不是根节点,然后遍历根节点来生成多条树
【2】问题现象
现象就是一共查出来3000多条节点,根节点60个左右,但是接口返回的数据有700M,远远超出正常的数据内存量,接口长时间卡顿后直接爆掉
【3】参数配置
【4】排查过程
查看下载的堆内存快照日志
【5】问题解决
(七)JVM优化问题总结
【1】如果使用合理的 JVM 参数配置,在大多数情况应该是不需要调优的
JVM 经过这么多年的发展和验证,整体是非常健壮的。99%的情况下,基本用不到 JVM 调优。JVM 参数的默认(推荐)值都是经过 JVM 团队的反复测试和前人的充分验证得出的比较合理的值,因此通常来说是比较靠谱和通用的,一般不会出大问题。
大部分情况都是代码 bug 导致 OOM、CPU load高、GC频繁啥的,这些场景也基本都是代码修复即可,通常不需要动 JVM。
【2】JVM 参数配置的标准和模板
-XX:NewRatio=2,年轻代:老年代=1:2
-XX:SurvivorRatio=8,eden:survivor=8:1
堆内存设置为物理内存的3/4左右
【3】JVM 有哪些核心指标?合理范围应该是多少?
(1)jvm.gc.time:每分钟的GC耗时在1s以内,500ms以内尤佳
(2)jvm.gc.meantime:每次YGC耗时在100ms以内,50ms以内尤佳
(3)jvm.fullgc.count:FGC最多几小时1次,1天不到1次尤佳
(4)jvm.fullgc.time:每次FGC耗时在1s以内,500ms以内尤佳
通常来说,只要这几个指标正常,其他的一般不会有问题,如果其他地方出了问题,一般都会影响到这几个指标。
【4】JVM 优化步骤?
(1)CPU指标
1、查看占用CPU最多的线程
2、查看线程堆栈快照信息
3、分析代码执行热点
4、查看哪个代码占用CPU执行时间最长
5、查看每个方法占用CPU时间比例
(2)JVM 内存指标
1、查看当前 JVM 堆内存参数配置是否合理
2、查看堆中对象的统计信息
3、查看堆存储快照,分析内存的占用情况
4、查看堆各区域的内存增长是否正常
5、查看是哪个区域导致的GC
6、查看GC后能否正常回收到内存
(3)JVM GC指标
1、查看每分钟GC时间是否正常
2、查看每分钟YGC次数是否正常
3、查看FGC次数是否正常
4、查看单次FGC时间是否正常
5、查看单次GC各阶段详细耗时,找到耗时严重的阶段
6、查看对象的动态晋升年龄是否正常
JVM 的 GC指标一般是从 GC 日志里面查看,默认的 GC 日志可能比较少,我们可以添加以下参数,来丰富我们的GC日志输出,方便我们定位问题。GC日志常用 JVM 参数:
// 打印GC的详细信息
-XX:+PrintGCDetails
// 打印GC的时间戳
-XX:+PrintGCDateStamps
// 在GC前后打印堆信息
-XX:+PrintHeapAtGC
// 打印Survivor区中各个年龄段的对象的分布信息
-XX:+PrintTenuringDistribution
// JVM启动时输出所有参数值,方便查看参数是否被覆盖
-XX:+PrintFlagsFinal
// 打印GC时应用程序的停止时间
-XX:+PrintGCApplicationStoppedTime
// 打印在GC期间处理引用对象的时间(仅在PrintGCDetails时启用)
-XX:+PrintReferenceGC
【5】制订优化方案
(1)代码bug:升级修复bug。典型的有:死循环、使用无界队列。
(2)不合理的JVM参数配置:优化 JVM 参数配置。典型的有:年轻代内存配置过小、堆内存配置过小、元空间配置过小。
【6】可能还是存在少量场景需要调优
我们可以对一些 JVM 核心指标配置监控告警,当出现波动时人为介入分析评估
调优工具需要补充测试的内容