JVM调优案例
一、核心指标
调优之前首先我们要知道怎样才算是“优”,不能笼统的说我的程序性能很好,所以就需要有一个具体的指标来衡量性能情况,而在JVM里面衡量性能两个指标分别“吞吐量”和“停顿时间”。
吞吐量:程序运行过程中执行两种任务,分别是执行业务代码和进行垃圾回收,吞吐量大意就是说程序运行业务代码的时间越多程序的吞吐量就越高,其计算公式,吞吐量 = CPU在用户应用程序运行的时间 / (CPU在用户应用程序运行的时间 + CPU垃圾回收的时间),一般而言GC的吞吐量不能低于95%。
停顿时间:因为JVM进行垃圾回收的时候,某些阶段必须要停止业务线程专心进行垃圾收集,停顿时间就是指JVM停止业务线程而去进行垃圾收集的这段时长,停顿时间越长就意味着用户线程等待的时间越长,停顿时间会直接影响用户使用系统的体验。
垃圾回收频率:通常来说垃圾回收频率是越低越好,垃圾收集的过程是非常占用CPU资源的,资源有限如果垃圾收集占用的资源越多那么意味着其他事情所用的资源会减少,系统所能做的事情也会越少。当然也不能一味的追求GC次数减少,GC次数减少了有可能就会使得单次GC的时间变长,那么就可能会增加单次GC的“停顿时长”,所以需要在这两者之间做一些平衡。
如果获得这些指标?
在项目启动的时候增加下列参数来收集GC日志,然后通过第三方的日志分析工具GC easy分析收集到的GC日志来得到吞吐量、停顿时间相关的统计数据。
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+UseGCLogFileRotation
-XX:+PrintHeapAtGC -XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
-Xloggc:/opt/ard-user-gc-%t.log
-jar abg-user-1.0-SNAPSHOT.jar
参数说明:
- -Xloggc:/opt/app/ard-user/ard-user-gc-%t.log 设置日志目录和日志名称
- -XX:+UseGCLogFileRotation 开启滚动生成日志
- -XX:NumberOfGCLogFiles=5 滚动GC日志文件数,默认0,不滚动
- -XX:GCLogFileSize=20M GC文件滚动大小,需开启UseGCLogFileRotation
- -XX:+PrintGCDetails 开启记录GC日志详细信息(包括GC类型、各个操作使用的时间),并且在程序运行结束打印出JVM的内存占用情况
- -XX:+PrintGCDateStamps 记录系统的GC时间
- -XX:+PrintGCCause 产生GC的原因(默认开启)
二、调优标准
小明和小芳今天都准备在家打扫卫生,小明准备花两个小时一次性把所有房间全部打扫完毕,然后剩下的时间可以去安心打游戏了。而小芳的想法不一样,它打算每打扫一个房间就休息一会听听音乐看看电视,这样就感觉没那么累,一直能保持着愉悦的心情。那么如果是你来打扫卫生,你会选择什么样的方式呢?
我想不同的人会有不同的答案,而且谁也不能说哪个人的打扫方式是最好的,因为在这个场景里并没有最好的选择,而只有在自己特有的需求场景里最优的选择。那么JVM调优也是一样的,没有万能的公式和标准就是因为每个人所面对的场景是不一样的。要想调整到最优的性能,其实首先要确认的是自己的需求目标是什么,我们需要做的就是根据这个目标去慢慢的调整各项指标从而达到一个最佳的平衡点。
吞吐量和停顿时间的选择
调优前首先要确定大方向,是选择基于吞吐量调优、还是停顿时间调优,哪个是你的硬性指标,这个硬性标准就是指导你进行调优的原则。如果你的应用和用户没有什么交互,完全不需要关注用户体验,那么你的硬性标准就是不顾一切的提升吞吐量,达到程序性能的最优。相反如果你的应用是频繁和用户进行交互的,那么提升用户体验就是一个非常重要的指标了,这个时候你的原则就是在用户能忍受卡顿时间(停顿时间)范围之内,来调整指标来找到停顿时间和吞吐量的一个平衡值。
举个不是很恰当但有助于你理解这个思路的例子:
如果用户在点击一个功能之后500ms之内没有返回,就会使用户焦虑,那么500ms就是影响用户体验的一个标准了。如果你的业务代码执行到返回的时间需要运行的时间为400ms,那么意味着垃圾回收的停顿的时间必须控制在100ms之内才对用户体验没有影响。所以这个时候你的调优硬性标准就是把停顿时间控制在100ms之内,然后在这个时间范围的基础上去调整JVM参数让吞吐量越高越好。
有了指导原则后,我们就需要在这两个指标上进行平衡达到最优值了,比如这个时候如果有两组指标,第一组:停顿时间为80ms,吞吐量为92,第二组停顿时间为98ms,吞吐量为98,那么相对而言第二组指标是一个合适调优结果,因为它即符合100ms停顿时间的原则,又将吞吐量最大化了。
三、常用调优策略
3.1 选择合适的垃圾回收器
垃圾回收器的选型直接决定调优上限,需结合CPU核心数、JDK版本、内存大小选择:
- CPU单核,那么毫无疑问Serial垃圾收集器是你唯一的选择。
- CPU多核,关注吞吐量,那么选择PS+PO组合。
- CPU多核,关注用户停顿时间,JDK版本1.6或者1.7,那么选择CMS。
- CPU多核,关注用户停顿时间,JDK1.8及以上,JVM可用内存6G以上,那么选择G1。
参数配置:
//设置Serial垃圾收集器(新生代)
开启:-XX:+UseSerialGC
//设置PS+PO,新生代使用功能Parallel Scavenge 老年代将会使用Parallel Old收集器
开启 -XX:+UseParallelOldGC
//CMS垃圾收集器(老年代)
开启 -XX:+UseConcMarkSweepGC
//设置G1垃圾收集器
开启 -XX:+UseG1GC
3.2 增加内存大小
现象:垃圾收集频率非常频繁。
原因:如果内存太小,就会导致频繁的需要进行垃圾收集才能释放出足够的空间来创建新的对象,所以增加堆内存大小的效果是非常显而易见的。
注意:如果垃圾收集次数非常频繁,但是每次能回收的对象非常少,那么这个时候并非内存太小,而可能是内存泄漏导致对象无法回收,从而造成频繁GC。
参数配置:
//设置堆初始值
指令1:-Xms2g
指令2:-XX:InitialHeapSize=2048m
//设置堆区最大值
指令1:`-Xmx2g`
指令2:-XX:MaxHeapSize=2048m
//新生代内存配置
指令1:-Xmn512m
指令2:-XX:MaxNewSize=512m
3.3 设置符合预期的停顿时间
现象:程序间接性的卡顿
原因:如果没有确切的停顿时间设定,垃圾收集器以吞吐量为主,那么垃圾收集时间就会不稳定。
注意:不要设置不切实际的停顿时间,单次时间越短也意味着需要更多的GC次数才能回收完原有数量的垃圾.
参数配置:
//GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间
-XX:MaxGCPauseMillis
3.4 调整内存区域大小比率
现象:某一个区域的GC频繁,其他都正常。
原因:如果对应区域空间不足,导致需要频繁GC来释放空间,在JVM堆内存无法增加的情况下,可以调整对应区域的大小比率。
注意:也许并非空间不足,而是因为内存泄漏造成内存无法回收。从而导致GC频繁。
参数配置:
//survivor区和Eden区大小比率
指令:-XX:SurvivorRatio=6 //S区和Eden区占新生代比率为1:6,两个S区2:6
//新生代和老年代的占比
-XX:NewRatio=4 //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2
3.5 调整对象升老年代的年龄
现象:老年代频繁GC,每次回收的对象很多。
原因:如果升代年龄小,新生代的对象很快就进入老年代了,导致老年代对象变多,而这些对象其实在随后的很短时间内就可以回收,这时候可以调整对象的升级老年代的年龄,让对象不那么容易进入老年代解决老年代空间不足频繁GC问题。
注意:增加了年龄之后,这些对象在新生代的时间会变长可能导致新生代的GC频率增加,并且频繁复制这些对象新生的GC时间也可能变长。
配置参数:
//进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,默认值7
-XX:InitialTenuringThreshol=7
3.6 调整大对象的标准
现象:老年代频繁GC,每次回收的对象很多,而且单个对象的体积都比较大。
原因:如果大量的大对象直接分配到老年代,导致老年代容易被填满而造成频繁GC,可设置对象直接进入老年代的标准。
注意:这些大对象进入新生代后可能会使新生代的
GC频率和时间增加。
配置参数:
//新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。
-XX:PretenureSizeThreshold=1000000
3.7 调整GC的触发时机
现象:CMS,G1经常FullGC,程序卡顿严重。
原因:G1和CMS部分GC阶段是并发进行的,业务线程和垃圾收集线程一起工作,也就说明垃圾收集的过程中业务线程会生成新的对象,所以在GC的时候需要预留一部分内存空间来容纳新产生的对象,如果这个时候内存空间不足以容纳新产生的对象,那么JVM就会停止并发收集暂停所有业务线程(STW)来保证垃圾收集的正常运行。这个时候可以调整GC触发的时机(比如在老年代占用60%就触发GC),这样就可以预留足够的空间来让业务线程创建的对象有足够的空间分配。
注意:提早触发GC会增加老年代GC的频率。
配置参数:
//使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小
-XX:CMSInitiatingOccupancyFraction
//G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为65%
-XX:G1MixedGCLiveThresholdPercent=65
3.8 调整JVM本地内存大小
现象:GC的次数、时间和回收的对象都正常,堆内存空间充足,但是报OOM
原因:JVM除了堆内存之外还有一块堆外内存,这片内存也叫本地内存,可是这块内存区域不足了并不会主动触发GC,只有在堆内存区域触发的时候顺带会把本地内存回收了,而一旦本地内存分配不足就会直接报OOM异常。
注意:本地内存异常的时候除了上面的现象之外,异常信息可能是
OutOfMemoryError:Direct buffer memory。解决方式除了调整本地内存大小之外,也可以在出现此异常时进行捕获,手动触发GC(System.gc())。
配置参数:
XX:MaxDirectMemorySize
3.9 优化业务代码
绝大部分的问题都出自于业务代码本身的问题,在JVM调优里面也不例外,要减少GC的频率其实业务代码做一个很简单的优化就可以达到。
比如我们如果业务代码中稍微减少了非必要的对象、字段、属性,对象变少了,体积变小了,那么是不是就可以很大程序的减少GC次数和时间问题。
提升方法的运行效率,方法执行完后产生的对象就可以释放进行回收了,方法运行时间越长那么这些对象呆在堆内存的时间就越久,内存就越容易堆满,GC的频率就会增加。
还有由于业务代码的不合理导致的内存泄露长期无法回收,这也是JVM最常见的问题。所以解决业务代码的问题有时候远比上面的参数调优要有效得多。
四、JVM调优场景案例
下面主要介绍一些实际场景的JVM调优案例和一些通用的问题排错思路,我们可以通过这些案例场景来学习一些调优的思路。
案例一:高并发系统下的频繁GC优化
背景
某电商平台在促销活动期间,系统并发量急剧增加,用户反馈页面加载缓慢,后台监控显示频繁发生GC,CPU使用率居高不下。
问题分析
通过JDK自带的jstat工具分析GC日志,发现年轻代GC(Minor GC)频繁发生,每次Minor GC后存活对象较多,且老年代增长速度较快,很快触发Full GC。原因是高并发请求导致对象创建速度极快,年轻代空间不足,对象频繁进入老年代。
调优措施
- 增大年轻代空间:通过参数
-Xmx64g -Xms64g -XX:NewRatio=2(调整新生代与老年代比例,默认2表示新生代:老年代=1:2,这里设置为新生代占堆内存的1/3),增加年轻代大小,使对象在年轻代有更多机会被回收。 - 调整
Survivor区比例:使用参数-XX:SurvivorRatio=8(默认8表示Eden区:Survivor区=8:1:1),适当增大Survivor区,提高对象在年轻代的存活时间,减少过早晋升到老年代的对象。
效果评估
调优后,Minor GC频率大幅降低,Full GC次数明显减少,系统响应时间缩短,CPU使用率下降,用户体验得到显著改善。
案例二:内存泄漏导致的Full GC频繁问题解决
背景
某企业级应用在长期运行过程中,逐渐出现性能下降,Full GC频繁发生,且Full GC后内存使用率没有明显下降。
问题分析
利用VisualVM工具对JVM进行监控,并dump出内存快照。通过MAT(Memory Analyzer Tool)工具分析内存快照,发现存在大量未被释放的对象,这些对象持有大量引用,导致无法被垃圾回收器回收,确定为内存泄漏问题。进一步排查代码,发现是一个缓存模块中,对缓存对象的引用没有正确释放,随着时间推移,缓存对象越来越多,占用大量内存。
调优措施
- 修复代码中的内存泄漏问题:在缓存模块中,当对象不再需要时,及时清除对其的引用,确保垃圾回收器能够正常回收这些对象。
- 优化缓存策略:设置合理的缓存过期时间,定期清理过期缓存对象,减少内存占用。
效果评估
经过代码修复和缓存策略优化后,Full GC频率显著降低,内存使用率恢复正常,系统性能得到稳定提升。
案例三:大对象引发的性能问题调优
背景
一个数据处理应用,在处理大数据集时,出现频繁的Full GC,系统响应迟缓,甚至出现卡顿现象。
问题分析
通过分析GC日志和代码逻辑,发现应用中存在一些大对象的创建和处理。这些大对象由于无法在年轻代分配,直接进入老年代,导致老年代空间快速被填满,频繁触发Full GC。例如,在数据读取和处理过程中,一次性将大量数据加载到内存中,形成大对象。
调优措施
- 调整对象创建方式:将大对象拆分成多个小对象进行处理,减少单个对象的内存占用,使其能够在年轻代分配和回收。
- 使用内存池技术:针对频繁创建和销毁的对象,使用对象池进行管理,避免频繁的对象创建和销毁操作。
- 调整堆内存参数:适当增大老年代空间,通过参数
-Xmx128g -Xms128g -XX:NewRatio=3(增大老年代比例,新生代占堆内存的1/4),以容纳大对象。
效果评估
调优后,Full GC次数明显减少,系统处理大数据集的能力增强,响应速度加快,卡顿现象消失。
案例四:Metaspace导致的Full GC优化
背景
某Java Web应用在运行一段时间后,频繁出现Full GC,且Metaspace区域内存使用持续增长,最终导致应用崩溃。
问题分析
通过监控工具发现,Metaspace区域内存不断增长,原因是应用中动态加载了大量类,而类加载器没有及时释放不再使用的类元数据。查看代码发现,在一些模块中,使用了自定义类加载器,并且没有正确处理类加载器的生命周期,导致类元数据无法被回收。
调优措施
- 优化类加载逻辑:确保自定义类加载器在不再使用时,及时卸载其所加载的类,释放类元数据。例如,在类加载器使用完毕后,调用
ClassLoader.clearAssertionStatus()方法清除相关状态,并将类加载器的引用设置为null,以便垃圾回收器回收。 - 设置合理的Metaspace大小:通过参数
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m,限制Metaspace的初始大小和最大大小,避免其无限制增长。
效果评估
调优后,Metaspace区域内存使用稳定,Full GC频率大幅降低,应用运行稳定性显著提高。
案例五:频繁GC导致的吞吐量下降问题优化
背景
一个批处理应用,在处理大量数据时,虽然没有出现内存溢出等严重问题,但整体处理时间过长,吞吐量较低,经分析发现是频繁的GC操作导致。
问题分析
通过GC日志分析,发现应用中对象创建和销毁频繁,导致Minor GC频繁发生,每次Minor GC都会暂停应用线程,从而影响了整体的吞吐量。例如,在数据处理过程中,大量临时对象被创建用于中间计算。
调优措施
- 调整垃圾回收器:从默认的
Parallel Scavenge(新生代)+Serial Old(老年代)垃圾回收器,更换为G1垃圾回收器,通过参数-XX:+UseG1GC启用。G1垃圾回收器可以更好地处理大内存和高并发场景,减少GC停顿时间。 - 优化代码逻辑:减少不必要的临时对象创建,尽量复用已有的对象。例如,在循环中,将对象创建移到循环外部。
效果评估
调优后,Minor GC频率降低,应用吞吐量提高,批处理任务的执行时间明显缩短。
案例六:高吞吐量需求下的JVM调优
背景
某大数据计算平台,对系统吞吐量有较高要求,需要在单位时间内处理大量数据,但当前系统性能无法满足业务增长需求。
问题分析
通过性能测试和监控,发现系统在高负载下,垃圾回收时间占比较大,导致实际用于业务计算的时间减少,从而影响吞吐量。主要原因是现有的垃圾回收器配置和堆内存分配不合理。
调优措施
- 选择合适的垃圾回收器:针对高吞吐量需求,选用
Parallel Scavenge(新生代)+Parallel Old(老年代)垃圾回收器组合,通过参数-XX:+UseParallelGC -XX:+UseParallelOldGC启用。这两个垃圾回收器专注于吞吐量,能够在STW(全局暂停)期间,利用多线程快速完成垃圾回收工作。 - 优化堆内存分配:根据业务数据量和对象存活周期,合理设置堆内存大小和新生代、老年代比例。通过测试,设置参数
-Xmx256g -Xms256g -XX:NewRatio=4(新生代占堆内存的1/5),使对象在年轻代和老年代之间有更合理的分布,减少Full GC次数。
效果评估
调优后,系统吞吐量显著提升,在相同时间内能够处理更多的数据,满足了业务增长的需求。
案例七:低延迟场景下的JVM调优
背景
某实时交易系统,对响应延迟要求极高,任何微小的延迟都可能导致交易损失,但当前系统在高并发时,响应延迟明显增加。
问题分析
经分析,高并发下频繁的GC操作导致应用线程暂停,产生较大延迟。尤其是Full GC时,暂停时间较长,严重影响系统的实时性。同时,堆内存分配不合理,导致对象频繁在年轻代和老年代之间移动,增加了GC开销。
调优措施
- 启用低延迟垃圾回收器:选择
CMS(Concurrent Mark Sweep)垃圾回收器,通过参数-XX:+UseConcMarkSweepGC启用。CMS垃圾回收器采用并发标记和清除算法,尽量减少STW时间,降低延迟。 - 调整堆内存参数:为了减少对象在年轻代和老年代之间的移动,适当增大年轻代空间,设置参数
-Xmx64g -Xms64g -XX:NewRatio=1(新生代与老年代比例为1:1)。同时,设置合理的Survivor区大小,通过参数-XX:SurvivorRatio=10,延长对象在年轻代的存活时间。 - 优化代码:减少对大对象的创建和使用,避免频繁的对象分配和回收操作。例如,对一些固定大小的数据结构,采用对象池技术进行复用。
效果评估
调优后,系统响应延迟大幅降低,在高并发场景下也能满足实时交易系统对低延迟的严格要求。
案例八:堆内存过小导致的性能问题调优
背景
某小型Java应用,在部署到生产环境后,经常出现内存不足错误(OOM),应用无法正常运行。
问题分析
检查服务器资源和应用配置,发现分配给JVM的堆内存过小,无法满足应用运行时的内存需求。应用在运行过程中,不断创建对象,当堆内存耗尽时,就会触发OOM错误。
调优措施
- 增大堆内存:通过参数
-Xmx1g -Xms1g,将堆内存的初始大小和最大大小都设置为1GB,以满足应用的内存需求。 - 监控内存使用情况:使用
JDK自带的jconsole工具,实时监控堆内存的使用情况,观察对象的创建和回收是否正常,确保调整后的堆内存能够满足应用运行。
效果评估
调优后,应用不再出现OOM错误,运行稳定,性能得到明显改善。
案例九:对象过早提升导致的Full GC优化
背景
某数据分析应用,在运行过程中频繁出现Full GC,且每次Full GC后老年代使用率较低,系统性能受到较大影响。
问题分析
分析GC日志发现,年轻代中的对象过早地晋升到老年代,导致老年代空间快速被填满,触发Full GC。原因是年轻代空间不足,Survivor区设置不合理,使得对象在年轻代存活时间较短,就被晋升到老年代。
调优措施
- 增大年轻代空间:通过参数
-Xmx64g -Xms64g -XX:NewRatio=2,增加年轻代大小,使对象有更多机会在年轻代被回收。 - 调整
Survivor区比例:使用参数-XX:SurvivorRatio=10,增大Survivor区,延长对象在年轻代的存活时间,减少过早晋升到老年代的对象。
效果评估
调优后,Full GC频率显著降低,系统性能得到提升,老年代使用率趋于合理。
案例十:JIT编译导致的性能问题调优
背景
某Java应用在启动后一段时间内,性能表现较差,随着运行时间增长,性能逐渐提升,经分析与JIT(Just-In-Time)编译有关。
问题分析
JIT编译器在应用运行过程中,会对热点代码进行编译优化,以提高执行效率。但在应用启动初期,大量代码需要编译,导致CPU使用率较高,性能下降。而且,由于编译策略不合理,一些非热点代码也被频繁编译,浪费了资源。
调优措施
- 调整JIT编译阈值:通过参数
-XX:CompileThreshold=10000(默认值是10000,表示方法调用次数或循环回边执行次数达到该值时,触发JIT编译),适当增大编译阈值,减少不必要的编译操作,让JIT编译器更聚焦于真正的热点代码。 - 启用分层编译:使用参数
-XX:+TieredCompilation启用分层编译,分层编译将编译过程分为多个层次,不同层次采用不同的编译策略,在保证编译质量的同时,提高编译速度,减少启动初期的性能损耗。
效果评估
调优后,应用启动初期的性能得到明显改善,整体性能更加稳定,JIT编译对系统性能的负面影响得到有效控制。
五、主流垃圾回收器对比(选型参考)
当前主流/优秀的搜集器包含:
- Parrallel Scavenge + Parrallel Old:吞吐量优先,后台任务型服务适合;
- ParNew + CMS:经典的低停顿搜集器,绝大多数商用、延时敏感的服务在使用;
- G1:JDK 9默认搜集器,堆内存比较大(6G-8G以上)的时候表现出比较高吞吐量和短暂的停顿时间;
- ZGC:JDK 11中推出的一款低延迟垃圾回收器,目前处在实验阶段;
结合当前服务的实际情况(堆大小,可维护性),选择比较合适的方案。
六、总结
JVM调优的核心逻辑是:先定义指标,再确立标准,后精准优化。不要盲目调整参数,而是通过GC日志、堆快照、线程栈等工具定位问题根源,结合业务场景选择合适的回收器和优化策略。多数情况下,业务代码优化 + 合理的内存分配,就能解决80%的JVM性能问题。记住:调优是一个迭代过程,需要持续监控和动态调整,才能让系统在不同负载下保持最优状态。

浙公网安备 33010602011771号