一、JVM 调优核心原则
JVM 调优旨在平衡系统的吞吐量、延迟和内存使用。在进行 JVM 调优时,我们可以遵循以下原则:
先优化代码:优先排查业务逻辑中的内存泄漏、对象滥用等问题。优化代码不仅能从根本上解决性能问题,还能减少对 JVM 参数调整的依赖。比如,在一个电商订单处理系统中,开发人员发现部分代码在每次处理订单时都会创建大量临时对象,且这些对象在使用后未及时释放,导致内存占用不断攀升。通过优化代码,将这些临时对象的创建和使用进行合理管理,有效地减少了内存消耗,降低了垃圾回收的频率。
监控先行:通过jstat、jmap、arthas等工具分析 GC 日志和堆快照,了解 JVM 的运行状态。在一个高并发的 Web 应用中,使用jstat工具定时查看 GC 的频率和耗时,发现年轻代 GC 频繁发生,且每次耗时较长。进一步使用arthas工具进行深入分析,发现是某些业务模块中对象创建过于频繁,导致年轻代空间快速被填满,从而频繁触发 GC。
参数迭代:逐步调整内存分配和 GC 策略,对比优化前后的指标变化。每次只调整一个参数,这样可以清晰地了解每个参数调整对系统性能的影响。以一个大数据处理任务为例,首先尝试调整堆内存大小参数-Xms和-Xmx,观察任务的执行时间和内存使用情况;然后再调整垃圾回收器相关参数,如选择不同的垃圾回收器或调整垃圾回收器的参数,对比每次调整后的性能指标,从而找到最适合的参数配置。
二、经典案例分析与解决方案
案例 1:脚本引擎引发的 Metaspace 内存溢出
在一个金融风险评估系统中,系统突然发生 Full GC,并且 Metaspace 区域的占用持续增长。重启后,问题虽然暂时得到缓解,但没过几天又周期性地复现,频繁的 Full GC 触发了告警。
为了定位问题,首先开启 GC 日志,添加参数-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintHeapAtGC -XX:+PrintReferenceGC -XX:+PrintGCApplicationStoppedTime -Xloggc:/path/to/gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=14 -XX:GCLogFileSize=100M。通过分析 GC 日志,发现 Full GC 发生在 Metaspace 区 ,这表明可能存在类的元数据不断增加的问题。通常情况下,Metaspace 在系统稳定运行一段时间后占用空间应该比较稳定,而现在出现频繁 Full GC,说明在不断地加载类。进一步观察发现,在 Full GC 期间有大量类被卸载,这暗示着可能在频繁地生成大量 “一次性” 的类。
接着进行堆 dump 分析,使用jmap -dump:format=b,file=/tmp/heapDump.hprof [pid]命令生成堆快照 ,然后通过 VisualVM 工具导入快照进行分析。结果发现存在大量的匿名 Script 类,追踪这些类的引用,发现来自于 Lhs 类。查看 Lhs 类的代码,发现其中使用了 Aviator 脚本引擎进行表达式编译,每次编译都会生成新的匿名类,而这些类会占用 JVM 的 Metaspace 区域。经过查询 Aviator 官网文档,确认这是一个已知的内存溢出问题。
问题的根源在于 Aviator 脚本引擎在编译表达式时,默认每次都生成新类,且未启用缓存模式。解决方法是在编译时使用缓存模式,将AviatorEvaluator.compile(this.expression)改为AviatorEvaluator.compile(this.expression, true) 。重新发布系统后,Metaspace 的占用趋于稳定,Full GC 的问题得到解决。
案例 2:Kafka 消费不均衡导致 CPU 负载飙升
某广告投放服务突然出现大量 CPU 负载过高的告警,尤其是在服务发布时,告警更加频繁。这严重影响了广告投放的实时性和稳定性,可能导致广告展示延迟,进而影响广告主的投放效果和业务收益。
为了找出问题所在,使用 arthas 工具进行线程分析。arthas 提供了丰富的命令,如thread、jvm、memory等,通过thread命令查看线程信息,快速定位到是mkt_ta_track_topic.sub消费线程占用了大量的 CPU 资源。进一步分析发现,主要存在两个问题:一是ta埋点消息量很大,但当前只是做了简单的判断和日志打印,没有其他复杂业务逻辑,却使用了多线程消费,导致消费速度很快,几乎相当于空循环,这使得 CPU 资源被大量浪费;二是mkt_ta_track_topic当时申请了 8 个分区,而只有 5 台机器,这就意味着部分机器可能需要消费多个分区,从而导致 CPU 占用更加严重。此外,服务发布时会导致 Kafka 分区重分配,进一步加剧了负载过高的问题。
针对这些问题,采取了以下解决方案:一是调整线程池,将多线程消费改为单线程消费,这样可以避免空循环对 CPU资源的浪费。不过考虑到后续业务可能会有正常处理需求,后续可根据实际情况再做调整;二是申请扩展机器,新增 3 台服务。之所以选择扩容机器而不是调整分区,是因为当前服务负载本来就很高,扩容不仅可以解决当前分区分配不均的问题,还能提升系统整体的处理能力。扩容之后,资源利用率依然有75.97%,说明系统还有一定的扩展空间。通过这些措施,CPU 负载过高的问题得到了有效解决,广告投放服务的稳定性和性能得到了显著提升。
案例 3:Full GC 停顿过长引发服务超时
新接手一个服务后,上游反馈偶尔调用超时,超时时间为 1000ms。与上游确认超时发生的时间点后,查看当时系统的运行情况,发现超时时间与 Full GC 的发生时间重合,且 Full GC 的停顿时间超过了 1000ms。这表明 Full GC 停顿过长是导致上游调用超时的主要原因。
为了深入分析问题,首先进行 GC 日志分析。开启 GC 日志打印参数,获取详细的 GC 日志。通过分析日志发现,Full GC 耗时长达 1.2 秒,远远超过了业务允许的阈值。进一步查看垃圾收集器的配置,发现 JDK 使用的是 JDK8,默认垃圾收集器为 Parallel Scavenge 收集器。该收集器的设计目标是追求高吞吐量,主要关注的是整体的运行效率,而不太关心停顿时间。在高并发场景下,较长的停顿时间会导致服务响应变慢,从而引发超时问题。
针对这种情况,解决方案是更换垃圾收集器,选择以获取最短回收停顿时间为目标的 CMS
收集器。通过添加参数-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly来启用 CMS 收集器。其中,-XX:+UseConcMarkSweepGC用于指定使用 CMS
收集器;-XX:CMSInitiatingOccupancyFraction=80表示当老年代的内存占用率达到 80% 时,开始触发 CMS
收集器进行垃圾回收;-XX:+UseCMSInitiatingOccupancyOnly则确保只根据设定的占用率来触发垃圾回收,而不是采用其他默认的触发方式。更换收集器后,Full
GC 的停顿时间明显缩短,服务超时问题得到了解决。
三、JVM 调优实用技巧
- 内存分配策略
堆大小:通过-Xms和-Xmx设置相同值,避免动态扩展。在一个高并发的电商订单处理系统中,由于业务量波动较大,一开始没有将-Xms和-Xmx设置为相同值,导致 JVM 在运行过程中频繁调整堆内存大小。每次调整堆内存时,JVM 都需要向操作系统申请额外的内存,这不仅增加了系统的开销,还导致了垃圾回收频率的增加。后来将-Xms和-Xmx设置为相同值,系统的性能得到了显著提升,垃圾回收的频率也明显降低。
新生代与老年代比例:-XX:NewRatio=2(新生代占 1/3)。在一个实时数据分析系统中,大量的数据处理任务会产生大量的短期存活对象。如果新生代过小,这些对象很快就会填满新生代,导致频繁的 Minor GC,影响系统的性能。通过设置-XX:NewRatio=2,适当增大了新生代的比例,使得短期存活对象能够在新生代中得到及时的回收,减少了 Full GC 的发生频率,提高了系统的响应速度。
Survivor 区优化:-XX:SurvivorRatio=8(Eden:Survivor=8:1)。在一个基于 Spring Boot 开发的 Web 应用中,由于对象的创建和销毁比较频繁,Survivor 区的合理配置显得尤为重要。如果 Survivor 区过小,对象可能会过早地晋升到老年代,增加老年代的压力。通过设置-XX:SurvivorRatio=8,确保了 Eden 区和 Survivor 区的比例合理,使得对象能够在新生代中经历多次 Minor GC 后再晋升到老年代,减少了老年代的垃圾回收压力。 - 垃圾收集器选择
场景
推荐收集器
参数配置
单核 CPU,低延迟
Serial
-XX:+UseSerialGC
多核 CPU,高吞吐量
Parallel Scavenge
-XX:+UseParallelGC -XX:MaxGCPauseMillis :设置最大 GC 停顿时间 -XX:GCTimeRatio :设置 GC 时间与应用运行时间的比例
多核 CPU,低停顿
CMS/G1
CMS: -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction :设置老年代占用多少比例时开始 CMS 收集 -XX:+UseCMSInitiatingOccupancyOnly :确保只根据设定的占用率来触发垃圾回收 G1: -XX:+UseG1GC -XX:MaxGCPauseMillis :设置目标最大停顿时间 -XX:G1NewSizePercent :设置新生代最小占比
在一个单核 CPU 的小型服务器上运行的 Java 应用,主要用于处理一些简单的业务逻辑,对延迟要求较高。由于单核 CPU 的性能有限,使用 Serial 收集器可以充分利用单核 CPU 的性能,并且它的停顿时间相对较短,能够满足应用对低延迟的要求。
在一个大数据处理集群中,每个节点都配备了多核 CPU,集群主要负责处理海量的数据计算任务,对吞吐量要求较高。Parallel Scavenge 收集器可以充分利用多核 CPU 的计算能力,通过设置-XX:MaxGCPauseMillis和-XX:GCTimeRatio等参数,可以有效地控制垃圾回收的停顿时间和吞吐量,使得集群能够高效地处理大量的数据。
在一个互联网电商平台的后端服务中,运行着多个高并发的业务模块,对响应时间要求极高,不允许出现长时间的停顿。使用 CMS 收集器可以在垃圾回收过程中尽量减少对用户线程的影响,通过合理设置-XX:CMSInitiatingOccupancyFraction和-XX:+UseCMSInitiatingOccupancyOnly等参数,可以避免 Concurrent Mode Failure 等问题的发生,保证系统的低停顿和高可用性。对于一些堆内存较大、对停顿时间要求非常严格的应用场景,G1 收集器也是一个不错的选择,它通过将堆内存划分为多个区域,实现了更细粒度的垃圾回收控制,能够在有限的时间内获得最大的回收效率。
3. 避免内存泄漏
谨慎使用缓存:对本地缓存设置大小限制和淘汰策略(如 LRU)。在一个基于 Spring 框架开发的企业级应用中,使用了本地缓存来提高数据的访问速度。但是,如果不设置缓存的大小限制和淘汰策略,随着数据的不断增加,缓存可能会占用大量的内存,导致内存泄漏。通过使用 Guava Cache,并设置大小限制和 LRU 淘汰策略,当缓存达到一定大小时,会自动淘汰最近最少使用的数据,有效地避免了内存泄漏的问题。
关闭显式 GC:添加-XX:+DisableExplicitGC禁止System.gc()触发 Full GC。在一个多线程的并发应用中,有些开发人员可能会在代码中误调用System.gc(),这可能会导致不必要的 Full GC,影响系统的性能。通过添加-XX:+DisableExplicitGC参数,禁止了System.gc()的调用,避免了因误调用而引发的 Full GC,提高了系统的稳定性和性能。
监控堆外内存:通过jstat -gccapacity观察DirectByteBuffer使用情况。在一个使用了 Netty 框架进行网络通信的应用中,Netty 会使用DirectByteBuffer来进行数据的读写操作。如果DirectByteBuffer使用不当,可能会导致堆外内存泄漏。通过使用jstat -gccapacity命令,可以实时观察DirectByteBuffer的使用情况,及时发现和解决堆外内存泄漏的问题。例如,当发现DirectByteBuffer的使用量持续增加且没有释放时,就需要检查代码中是否存在DirectByteBuffer未正确释放的情况 。
四、调优工具推荐
JDK 自带工具
jconsole:从 Java 5 开始引入,是一个基于 JMX 的 GUI 性能监控工具 。它可以实时监控内存、线程、类加载等情况,几乎不消耗性能,因为其自身占用服务器的内存非常小。使用时,既可以通过 cmd 命令直接打开,也能到 JDK 的 bin 目录下双击 jconsole.exe 启动。启动后,可连接本地或远程的 JVM 实例。连接成功后,在主界面的 “内存” 页面能查看堆内存、非堆内存的使用情况以及 GC 的相关信息;“线程” 页面可展示当前 Java 程序中线程的使用信息,还能点击 “检测死锁” 来检查线程之间是否存在死锁情况;“类” 页面则显示了当前 Java 程序中关于类加载的信息。例如,在一个大型电商系统的开发和测试过程中,开发人员使用 jconsole 连接到运行中的应用程序,通过观察 “内存” 页面,发现堆内存的使用率在某些业务操作后急剧上升,且长时间没有下降,进一步查看 GC 信息,发现频繁的 Full GC 并没有有效释放内存,从而判断可能存在内存泄漏问题,为后续的问题排查提供了方向。
jmap -histo:用于分析堆中对象的分布情况。通过jmap -histo:live [pid]命令,可以查看堆中各个类的实例数量和占用空间大小。在一个基于 Spring Cloud 的微服务架构中,某个微服务出现内存占用过高的问题。运维人员使用jmap -histo:live命令获取该微服务进程的堆中对象信息,发现某一个自定义的业务对象实例数量异常多,占用了大量的内存空间。进一步检查代码,发现是在一个循环中错误地创建了该对象,而没有进行有效的复用和释放,从而导致内存占用过高。
jstack:能够生成虚拟机当前时刻的线程快照,主要用于诊断线程死锁或阻塞等问题。当线程出现长时间停顿,比如发生死锁、死循环或者请求外部资源导致长时间等待时,通过jstack [pid]命令查看各个线程的调用堆栈,就可以了解没有响应的线程正在执行的方法以及等待的资源。例如,在一个多线程的文件处理系统中,部分文件处理任务出现长时间卡顿的情况。开发人员使用jstack命令获取线程快照,分析发现是两个线程在访问共享资源时发生了死锁,通过调整代码中资源的访问顺序和加锁机制,解决了死锁问题,恢复了系统的正常运行。
第三方工具
Arthas:由 Alibaba 开源,是一款强大的 Java 诊断工具,支持 JDK 6+,兼容 Linux/Mac/Windows 系统 。它采用命令行交互模式,并提供丰富的 Tab 自动补全功能,方便用户进行问题的定位和诊断。通过thread命令,不仅可以查看线程的详细情况,还能通过thread -b命令快速定位线程死锁问题;使用jad命令可以反编译类,方便查看线上代码版本是否正确;ognl命令则能查看和修改线上系统变量的值。在一个高并发的分布式系统中,某个服务的 CPU 使用率突然飙升。运维人员使用 Arthas 的thread命令,通过thread -n 3参数查看 CPU 占用率最高的前 3 个线程,发现是一个业务处理线程占用了大量 CPU 资源。进一步使用jad命令反编译该线程对应的类,发现是一段复杂的业务逻辑中存在算法效率低下的问题,经过优化算法,CPU 使用率恢复正常。
MAT(Memory Analyzer Tool):这是一个基于 Eclipse 的独立内存分析工具,能快速分析 Java 堆转储快照,帮助开发人员定位内存泄漏和内存浪费问题。当系统发生 OOM(OutOfMemoryError)时,如果配置了-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/…这两个 JVM 参数 ,JVM 会在崩溃前生成一份 dump 快照。使用 MAT 打开该文件后,通过 “Overview” 可以查看整体的内存使用情况,如哪个线程或对象占用了大量内存;“Dominator Tree”(支配树)能直观地反映一个对象本身及它持有引用的对象所占的内存大小和比例,通过 “Shallow Heap”(对象本身所占的内存大小)和 “Retained Heap”(对象的 retained set 所包含对象所占内存的总大小)来分析内存占用情况;“Leak Suspects”(泄漏疑点)功能更是强大,它可以自动分析泄漏的原因,并生成详细的分析报告。在一个大型企业级应用中,系统偶尔会出现 OOM 问题。运维人员在 OOM 发生后,获取到 dump 文件,使用 MAT 进行分析。通过 “Leak Suspects” 功能,快速定位到是一个缓存模块中的对象没有正确释放,导致内存泄漏,经过修复缓存模块的代码,解决了 OOM 问题。