1. 先从JVM内存模型说起1.1 堆内存划分与对象的一生JVM调优这件事大多数人的第一反应是调参数比如堆内存大小、垃圾回收器类型。但真正调过几年之后你会发现参数只是最后一步第一步永远是搞清楚内存里面到底装了什么。堆内存是JVM管理的最主要区域几乎所有对象实例都在这块区域分配。堆内部又分成新生代和老年代两块逻辑区域新生代里还包含Eden区、S0幸存区、S1幸存区。对象的一生大致是新对象先在Eden区分配Eden区满了触发Minor GC存活下来的对象进入S0区下一次Minor GC时S0和S1之间互相复制每经过一次回收且年龄达到阈值默认15岁对象晋升到老年代。很多人学到这里会背但放到实际调优中到底有什么用我举一个真实的例子某个服务启动参数里把-Xmn调得特别大新生代占了堆的3/4结果Minor GC次数是少了但老年代空间被压缩大对象不断涌入老年代反而频繁触发Full GC。这就是典型的没有理解“对象的一生”而乱调参数的后果。1.2 方法区、虚拟机栈、本地方法栈除堆以外还有几个区域在调优时同样需要关注。方法区存放类元信息、常量、静态变量等JDK 8之后改名为元空间直接使用本地内存。常量池和静态变量在极端情况下也会成为内存泄漏的来源尤其是自定义类加载器反复加载类很容易让元空间占满。虚拟机栈是线程私有的每个线程在创建时会分配栈空间默认大小取决于平台通常512KB到1MB。栈里存的是栈帧每个方法调用对应一个栈帧。如果递归调用过深或无限递归会抛出StackOverflowError。这里调优的关键是线程数量与栈大小的平衡——栈越大能开的线程越少线程数多的服务比如网关、长连接服务通常需要调小单线程栈大小。本地方法栈服务于native方法平时排查问题很少直接操作但要知道它的存在。如果用了JNI、Netty等框架本地内存泄漏往往会在操作系统层面表现出来进程占用的内存很高但堆内存快照却是正常的这点在后面的案例里会提到。1.3 JDK版本不同内存模型还不太一样JVM内存模型并不是一成不变的。JDK 7的永久代PermGen在JDK 8中被元空间Metaspace取代字符串常量池从永久代挪到了堆中。JDK 11开始默认垃圾回收器变成了G1JDK 17继续默认G1。这些变化对调优影响很大比如网上搜到的老调优帖子说“永久代太高了要调-XX:PermSize”在JDK 8以后这个参数根本无法识别启动了直接报错。所以看任何调优文章、面试题、参数表格第一件事就是确认它说的JDK版本。不同版本的JVM默认参数、日志格式、工具命令都有差异。我自己写调优笔记的时候都会在参数后面标注适用的JDK版本避免自己以后踩坑。如果你还停留在“只用JDK 8跑生产”的阶段强烈建议至少在测试环境体验一下JDK 11或17感受一下默认G1的行为差异。2. 调优工具JDK自带的那几个就够了2.1 jps先确认自己在看哪个进程如果你连目标进程的PID是几都不确定后面所有分析无从谈起。jps -l列出当前机器上所有JVM进程及主类信息jps -v还能看到进程启动时的JVM参数。这个命令简单到没人愿意写文章讲但实际工作中非常实用。一台机器上部署了多个Java应用通过jps -l能快速区分每个进程避免用top看到PID后还要反查是什么进程的尴尬。集群管理工具确实能帮你列出所有节点的信息但单机排查时jps永远是第一步。2.2 jstat观察GC频率和堆占用jstat是定位GC问题的第一利器。最常用的命令是jstat -gcutil pid 1000 10每1000毫秒输出一次共输出10次查看各区域的使用率和GC累计时间。输出列的S0、S1、E、O分别代表幸存区、Eden区、老年代的使用百分比M是元空间使用率。YGC表示Minor GC次数YGCT是累积耗时FGC和FGCT同理。这个命令看什么很多人只看当前FGC数字但更重要的是观察趋势。比如你看到了FGC次数一直在涨从10次变成50次再变成100次说明老年代空间持续被占满每次Full GC后都回收不掉多少对象基本就可以断定存在内存泄漏或对象堆积。如果FGC数字保持不变只是YGC很频繁说明新生代空间偏小或者Eden区存活对象太多导致过早晋升。2.3 jmap堆内存快照jmap有两个高频用法。一是jmap -histo pid查看堆中对象的实例数和占用大小排行能快速定位哪些对象占据大量内存。二是jmap -dump:formatb,fileheap.hprof pid导出完整堆快照用于后续MAT或VisualVM分析。生产环境执行jmap -dump要慎重因为导出快照会暂停JVM老版本影响更大而且大堆文件可能占掉几个G磁盘空间。我的习惯是先用jmap -histo:live看存活对象的分布情况确认问题存在后再决定是否dump完整快照。另外-histo:live会触发一次Full GC这个副作用在高峰期尤其要谨慎。如果只是排查大对象优先用jmap -histo不带live它不触发GC虽然数量不是精确的存活对象但足够看出哪些类型对象过多。2.4 jstack线程栈快照JVM调优从来不只是调内存CPU飙高、接口无响应、死锁都跟线程状态有关。jstack pid thread.log可以把所有线程的当前栈信息导出然后结合top或top -Hp pid找到CPU占用最高的线程号换算成十六进制后在栈日志中搜索。换算方法很简单printf %x\n 线程号得到的十六进制值就是这个线程的nid。在jstack输出中找到对应的线程栈基本定位到了哪段代码在消耗CPU。这个操作我在排查“Idea占用CPU过高”类似问题时反复使用虽然IDE场景不是生产服务器但排查思路完全一致。2.5 jconsole与VisualVM图形化观察如果不习惯命令行jconsole和jvisualvm提供了图形化界面能实时查看堆内存、线程、GC活动。生产环境通常不开JMX远程端口所以我更多是拿它们来分析本地加载的堆快照文件尤其是VisualVM的堆dump查看功能能辅助MAT完成初步分析。VisualVM的插件生态里有个GC监控插件能看到图形化的GC时间线。不过我承认现在用图形化工具的时间越来越少因为线上环境大多无法直连JMX端口。我更依赖命令行工具组合这也是所有线上问题排查的基础能力。图形化工具适合学习期理解JVM运行状态真正到生产环境还是命令行的天下。2.6 Arthas在线排查的利器阿里开源的Arthas是近几年Java排查工具里最值得学的。dashboard命令一键展示线程、内存、GC等信息thread -n 3直接列出CPU占用最高的线程省掉了top换算十六进制的步骤。jad反编译线上类sc、watch、trace能在线追踪方法调用参数、返回值和耗时。我最常用的场景有两个一个是接口响应慢用trace观察方法内部每个子调用的耗时分布定位瓶颈在数据库还是在远程调用。另一个是线上代码和本地代码不一致时用jad反编译确认线上部署的真实字节码。这些功能在传统工具链里基本做不到Arthas的存在让线上排查效率提升了几个量级。3. 垃圾回收器选型与核心参数配置3.1 从Parallel GC到G1再到ZGCJVM的垃圾回收器发展脉络本质上是“吞吐量优先”向“低延迟优先”演进的趋势。Parallel GC是JDK 8默认垃圾回收器追求高吞吐量适合后台计算、离线和批量处理场景。CMS追求低停顿但会碎片化已经在JDK 14中被移除。G1把堆划分为多个Region兼顾吞吐量和停顿时间从JDK 9开始成为默认选择。ZGC面向超大堆和超低停顿场景停顿时间几乎不随堆大小变化。选型建议很简单。存量JDK 8服务且没有明显GC问题保持默认Parallel GC。新服务直接用G1。如果堆达到几十GB且对延迟极其敏感可以考虑升级JDK并尝试ZGC。不要为了“新”而换回收器回收器没有绝对优劣只有适不适合当前场景。3.2 我常用的几个核心参数以JDK 8Spring Boot服务为例我经常在Dockerfile或启动脚本里看到这样的参数组合java -Xms4g -Xmx4g \ -Xmn1536m \ -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heap.hprof \ -jar app.jar逐个解释为什么这么配。-Xms和-Xmx设为相同值避免JVM运行期动态扩容堆大小带来的性能抖动。-Xmn控制新生代大小设置过小导致对象频繁晋升老年代设置过大会压缩老年代空间这个值需要结合GC日志观察后微调。-XX:MaxGCPauseMillis是给G1设定的停顿目标G1会根据这个目标动态调整各Region的回收策略但别指望设成50ms就一定能做到实际效果受对象分配速率和堆大小制约。HeapDumpOnOutOfMemoryError这个参数强烈建议所有Java服务都加上虽然不是在参数层面直接改善性能但OOM发生留一份现场堆快照配合日志文件可以复现问题。坑点是默认的dump路径可能是进程启动目录容器化部署时该目录可能没有写权限或重启后丢失所以必须明确指定一个持久化路径。3.3 GC日志调优的依据没有GC日志所有的参数调整都是盲调。JDK 8系列用这些参数开启GC日志-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.logJDK 9以后参数格式统一调整为-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags查看GC日志时重点信息包括每次GC的停顿时间real字段、GC前后各区域容量变化、晋升对象大小、Full GC发生原因。G1的日志里出现To-space exhausted或者Humongous Allocation相关字样时说明大对象分配异常频繁。很多文章教你看GC日志会列出一堆字段但实战中更重要是持续监控趋势。我一般会把GC日志接入到日志平台或定时任务中做简单统计看每天GC次数、平均停顿时间的走势。调优不是一锤子买卖是在稳定监控基础上的持续微调。3.4 一个4C8G服务怎么配给你一个可直接抄作业的起始模板。一个4核8G的Spring Boot订单服务部署在K8s里配置了limits: memory: 4Gi的容器限制JVM参数如下-Xms3g -Xmx3g -XX:MaxMetaspaceSize256m -Xss512k -XX:UseG1GC -XX:MaxGCPauseMillis100为什么堆只给3G而不是4G因为容器内存还要留给元空间、线程栈、JIT编译产物、堆外网络缓冲区。8G的宿主机上多个Pod部署时不能满打满算。-Xss512k是因为该服务是API接口类型线程数量比较多每个线程栈减少一半能支撑更多并发线程。这些数字并非一成不变而是基于该服务的QPS、对象分配速率和GC日志逐步调整出来的。4. 实战案例一个订单服务的Full GC排查全过程4.1 症状描述和初步迹象有一次线上告警订单服务从13:20开始接口P99耗时从80ms飙升到2秒以上紧接着收到Full GC告警。我先看了监控大盘老年代内存占用曲线在13:20前后从正常水位一路爬升不降反升。这个曲线特征一出来我心里就已经有八分把握要么是老年代一直被静态集合持有要么是大量对象无法回收典型的对象堆积。连接服务器jps -l确认进程PID然后执行jstat -gcutil pid 1000 5。输出的FGC数字每次刷新都在增加从132涨到137再到141同时O区使用率保持在98%以上。这说明Full GC之后老年代仍然几乎不释放空间内存已经处于“回收即满”的恶性循环。4.2 用jmap和MAT定位到具体的对象接下来用jmap -histo pid | head -30排在前面的是一条一条的业务实体对象其中有个订单明细对象占了2/3的堆空间明显异常。为了看引用关系导出堆快照jmap -dump:formatb,file/data/logs/heap.hprof pid快照文件大约4.8G用MAT打开后看Dominator Tree占据内存最大的对象是一个ArrayList里面塞了几十万个订单明细对象。持有这个ArrayList的是一个任务调度类的静态字段。4.3 根因一次不设条件的批量查询任务调度类里有一个每日定时任务本意是批量处理前一天的订单数据做统计。但某次发布时SQL的WHERE条件里时间范围因为时区换算问题没有生效变成了全表查询。几十万订单数据一次查出来全部加载进内存再逐条处理写回库。正常情况下这笔数据量不至于直接打爆4G堆但这个任务还嵌套了一个大数据量的去重集合内存占用被进一步放大。4.4 解决方案和效果根因修复当然是改SQL让时间条件恢复正常并且增加了分批查询逻辑每次只查5000条。JVM参数层面我也做了调整-Xmx从3G调到4G宿主机升级过给大对象多一点缓冲。最终效果是Full GC从一天几百次降到个位数接口P99恢复正常。复盘这个案例我想强调JVM调优真正的功力不是把参数背得多熟而是能通过工具链快速定位“内存被谁占了”“为什么一直不释放”。绝大多数所谓“JVM性能问题”根子都在业务代码或SQL层面JVM参数只是最后一道防线。5. 高频调优面试题把实战经验变成答案5.1 什么是Full GC如何降低Full GC频率Full GC是指对整个堆新生代老年代元空间进行回收通常伴随较长的停顿。触发条件包括老年代空间不足、元空间不足、调用System.gc()等。降低Full GC频率的核心思路是减少对象在新生代的分配、避免大对象直接进入老年代、及时释放无用引用。如果你发现Full GC很频繁第一反应不应该是调整堆大小而是用jmap -histo和MAT找出谁占满了老年代。5.2 如何判断对象可以被回收主流做法是可达性分析算法。从GC Roots出发凡是引用链能到达的对象都判定为存活不可达的对象可以被回收。GC Roots包括栈帧中的本地变量、静态变量、活跃线程、JNI引用等。引用计数算法虽然实现简单但无法解决循环引用问题主流JVM都没有采用。5.3 生产环境CPU飙高第一步做什么很多人回答“看日志”这其实绕远了。第一步是top -Hp pid找到CPU占用最高的线程号再换算成十六进制用jstack导出线程栈定位对应的业务代码。如果你想给面试官留下更深的印象可以补一句“线上环境我会直接用Arthas的thread -n 3一步到位看线程栈效率远高于手算十六进制”。5.4 CMS和G1有什么区别CMS是基于标记清除算法目标是低停顿但会产生内存碎片且并发阶段占用CPU资源。G1是把堆划分为多个大小相同的Region通过维护每个Region的回收价值和回收成本优先回收回收价值大的Region目标是在可预期的停顿时间内完成回收且能处理超大堆场景。简单说CMS是“老年代收集器的改进版”G1是“面向全堆的局部回收器”。JDK 9之后CMS被废弃G1成为默认这是主流答案的大方向。5.5 OOM有哪几种类型堆溢出是最常见的java.lang.OutOfMemoryError: Java heap space元空间溢出是Metaspace栈溢出通常是StackOverflowError不算OOM但经常一起被问还有直接内存溢出Direct buffer memory。每种类型对应不同的排查方向堆溢出用jmapMAT元空间溢出重点检查类加载器直接内存溢出重点检查Netty等NIO框架。能把这几个类型分清楚再各配一个真实场景案例面试官通常就很满意了。回到调优本身我想说的是JVM调优这行入门容易精深难。工具链和参数表背两天就能用真正拉开差距的是遇到问题时能不能顺着“内存谁在用、GC为什么触发、停顿为什么长”这条线一步步找到业务代码层面的根因。把这套思路练熟不管换到哪个项目、哪个框架、甚至哪个版本JDK调优都不会是无头苍蝇。
