你开了一家餐厅。生意越来越好,客人排队排到门口。你还没来得及高兴,后厨先崩了:备餐台堆满食材,冰箱塞到关不上门,厨师在过道里互相撞,出餐速度肉眼可见地变慢。你咬牙租了更大的厨房、买了更大的冰柜,结果问题只好了一点点,成本却翻了一倍。JVM调优这件事,说穿了就是在Java的“后厨”里解决同样的内存管理问题。
Java应用跑着跑着变慢、卡顿,甚至直接OutOfMemoryError挂掉,绝大多数时候不是CPU不够用,也不是代码逻辑算不过来,而是内存——更准确地说,是JVM管理的堆内内存出了问题。这篇文章我会用经营餐厅的思路,把JVM的内存区域、核心参数、GC日志、常见故障排查完整过一遍,适合刚接触JVM调优的新手,也适合那些被线上OOM折腾过、想系统补一下内存知识的开发同学。里面用到的命令和参数都是我在生产环境实打实验证过的,你可以直接照着操作。
1. 后厨的地图:先搞懂JVM的内存区域到底长什么样
1.1 堆内存:餐厅的中央后厨
JVM的堆(Heap)是所有线程共享的一块内存区域,存放几乎所有的对象实例。用餐厅打比方,堆就是中央后厨,里面堆满了食材、半成品、成品。堆又分成三块,对应后厨里三个不同功能的区域。
新生代(Young Generation)是食材的临时堆放区,几乎所有新对象都在这诞生。里面又细分成Eden区和两个Survivor区(S0、S1)。Eden区可以理解成进货口旁边的备餐台,刚买回来的菜、正在处理的菜都堆在这;Survivor区是两个半成品暂存柜,厨师把切好洗净、暂时用不到的食材倒进去存着,而且两个柜子轮流用,每次清理时把其中一个的存活食材倒到另一个里。
老年代(Old Generation)是冷藏库和干货仓库。存活时间足够长的对象会被搬到这里,这里的东西不会天天清,空间大、清理成本高。堆整体的比例,默认情况下新生代只有老年代的一半大小,这对大部分业务场景偏小,后面讲参数时会细说。
1.2 虚拟机栈:厨师的个人操作台
栈是线程私有的,每个线程都有自己的虚拟机栈,里面保存着方法调用过程中的局部变量、操作数栈、方法返回值等。你可以把每个栈帧当成厨师面前的一张操作台,调用一个方法就铺开一张台面,方法执行完就把台面收走,干干净净。
栈的特点是生命周期非常明确:方法进入,栈帧入栈;方法退出,栈帧出栈。不需要垃圾回收器操心。栈最典型的故障是StackOverflowError,就是一张台面被无限嵌套的操作步骤铺满了,比如递归没有出口。还有个容易忽略的点——栈深度是可以在启动参数里调的,但默认1000多层的深度对绝大多数业务已经够用,真调大栈深度往往意味着代码设计有问题。
1.3 元空间:后厨的菜谱档案室
JDK 8之后,方法区被元空间(Metaspace)取代,存放类的元数据、方法信息、静态变量等。这些信息的特点是“一次性加载、长期使用、不随业务波动频繁变化”,就像厨房墙上的菜谱和操作SOP——今天炒什么菜、怎么颠勺,都在上面写着,不需要每天清理。
元空间跟堆是分开的,默认不设上限,用的是本地内存(也就是操作系统给进程的内存)而非JVM堆内存。这个设计初衷是避免类元数据导致的OOM,但也带来一个坑:如果你用CGLIB、ASM动态生成大量代理类,或者做热部署,元空间会悄悄涨上去,最终把整台服务器的内存吃光。
1.4 程序计数器和本地方法栈:容易忽略的小角落
还有两个占比很小的区域。程序计数器(PC Register)就是记录当前线程执行到哪一条字节码指令,相当于厨师手里的菜单页签,翻到哪页做哪道菜。本地方法栈则是给native方法用的,比如JNI调用C/C++代码时走的就是这条道。这两个区域几乎不参与调优讨论,但面试时经常被问到,知道它们是“记录运行位置”和“给本地方法用的”就够了。
看完这张地图你应该明白了,JVM调优90%的工作量都发生在堆上,尤其是新生代和老年代的配比与回收策略。后厨管理的核心问题就是:东西放哪、放多少、什么时候清理、怎么清理,全都有讲究。
2. 定后厨的“仓位”:核心参数这样设才不浪费
2.1 -Xms和-Xmx:厨房面积必须一次定死
-Xms是JVM启动时分配的初始堆大小,-Xmx是堆的最大上限。很多新手只设置-Xmx,觉得“反正最大能扩到这个数就行”。这个想法在开发环境无所谓,在生产环境是灾难。
JVM在运行过程中如果发现堆不够用,会尝试向操作系统申请更多内存,把堆从当前容量扩大到上限。这个扩容动作需要触发Full GC来重新整理堆布局,而Full GC本身就要暂停业务线程。当你的堆在一段时间内反复“不够了—扩容—稳了—又不够了—再扩容”时,应用的响应时间就会像过山车一样忽高忽低。
我的建议很简单:生产环境-Xms和-Xmx设成一样,比如-Xms8g -Xmx8g。换句话说,项目一开始就把厨房租下来,不要等到客人坐满了才想起和物业租新场地。这样做的代价是进程启动时占用的内存会多一些,但换来的是运行期稳定,不折腾。省那点启动内存,远不如线上p99抖动带来的损失大。
2.2 新生代和老年代的配比:备餐区和仓库的博弈
-XX:NewRatio控制新生代和老年代的比例,默认值是2,意思是老年代是新生代的2倍。而-Xmn可以直接指定新生代大小,优先级更高。
怎么理解这个比例?你的业务如果是“创建即用完”的短命对象为主,比如大量临时DTO、查询结果封装、函数式中间对象,新生代就该给大一点,让这些对象在新生代就被回收掉,别往老年代挤。反过来,如果业务里有大量缓存类对象、连接池对象、常驻业务实例,老年代就得留足空间。
一个很常见的经验值:新生代占整个堆的1/3到1/2。注意不要盲目调大新生代——新生代越大,Young GC发生的频率虽然降低,但单次GC暂停时间会变长,而且留给老年代的空间变小,反而更容易触发Full GC。我遇到过最矫枉过正的案例,是把堆16G分了12G给新生代,结果老年代只剩4G,高峰期对象一晋升就直接Full GC,比调优之前还糟糕。
2.3 Survivor区和大对象阈值:半成品柜可不是摆设
有了新生代配比还不够,-XX:SurvivorRatio决定了Eden和单个Survivor区的比例,默认是8,也就是Eden:S0:S1 = 8:1:1。别看Survivor只有两个小柜子,它存在的意义是给对象一个“缓冲期”,让短命对象在Eden被回收掉,而不是直接进老年代。
对象晋升老年代的规则有三条:年龄达到-XX:MaxTenuringThreshold设定的值(默认15次Young GC);Survivor区装不下了,多出来的直接进老年代;超过-XX:PretenureSizeThreshold的大对象直接进老年代。前两条都比较好理解,第三条要注意一个坑:-XX:PretenureSizeThreshold只对Serial和ParNew收集器有效,对Parallel Scavenge(也就是-XX:+UseParallelGC)不生效。很多博客没提这茬,新手抄了配置发现没作用,还以为是自己参数写错了。
2.4 GC选型:你的后厨适合哪种清理模式
JDK 8默认是Parallel GC,JDK 9及以后默认换成G1。Parallel GC的哲学是“干活的时候所有人一起上,把清理效率堆到极致”,适合对吞吐量要求高、能容忍短暂停顿的后台批处理任务。G1的哲学则是“把后厨切成很多小格子,每次只清理最脏的几个格子”,通过-XX:MaxGCPauseMillis(默认200ms)控制单次停顿目标,适合对延迟敏感的在线服务。
我给新手的建议:如果你的服务是面向用户的在线接口,JDK 8以上优先用G1,把-XX:MaxGCPauseMillis设在150ms左右,然后根据监控慢慢调整;如果你的服务是离线任务、对吞吐量极其敏感,Parallel GC还是很好的选择。这里不要盲从,后厨清理方式没有绝对的优劣,只有适不适合当前业务。
3. 从“经营事故”反推问题:常见的挂掉方式和排查链路
3.1 Java heap space:厨房堆满了,先分清是“堆积”还是“泄漏”
看到java.lang.OutOfMemoryError: Java heap space,很多人的第一反应是“堆不够大,加内存”。在没有分析堆转储之前就加大-Xmx,这是最大的忌讳。堆内存满了只可能有两种原因:要么对象真的该回收但回收不掉,导致持续堆积,这叫内存泄漏;要么业务确实需要这么多内存,只是你的堆上限给得太低,这叫内存膨胀。
区分两者的方法只有一个:导出堆转储文件(Heap Dump)来看。操作链路是这样的。
先用jps找到Java进程ID,然后执行:
jmap -dump:format=b,file=heap.bin <pid>也可以直接在启动参数里加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heap.bin,让JVM在OOM发生的那一刻自动留档。拿到heap.bin后用MAT(Eclipse Memory Analyzer)打开,重点看Dominator Tree和Leak Suspects。Dominator Tree会按对象持有关系排序,你能一眼看到是哪个对象实例占了最多内存——是某个ConcurrentHashMap一直往里put不remove,还是某个线程池的队列无限堆积任务,管道的源头就浮出水面了。
3.2 Metaspace OOM:菜谱失控了
元空间OOM的报错是java.lang.OutOfMemoryError: Metaspace。常见诱因是动态生成类:CGLIB代理、反射生成大量代理类、热部署加载完class不卸载、滥用Class.forName等。
排查方式跟堆OOM类似,但侧重点不同。堆OOM看“谁占着内存不释放”,元空间OOM看“谁在疯狂生成新的类”。如果代码里用了动态代理框架,先怀疑缓存了Class对象的地方;如果做了热部署,检查自定义ClassLoader是否在每次部署时都新建一个且没有回收机制。临时止损手段是设置-XX:MaxMetaspaceSize,但根治还是要靠代码层面限制类生成的数量。
3.3 频繁Full GC:还没打烊就开始清仓
Full GC相比OOM更隐蔽,它不会直接让进程挂掉,但会让应用卡顿到几乎不可用。Full GC的特征是“整个堆都清理一遍,业务线程全部暂停”,专业术语叫Stop-The-World。如果你通过监控发现Full GC每几分钟就来一次,每次耗时几百毫秒甚至几秒,说明老年代已经不堪重负。
常见原因有几种:新生代设置太小,对象频繁晋升到老年代;大对象直接进老年代,把仓库占了一角;代码里持有大量不再使用的长生命周期对象;堆大小设置不合理。这里面最容易忽略的是“大对象直接进老年代”的场景——比如一次性加载了一整张几百万行的表到内存里做处理,这种对象就是老年代的“钉子户”,FGC根本奈何不了它。碰到这种代码,第一选择是改实现方式(流式处理、分批处理),第二才是调参数。
3.4 排查工具:jps、jstat、jmap、jvisualvm怎么配合
新手最容易犯的毛病是拿到一个问题就漫无目的地用工具,我推荐一套固定打法:
# 1. 找到进程 jps -l # 2. 每秒采样一次内存使用情况,观察Eden、Old区变化和GC频率 jstat -gcutil <pid> 1000 10 # 3. 需要分析对象分布时,导出堆转储 jmap -dump:format=b,file=heap.bin <pid> # 4. 用jvisualvm或MAT可视化分析jstat -gcutil的输出里,重点是E(Eden使用率)、O(老年代使用率)、YGC(Young GC次数)、FGC(Full GC次数)。如果E一直满了又降、降了又满,Young GC频率很高,说明新生代太小或Eden存活率高;如果O只涨不降,说明老年代堆积,距离Full GC越来越近。这套“先看数据,再下结论”的顺序,比拍脑袋调参数靠谱得多。
4. 看懂GC日志:后厨每天的“打烊盘点单”
4.1 开启GC日志的正确姿势
GC日志是调优最重要的客观依据。没有日志就做调优,等于不看温度计就判断发烧病人该吃什么药。
JDK 8及以前版本这样开启:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTimeJDK 9及以后的统一日志语法是:
-Xlog:gc*,gc+age=debug:file=/data/logs/gc.log:time,pid,level,tags日志文件一定要加-XX:+HeapDumpOnOutOfMemoryError一起使用,因为GC日志负责告诉你“发生了什么”,堆转储负责告诉你“为什么发生”,两者配合才完整。
还有一个细节:GC日志文件会不断增大,线上最好配合-XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M做滚动切割,别让日志把磁盘撑爆。
4.2 一段Young GC日志的逐行拆解
下面是一段典型的Young GC日志:
[GC (Allocation Failure) [PSYoungGen: 491520K->92160K(512000K)] 839020K->505560K(1515520K), 0.0345671 secs] [Times: user=0.05 sys=0.02, real=0.03 secs]看日志不要被一堆数字吓到,抓住四个关键片段。PSYoungGen: 491520K->92160K(512000K)表示新生代回收前用了491520K,回收后剩余92160K,新生代总容量是512000K——回收后从“快满了”降到“用了18%”,说明存活率不高,回收效果很好。后面括号里的839020K->505560K(1515520K)是整个堆的变化,回收前堆总使用839020K,回收后505560K,堆总容量1515520K。注意这个数字:整个堆只回收了约330M,但新生代就回收了约400M,说明回收过程中有部分对象晋升到了老年代。最后的0.0345671 secs表示GC耗时34毫秒,这个数字对在线服务来说是“可接受的”。
如果你看到Young GC日志里PSYoungGen回收后Eden区清零但Survivor区长期满的迹象,或者堆总回收量远小于新生代回收量,就要警惕对象晋升太快,这是老年代危机的早期信号。
4.3 Full GC日志:一眼看出危机在哪
当出现[Full GC (Ergonomics)或[Full GC (Allocation Failure)前缀时,说明JVM开始对整个堆做“清仓式”回收。典型的Full GC日志长这样:
[Full GC (Ergonomics) [PSYoungGen: 20480K->0K(512000K)] [ParOldGen: 1003520K->997889K(1003520K)] 1024000K->997889K(1515520K), [Metaspace: 68542K->68478K(69000K)], 0.6890456 secs]这一行信息量很大:新生代被清空了(20480K->0K),这是正常操作;但老年代从1003520K只降到997889K,几乎没释放什么空间,而且老年代总容量就是1003520K,也就是说老年代在回收前已经满了。整个Full GC耗时689毫秒,其中大部分时间都花在收效甚微的老年代清理上。这种日志就是典型的“老年代堆积”信号——堆里躺着大量无法被回收的长生命周期对象,只有dump堆转储才能看出是谁占着位置。
4.4 jstat:不翻日志也能看的实时内存仪表盘
线上很多环境不允许随便重启加日志参数,或者GC日志没开,这时候jstat是你的主力工具。
jstat -gcutil <pid> 1000每秒钟刷新一次,你会看到一列数据:S0、S1是Survivor区使用率,E是Eden使用率,O是老年代使用率,M是元空间使用率,YGC和FGC是累计GC次数,YGCT和FGCT是累计GC耗时。我判断“是否需要立即介入”的标准很简单:O超过80%且持续上升,同时FGC数量在短时间内快速增加,就说明老年代已经开始承压;如果FGC每次耗时都超过500ms,对在线业务已经是不可接受的了。
5. 真实案例:从频繁卡顿到稳定运行的一次完整调优
5.1 症状:QPS涨了两倍,接口就“跪了”
去年我接手过一个订单中心服务,8核16G的机器,业务高峰期QPS大概1500左右。上线新版本之后,运营做了一波活动,QPS冲到4000,随后监控显示接口p99从平时的80ms飙到1200ms,并且下游开始报超时。从监控面板看,老年代使用率在半小时内从40%涨到95%以上,Full GC每隔十来秒就来一次,单次要停600毫秒以上。整个服务处于“勉强活着但谁也不爽”的状态。
5.2 用jstat和堆转储定位真凶
第一步先用jstat -gcutil确认GC状态,采样结果大体是这样的趋势:
S0 S1 E O M YGC FGC FGCT GCT 0.00 8.75 96.50 89.78 70.12 48213 187 118.24 142.01Eden区一直维持在95%以上,说明新对象分配极快;老年代89%且FGC已经187次,每次平均600多毫秒,典型的老年代承压。第二步导堆转储,用MAT打开后,Dominator Tree里最显眼的是一个ConcurrentHashMap,持有几十万个订单详情结构对象,占了整个堆的70%。翻代码后发现,有一个“最近处理过的订单快照”缓存,key是订单号,value是整单详情,这个缓存只往里put,从没有清理逻辑。活动期间订单量暴增,Key数量狂涨,活活把老年代塞满了。
5.3 第一轮调整:参数续命
当时线上不能马上改代码发版,我先做了一步“抢救性”调参,把风险扛过去再说。主要调整如下:
| 参数 | 原配置 | 调整后 | 调整理由 |
|---|---|---|---|
| -Xms/-Xmx | 4g / 4g | 8g / 8g | 给老年代争取更多缓冲空间 |
| -XX:NewRatio | 2 | 1 | 提高新生代占比,缓解快速分配压力 |
| -XX:MaxGCPauseMillis | 无 | 150 | 切换G1后限制单次停顿 |
| -XX:InitiatingHeapOccupancyPercent | 默认45 | 35 | 提前触发并发标记,避免堆满了才动手 |
同时把GC收集器从Parallel GC换成G1。调整之后,Full GC频率从每十几秒一次降到每分钟一次,p99回落到250ms左右,服务稳住了。但我知道这是“吃止痛药”,不是治根。
5.4 第二轮修复:清掉根源问题
接下来推动开发改代码:给那个订单快照缓存加了TTL过期机制,超过30分钟的条目自动清理;同时限制缓存最大条数,满了之后按访问时间淘汰最老的。改完重新压测,QPS 4000的场景下堆内存趋近于一条平稳的曲线,Full GC几乎消失,G1的Mixed GC每次都在100ms以内。最终p99稳定在85ms左右。
这次调优给我的方法论就三个词:看数据、找根因、先续命后根治。参数调整永远只是给自己争取时间,真正的瓶颈如果出在代码上,再神的参数都救不了。
6. 新手最容易踩的坑:这些错误操作我见过太多次
6.1 无脑调大-Xmx
看到内存不够就堆上限翻倍,这是新手最常用的“调优”。问题在于,堆变大不等于内存够用,反而会让GC单次暂停时间更长——一个4G堆Full GC要300ms,16G堆可能就是1200ms。如果根子是内存泄漏,调大堆只是把爆炸时间往后拖了几天,该OOM还是OOM,而且每次卡顿更严重。调大堆的前提永远是:你已经通过堆转储确认了“对象没有泄漏,只是业务确实需要这么多空间”。
6.2 指望参数解决代码泄漏,逃避dump分析
有些同学一碰到GC异常,先把网上的“JVM调优参数大全”复制一遍,参数加了一堆,问题原封不动。内存调优里最核心的能力其实不是参数设置,而是分析堆转储的能力。MAT打开一个heap.bin,比翻一百篇调优博客都管用。我习惯的做法是:先让问题复现一次,拿到堆转储,分析出对象持有链,再决定是改代码还是改参数。没有堆转储数据的调优,都是盲人摸象。
6.3 只调优不监控,下次还会翻车
调优不是一锤子买卖。参数改完了,GC频率降下来了,就以为大功告成,这是很危险的。业务是动态的,明天上线一个新功能可能就改变对象分配模式。生产环境至少要有GC耗时、GC频率、堆使用率、Full GC次数这四项指标的监控告警。不需要多复杂的平台,Prometheus加Grafana,或者直接用JDK自带的JFR(Java Flight Recorder)定时录制,都能达到目的。我在实践中的底线是:任何一次调优,三天后回头看一次监控曲线,确认没有“调完更差”的反效果。
6.4 盲从G1和网上的“最佳实践”
G1很好,但不是所有场景都该无脑上。一个后台批量计算任务,吞吐量优先、短时间卡顿可以接受,用Parallel GC可能吞吐更高;一个堆只有几百M的小应用,Serial GC那点停顿根本无所谓,上G1反而增加复杂度。网上流传的“某某参数设置后效果立竿见影”,通常都带着特定业务背景,直接抄到自己项目上往往水土不服。判断该不该用某个GC、某个参数,标准只有一个:在你自己的业务压测数据里,它能不能达成你的目标——延迟目标、吞吐目标或内存占用量目标。
.
最后说一点个人体会。我做JVM调优这几年,踩过最深的坑就是“想太多、看太少”——看了几篇博客就急着改参数,结果越调越乱。后来养成了一个习惯:每次动手前先回答三个问题——对象都在哪里?谁在引用它们?它们能活多久?回答完这三问,大部分调优方案其实自己就浮出来了。就像经营餐厅,后厨管理做到后面,靠的不是更多的锅和食材,而是对每一份食材流转的清楚认知。希望这篇内容能帮你把JVM内存这间“后厨”的每个角落看清,下次再遇到内存问题,不用慌,按流程走一遍,答案就在数据里。