概要
Java性能分析是一门艺术和科学。科学指的是性能分析一般都包括大量的数字、测量和分析;艺术指的是知识、经验和直觉的使用。性能分析的工具或者手段各有千秋,但性能的分析的过程却都大相径庭。本文就已知适用的Java性能分析窍门进行一些分享,帮助用户更好的理解和运用。
窍门一:线程栈剖析
线程栈分析是对正在运行的Java线程的快照分析,是一种轻量级的分析手段,用户在不清楚应用存在什么性能问题的时候可优先尝试。虽然判定Java线程是否异常并没有统一的标准,但用户可以通过一些指标进行定量的评估。以下分享4个检测指标:
1)线程死锁检查
线程死锁检查是一个非常有价值的检测指标。如果线程死锁,则一般存在系统资源的浪费或服务能力下降等问题,一旦发现就需要及时处理。线程死锁检测会展示线程死锁关系以及对应的栈信息,通过分析即可定位到触发死锁的代码。如图-1所示死锁模型展示了一个复杂的4线程死锁场景。
2)线程统计检查
状态统计是对运行的线程按照运行状态进行的统计和汇总。用户在不完全了解自己业务压力的情况下,对于可用线程数一般会配置一个非常充裕的范围,这样反而会因为过多的线程导致性能下降或者系统资源耗尽。如图-2所示,可以发现超过90%的线程处于阻塞和等待状态,那么适当优化线程数量是可以减少线程调度带来的开销以及不必要的资源浪费。
如图-3所示,处于运行阶段的线程数已经超过90%,进一步分析可能存在线程泄露的问题。同时,运行的线程太多,线程切换的开销也是非常大的。
3)线程CPU使用率检查
对各Java线程CPU使用情况进行统计和排序,针对CPU使用率极高的线程线程栈进行分析,可以快速定位到程序热点。如图-4所示,首个任务线程的CPU使用率达已经到100%,则开发人员可根据业务逻辑确定是否进行代码优化。
4)GC线程数检查
GC线程数往往是容易被用户忽视的指标。用户在设置并行GC线程数的时候容易忽视系统的资源情况,或者随意将应用部署在CPU核数较多的物理机。如图-5所示,我们发现在一个4核8GB的容器中G1的并发收集线程数为9(一般情况下并行GC的线程数是GC任务线程数的1/4),也就是在GC发生的时候可能会出现9个并行的GC线程,这种情况下CPU资源会被短时间直接耗尽而系统和业务阻塞。所以在使用GC收集器(如CMS、 G1)的时候尽可能设置或者关注GC的线程数。
窍门二:GC日志剖析
日志分析是对Java程序GC收集记录数据的分析,而这部分数据的收集是需要开启特定选项的。所以,在启动Java程序前一定要增加日志参数(如JDK8:-Xloggc:logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps;JDK11:-Xlog:gc*:logs/gc-%t.log:time,uptime)。GC日志分析的结果描述的是在过去一段时间内Java程序就内存回收的状态。通过分析这些状态信息,用户可以非常方便的获得到GC参数甚至Java代码优化的指标数据。以下就3个分析指标进行展开:
1)GC的吞吐率
吞吐率描述的是在JVM运行时间段可用于业务处理的时间占比,即非GC占用时间。该值越大表示用户GC占用的时间越少,JVM性能越好。JVM内部指定该值不能低于90%,否则JVM本身所带来的性能损耗就会严重影响业务性能。如图-6所示是JDK8的CMS运行3小时左右的日志分析结果,分析结果显示其吞吐率超过99.2%,JVM(GC)导致的性能损耗是比较低的。
2)暂停时间统计
GC暂停时间指的是在GC过程中需要停止业务线程运行的时间,该时间需要在在一个合理的范围内。如果绝大部分的暂停时间超过预期(用户可以接受的范围),则很有需要去调整GC参数以及堆大小,甚至设置并行GC线程数。如图-7所示,95%以上的GC暂停时间是在40ms以内;而超过100ms的暂停可能是导致业务请求时间毛刺的主要因素。为了消除暂停时间波动问题,可以选择如G1 GC或ZGC,或者调整并行线程数或者GC参数等。
3)GC阶段散点图
散点图反映的是每一次GC操作释放的内存大小的分布情况。如图-8所示,每次GC释放的内存大小基本一致,说明内存释放过程比较稳定。但如果出现比较大的波动或者出现比较多的Full GC则有可能是新生代区堆空间不足导致晋升量较大;如果每次GC的释放量比较少有可能是G1 GC自适应算法导致的新生代空间较小等等。因为散点图展示的数据有限,所以一般需要结合其它指标以及用户的JVM参数进行联合分析。
窍门三:JFR事件剖析
JFR是Java Flight Record的缩写,是JVM内置的基于事件的JDK监控记录框架。社区中,JFR优先于OpenJDK11上发布,后移植到OpenJDK8的较高版本260上,且沿用了统一的使用接口与操作命令jcmd。同时,由于JFR录制一般对应用影响很小(默认开启的性能影响在1%以内),适合长时间开启;且JFR能收集到如Runtime、GC、线程栈、堆、IO等在内的丰富信息,非常方便用户了解Java程序的运行状况。
JFR录制的事件有100多种,如果程序复杂往往不到10分钟录制的JFR文件大小就会超过500MB,所以用户在分析时往往并不是所有的信息都会关注。以下就业务性能中常见几个做一下分享:
1)进程CPU占用率
CPU采样默认间隔是1s,基本能及时反映当前进程的CPU平均使用情况。在出现CPU持续偏高,或者CPU出现类似图-9所示的CPU偶发飙高的情况时,都可以进行一定的检测和分析。通过进一步定位,此处CPU飙高与GC触发时间一致,初步确认是GC导致的CPU变化。
2)GC配置及暂停分布
GC配置可以帮助我们了解到当前进程的GC收集器及其主要的配置参数,因为不用的收集器特性会不同,分析堆空间、触发控制等参数也是非常重要的。控制参数可以帮助我们理解GC收集过程,比如图-10所示的G1收集器,设置的最大堆为8GB,GC暂停时间在40ms左右(默认预期200ms),是远低于预期值的。进一步分析参数发现设置了NewRatio值为2(对G1 GC不太熟悉的情况下,用户很容易设置该参数),导致新生代区的GC触发频繁,而且从数据看未触发混合GC。为了增加对堆空间的利用,可以移除NewRatio参数,增加新生代区的最大值比例(因为未触发混合GC,说明堆回收时晋升量非常低),降低回收块的回收门槛等,进而增加对整堆的使用。通过优化,堆空间的使用从原来的4GB提高到7GB,YGC频率从20s/次提高到平均40s/次,GC暂停时间没有明显变化。
3)方法采样火焰图
方法火焰图是对调用方法采样次数的统计,比例越大表示调用次数越多。因为采样过程中有栈的完整信息,对于用户来说是非常比较直观的,性能优化的帮助性大增。如图-11所示,可以很清楚的看到GroupHeap.match执行次数比例接近30%,可以作为性能优化点。
4)IO读写性能
检查IO性能多半是对程序处理性能出现突变的场景,比如下降或飙升。如从socket读入的数据量飙升,导致处理业务的CPU飙高;或者因为需要写出的数据变多,导致业务线程阻塞,处理能力下降等。如图-11所示,可以通过读取/写入趋势图判断在监控时间段的IO能力。
窍门四:堆内容剖析
堆内容分析是分析Java堆OOM(OutOfMemoryError)原因的常用手段。OOM主要有堆空间溢出、元空间溢出、栈空间溢出和直接内存溢出等,但并不是所有溢出情况都可以通过堆内容分析获得。对于堆转储的文件而言,内存溢出的可能性是不确定的,但可以通过一些定量的指标或者约定的条件作出判断,再通过开发或者测试人员进行最后的确认。以下分享三个有价值的衡量指标:
1)大对象检查
统计大对象分布信息可以帮助我们了解内存消耗在这部分对象上的比重,以及存在的大对象是否合理。过多的大对象无法释放会更快的耗尽内存而出现OOM,相比全量的分析所有对象而言,大对象的检查是具有代表性的,如图-13所示。
2)类加载检查
类加载统计主要统计的是程序当前加载的全部类信息,是计算元空间占用的重要数据。过多的加载类信息也会导致元空间被大量占用,在类似RPC场景下,缓存加载类信息是容易触发OOM的。
3)对象泄露检查
首先引入三个概念;
浅堆:一个对象所占用的内存大小,和对象的内容无关,只和对象的结构有关。
深堆:一个对象被GC回收后,可以真实释放的内存大小,即通过该对象访问到的所有对象的浅堆之和(支配树)。
支配树:在对象的引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B;如果对象A是离对象B最近的一个支配对象,则认为对象A为对象B的直接支配者。
按照GC策略,堆中的对象只可能有两种状态,一种是通过GC的根可达的对象;另一种是通过GC根不可达的对象。不可达的对象会被GC收集器回收,对应的内存就会返回到系统中去。而可达对象都是被用户直接或者间接引用的对象,所以对象泄露针对的就是被用户间接引用但永远不会被使用的对象,这些对象因为被引用而无法释放。对象泄露不是绝对的,而是相对的,一般没有确切的标准,但可以通过对对象的深堆大小进行评估。比如检测到HashMap存放了4844个对象(如图-14所示),计算HashMap浅堆约115KB,看到这里可能觉得没有什么问题;但通过计算对象的深堆发现其超过500MB。这种情况下,如果无法释放HashMap而持续增加新的键值就有可能导致堆内存耗尽而出现OOM。
作者简介:
Nianwu,高级后端工程师
主要负责Java性能平台和JDK支持,对缺陷检查和编译器也有深入研究。
获取更多精彩内容,请关注[OPPO互联网技术]公众号