半夜两点被告警短信吵醒,这大概是我做后端这几年最熟悉的场景了。服务接口耗时飙升、CPU打满、日志里频繁出现GC停顿,打开监控面板一看:老年代都快顶到天花板了,Full GC隔几分钟就来一次。不用怀疑,这又是JVM内存出了问题。
“jvm内存监控”这个词,说大不大,说小不小。往小了说,就是几条jstat命令、一个可视化面板的事;往大了说,它牵扯到你对JVM内存模型的理解、对GC机制的把控、对线上容量规划的判断。很多朋友第一次接触JVM参数,其实是玩Minecraft用HMCL启动器时,在启动选项里调“-Xmx”给游戏多分点内存。原理和线上调优一模一样。这篇就围绕JVM内存监控展开,从内存模型讲到工具选型,再到一次完整排查案例,把我这几年踩过的坑和沉淀下来的方法分享出来。
1. JVM内存模型:监控前先搞懂布局
做监控有个前提,你得先知道JVM把内存花在了哪里。不然监控面板上堆使用率80%,你都不知道这个数字意味着什么。
1.1 堆内存:监控的重中之重
堆(Heap)是JVM内存中最大的一块区域,也是绝大多数Java对象生存的地方。我们在配置JVM参数时最常听到的“-Xms”和“-Xmx”就是控制堆内存的初始大小和最大大小。这地方按对象存活时间分为新生代(Young Generation)和老年代(Old Generation),新生代里又细分出Eden区和两个Survivor区(S0、S1)。
正常情况下,新创建的对象先分配到Eden区,Eden区满了触发Minor GC,存活对象在S0和S1之间来回拷贝,达到一定年龄的对象晋升到老年代。老年代满了触发Major GC或者Full GC,这才是让业务停顿的元凶。我见过很多刚做开发的同学,对着“堆内存”看半天,以为堆就是全部,其实这只是JVM内存布局的一部分。
堆内存的监控核心是三个维度:当前使用率、分配速率和晋升速率。使用率看的是水位线,分配速率反映业务创建对象的速度,晋升速率则决定老年代何时会被打满。这三个指标在jstat输出里对应E、S0、S1、O几个区域的使用百分比,以及YGC、FGC的次数。
1.2 堆外区域:容易被忽略的内存
堆之外的内存经常被忽视,但线上问题十有八九和它们有关。方法区在JDK 8以后被元空间(Metaspace)取代,存的是类元数据、方法信息、常量池这些东西。元空间的特点是它不在堆里,而是使用本地内存,默认情况下可以无限增长,直到把系统物理内存耗光。
虚拟机栈(VM Stack)给每个线程分配栈帧,存局部变量表、操作数栈、方法返回地址。栈深度超过默认值就抛StackOverflowError,这是递归没写退出条件时的老朋友了。本地方法栈服务于native方法,程序计数器则是记录当前线程执行的字节码行号,这些都是线程私有的区域。
还有一块容易被忽略的是直接内存(Direct Memory),NIO和Netty用得比较多。它不受堆大小限制,但受系统总内存约束。JVM参数里有-XX:MaxDirectMemorySize可以设置上限,默认等于堆大小。我遇到过一次Netty流量暴增导致直接内存撑爆的情况,监控面板上堆内存表现正常,但服务就是频繁假死,最后排查到直接内存才找到根因。
1.3 从JRE、JVM到JDK:理清概念再谈监控
很多初学者会把JDK、JRE、JVM混为一谈,这直接影响后续理解监控工具怎么用。JVM是Java虚拟机本身,负责加载字节码、管理内存、执行垃圾回收;JRE是JVM加上一系列核心类库,提供一个完整的Java运行环境;JDK则是在JRE基础上再增加编译工具(javac)、诊断工具(jstack、jmap)等开发必需的组件。
我们平时说的“配一下JVM参数”,实际操作的是启动脚本里的java命令加各种-XX选项。这套参数作用于JVM运行时数据区,直接影响堆大小、GC策略、日志输出等行为。理解了这三者的关系,你才能明白为什么监控JVM内存要用JDK自带的工具,而不是在任意一台机器上装个Java运行环境就上手。
2. 监控工具选型:不同阶段用不同工具
工具不在多,在于合适。我见过有团队买了商业APM产品,功能很全,但技术人员连基础指标都看不懂。也见过有的老哥一台裸机器上用命令行工具就把问题定位得明明白白。选什么工具,取决于你处于什么阶段。
2.1 JDK自带工具:排查问题的保底方案
JDK自带的工具是排查问题的基础保障,也是我线上定位问题的第一选择。jps列出本机Java进程;jstat观察GC行为和内存使用情况;jmap导出堆转储快照;jstack打印线程快照。这套组合拳在无图形界面、不能装额外软件的受限环境中尤其好用。
jstat是内存监控里最常敲的命令。举个例子,执行jstat -gcutil 12345 1000 10,意思是查看PID为12345的进程,每隔1000毫秒输出一次GC信息,总共输出10次。里面的S0、S1、E、O分别代表各个分区的使用百分比,YGC和YGCT是Minor GC的次数和耗时,FGC和FGCT对应Full GC。通过这些数字,能初步判断当前GC压力集中在哪个区域。
jmap中最让我谨慎的是-dump参数。执行jmap -dump:live,format=b,file=heap.hprof 12345会触发一次Full GC再导出堆,这在生产环境上会导致较长时间的业务停顿,操作前一定要评估影响,或者直接改用jcmd的GC.heap_dump命令,它可以不触发Full GC而只是导出堆。
2.2 可视化与在线诊断工具:效率更高的选择
命令行工具能解决问题,但效率偏低,人眼盯着不断滚动的数据看一天也看不出全貌。可视化方面,JDK自带的VisualVM可以连接本地或远程JVM,图形化展示堆内存历史曲线、GC趋势、线程状态,适合开发环境做初步验证。
线上诊断我推荐Arthas,阿里开源的那款Java诊断工具。它的dashboard命令能实时展示各线程的CPU占比、内存分布情况;heap命令可以检查堆内存中对象的分布;watch和trace能跟踪方法的调用参数和耗时。最重要的是,Arthas在问题排查过程中基本不需要重启服务,对线上环境很友好。
还有一个思路是启用GC日志。在启动参数里加上-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log,把GC日志落盘。GC日志的信息密度极高,包含每次GC发生的时间、原因、各区域回收前后的容量变化、停顿耗时,是分析GC频率异常和停顿问题的第一手资料。配上GCeasy这类在线分析工具,能把日志自动解析成趋势图,省去人肉看日志的痛苦。
2.3 工具搭配:我常用的组合拳
单靠一个工具很难完整还原问题全貌,我一般按这个顺序组合使用。
第一时间用jps找到目标进程,接着jstat看GC动态,粗略判断问题出在堆内还是堆外;然后翻GC日志,找频繁GC和停顿长的具体时间点;如果怀疑内存泄漏,jmap导出堆后用MAT(Memory Analyzer)做离线分析,找占用内存最大的对象引用链;线上不方便交互时,直接让Arthas挂载上去,用dashboard实时观察。这几步下来,绝大多数内存问题都能定位到具体代码。
这套组合拳的核心逻辑是层层递进。先用开销最小的命令扫一遍,确定问题范围;再决定是否做重度操作,比如导出堆快照。避免一上来就jmap dump导致线上停顿,这是新手容易犯的冒进错误。
3. 核心监控指标解读:数据要会看才有用
工具给你一堆数字,你得知道这些数字背后代表什么业务含义。这一节把监控中最重要的几个指标讲透。
3.1 堆内存使用率:最直观的体检指标
堆使用率是大家最关注的指标。监控告警通常设一个阈值,比如堆使用率超过80%就告警。但只看一个水位线容易误判。举个例子,一个批量导入任务在跑的时候,短时间内创建大量对象,堆使用率冲到90%以上,任务跑完就降下来,这就属于正常的业务波动。真正需要警惕的是水位线只涨不跌,像一口永远关不上的水龙头,这种情况下即使当前还没触发Full GC,也要着手排查了。
还要关注Eden区和Survivor区的比例关系。新生代里Eden和两个Survivor区域的默认比例是8:1:1,由-XX:SurvivorRatio控制。如果业务对象大多数存活时间很短,Eden区能快速回收,这个比例就够用;如果大量对象能扛过几次Minor GC,Survivor区太小会频繁触发“提前晋升”,导致对象过早进入老年代,反而加速Full GC。
设置初始堆和最大堆时有个经验之谈。开发环境无所谓,线上建议把-Xms和-Xmx设置成一样的值,避免扩容时反复申请内存、缩容时导致性能抖动。JVM启动时一次性向操作系统申请固定大小的堆,运行期间不会因为堆不足而扩容,GC行为更稳定。
3.2 GC频率与停顿:性能劣化的风向标
GC指标分为频率和停顿两部分。频率看的是单位时间内Minor GC和Full GC各发生多少次,停顿则是每次GC导致的业务线程暂停时间。
Minor GC频繁,说明Eden区设置偏小,或者业务代码创建对象过多。这时候调大新生代(-Xmn参数)往往立竿见影,但要注意,新生代太大会导致老年代空间缩小,反而增加Full GC风险,需要配套平衡。Full GC频繁则说明老年代被持续撑满,要么是存活对象太多,要么是晋升速率过快,要么就是内存泄漏。
GC停顿时间直接决定接口响应时间。我见过一个极端案例,Full GC每次停顿4秒钟,接口超时时间设在3秒,线上成功率直接跌到谷底。这类问题光看频率不够,要结合GC日志里的耗时数据,确认停顿主要发生在哪一个阶段。
有一个坑很多人不知道:CMS回收器(JDK 8的-XX:+UseConcMarkSweepGC)在并发模式失败(concurrent mode failure)时,会自动退化成Serial Old串行回收,停顿时间从几百毫秒暴涨到几十秒。如果监控发现GC停顿时间突然失控,先看一下GC日志是不是出现了concurrent mode failure,再做调优决定。
3.3 线程与类加载指标:别忽略侧面信号
线程数和类加载数看起来和内存无关,但它们都是JVM内存的消耗者。每个线程默认要占用固定大小的栈空间(-Xss参数,默认通常是1MB),几百个线程就是几百MB内存。监控线程数异常飙升时,首先怀疑线程池没有正确关闭,或者有并发请求把线程池打满了。
类加载数量则和元空间内存强相关。如果监控发现Metaspace使用率持续上涨,且伴随ClassLoader数量异常,基本可以判定是类加载器泄漏了。典型场景是频繁热更新、大量动态代理生成类,每个类都加载到新的ClassLoader里,旧的ClassLoader没有被回收,元空间就被慢慢吃光。
这些侧面指标的共同特点是,它们出问题往往晚于堆内存异常,但一旦恶化,恢复成本极高。所以我的日常巡检习惯是:堆、GC、线程、类加载四个维度一起看,不是只盯着内存水位线。
4. 实操案例:一次Full GC频繁的完整排查
理论讲再多,不如一次完整的实操过程有价值。分享一个最近处理的案例,流程很典型。
4.1 现象与初步判断:从指标异常到锁定方向
某订单服务上线两周后,监控平台告警:老年代使用率长期在90%以上,Full GC从每小时几次飙到每五分钟一次,接口P99延迟从80ms涨到800ms。我当时先登服务器,用jps找到进程ID,然后执行jstat -gcutil 17234 1000 10查看现场。
输出结果里O区使用率稳定在97%左右,FGC次数在10秒内就增加了两次,说明Full GC已经非常频繁。Eden区反而一直不到30%,说明Minor GC比较正常,问题集中在老年代:要么有大量大对象直接进入老年代,要么就是有对象泄漏,堆积在老年代里无法回收。
这时候我做了个关键判断:先不急着dump堆。Eden区的正常说明新生代没问题,我先用jmap -histo:live 17234 | head -50查看堆内存中的对象分布。如果Top对象里出现业务相关的对象且数量异常庞大,基本能定位到嫌疑代码。-histo会触发Full GC,执行前我确认了这个节点是集群中的一个,没有挂载多余流量,影响可控。
4.2 导出堆与内存分析:找到真正的“内存窃贼”
对象分布里果然有异常:有一个名为OrderMessageCache的HashMap对象,实例数只有1个,但内存占用排进了前三。单个对象占用几百MB,这种大体积集合高度符合缓存类泄漏的特征。我再用jmap -dump:live,format=b,file=/tmp/order_heap.hprof 17234导出堆到本地,趁Full GC的间隙导出了接近1.5GB的堆文件。
用MAT打开后,先看Dominator Tree(支配树),OrderMessageCache果然排在第一位。点击对象看引用链,发现它是一个static字段,被某个订单监听器持有,这个HashMap把订单消息报文按订单号存起来,从未清理。从代码逻辑看,设计初衷是防止重复消息,但漏了过期删除机制,导致每个订单的消息都积攒在老年代里,最终把老年代撑爆。
确认了根因,修复方向就很清晰了。先把缓存改成带过期时间的本地缓存,用的是Caffeine,设置最大条目数和过期时间;再增加一个定时任务,定期清理超过30分钟未消费的消息;对于确实需要防重复处理的场景,改为数据库去重判断,不再依赖内存缓存。
4.3 参数优化与代码修复:治标更要治本
代码修复后,我同时调整了JVM参数。原启动参数是-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200。这次改成-Xms4g -Xmx4g -XX:MaxGCPauseMillis=100,并把GC日志显式落盘,加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs做兜底,防止下次OOM时没有诊断现场。
这里解释一下为什么扩容堆和收紧停顿目标。扩容是因为业务的订单量和对象占用明显超了当初的预估,2G堆留给老年代的空间太局促;收紧停顿目标则配合G1的并发回收机制,让JVM更积极地控制每次GC停顿时间,代价是整体GC频率可能略有提升。这种参数的调整没有绝对标准,要用压测和线上波动数据来验证,调完要持续观察几天。
修复重新上线三天后,Full GC频率降到了几乎没有,接口P99回到80ms以下,老年代使用率稳定在40%。后续我总结了这次案例的根因:不是JVM参数的问题,而是业务代码把内存当成了无限容量的存储介质。调参只是缓兵之计,清理泄漏才是根治。
5. 常见问题与排查技巧实录
日常被问得最多的一些问题和用法,我整理成一份速查表,再补充几个自己琢磨出来的小技巧。
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 堆内存持续上涨不回落 | 对象泄漏或缓存未清理 | 导出堆快照,用MAT分析支配树 |
| Full GC频繁,老年代打满 | 晋升过快、大对象过多 | 查GC日志,调整NewRatio、SurvivorRatio |
| Metaspace持续增长 | 类加载器泄漏、动态类过多 | 排查ClassLoader数量,关注热更新逻辑 |
| 接口偶发卡顿 | GC停顿时间过长 | 看GC日志停顿阶段,考虑换回收器 |
| 进程内存远大于堆内存 | 堆外内存、直接内存占用 | 检查NIO/Netty使用,调整MaxDirectMemorySize |
| 线上OOM无现场 | 未开启堆转储参数 | 加上HeapDumpOnOutOfMemoryError |
这张表覆盖了我碰到的大多数JVM内存问题。注意,表格里说的“倾向”,不是非黑即白的答案。比如Full GC频繁也可能是堆太小,要结合业务量、对象大小一起估算,不能单靠某一条就下结论。
5.2 实操中的几个小技巧
多说几个常规文档里不太会写的东西。
第一个是jstat观察时间要够长。我习惯至少持续观察10分钟以上,覆盖一次完整的业务高峰和低谷,才能判断GC频率是稳定行为还是偶发波动。只看一分钟数据就调参,很容易被瞬时尖峰带偏。
第二个是jar包与JDK版本匹配问题。很多老项目跑在JDK 8上,用G1回收器的调参逻辑和JDK 11、17完全不同。比如新版本里-XX:+UseG1GC变成默认,且随着大版本迭代做了大量优化。网上搜到的调参建议五花八门,先确认自己用的JDK版本,再决定参考哪个方案。
第三个是GC日志文件别忘轮转。生产环境服务长期运行,GC日志默认是单文件追加,时间长了会占满磁盘。启动参数里加上-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M这类配置就能自动化处理,别等磁盘报警才想起来。
第四个经验是线上操作要留后路。每次jmap dump之前,先确认这台机器磁盘空间够不够,堆文件通常是堆大小的1到2倍;再确认当前节点是否在承担实时流量,最好在流量低谷执行。操作前做好记录,万一影响业务能快速回滚。
第五个是关于HMCL这种场景设置的延伸。玩Minecraft时在HMCL里设置JVM参数,核心就是-XX和-X这两个开头的配置项。给游戏分配内存其实也是在调整JVM堆大小,和线上调优是同一个思路。很多程序员就是从这类游戏场景开始点开JVM参数这个技能树的,我一点都不觉得奇怪。
6. 关于JVM内存监控的几点个人体会
踩过几次坑之后,我对JVM内存监控的定位越来越清晰:它是稳定性保障的底线,不是万能的银弹。
监控指标再完善,功能再花哨,定位问题的核心永远是看懂现象背后的内存模型和GC机制。工具只是放大你的观察能力,不能替代你对业务代码的理解。这也是为什么我一直强调:遇到内存问题,先看代码嫌疑,再看参数是否合理,而不是第一时间去调堆大小。
我个人的操作习惯里,日常巡检只看四个数字:Full GC频率、老年代使用率、堆使用率峰值、GC最大停顿时间。这四个数字的健康区间结合业务特点预先定好,不达标就触发人工介入。另外,每次线上内存问题处理完,我都会把堆转储文件保留一段时间,方便后续复盘和对比。
还有一个建议是定期做容量评估。业务增长后,原来的堆大小和GC参数大概率不再合适。每季度按在线用户数、接口QPS、缓存量这些数据重新计算一轮,比等线上出问题再救火划算得多。
最后分享一个小技巧。在自定义监控脚本里,把jstat和jmap封装成几条标准命令,配合定时采集把数据落库,再用简单的折线图展示趋势,比单人通过命令行看数据直观太多。监控不是给一个人看的,而是给整个团队一个统一视角。面向长期运营的系统,这套基础监控体系越早搭起来越省心。