1. 一张图拆开JVM的“动态全貌”,远比背命令重要
我刚入行那会儿,很喜欢在网上找各种“JVM内存结构图”,把堆、栈、方法区、程序计数器背得滚瓜烂熟,笔试聊起来头头是道。可真到了线上服务频繁卡顿、CPU莫名飙高、接口时不时超时的时候,我发现自己脑子里只有一幅**“静态图”**,完全不知道对象是怎么在内存里流动的、GC发生的时候系统到底经历了什么、哪块区域紧张了会先报警。后来被线上事故逼着啃了几个月的GC日志和堆转储快照,才慢慢把JVM的“运行图”在脑子里真正画了出来。
这篇文章想分享的,就是我后来沉淀下来的一幅动态视角的JVM运行全景:从类加载与运行时数据区的分工,到对象在堆内存里的完整生命周期,再从垃圾回收器的选型思路聊到内存溢出的现场排查与参数调优。不是零散地罗列JVM命令,而是把一串运行机制串成一条完整链路,让你遇到性能问题时不至于两眼一抹黑。想搞懂JVM底层、准备面试、或者正在被线上性能问题折磨的同学,这篇应该能给你一张能直接用的“地图”。
2. 运行时数据区:名为“内存结构”,实则是一张数据流动的管道图
2.1 程序计数器、虚拟机栈、本地方法栈:线程私有的“工作台”
大家第一眼看到JVM内存划分,往往只记住了堆和方法区。但真正理解运行图,得先搞清哪些数据是线程私有的,哪些是线程共享的。我不止一次在面试里看到候选人把“栈溢出”和“堆溢出”的原因搞混,根源就在于没想明白这两类区域的分工。
线程私有的几个区域,我习惯类比成“流水线上的操作台”:
- 程序计数器:占空间极小,存的是当前线程执行字节码的行号指示器。它在“运行图”里的作用很单一——记录这个线程接下来要执行哪一条字节码指令。它不会OOM,也不需要GC,在线程切换恢复执行时靠它定位位置。它是一块很小的内存区域,在JVM规范里是唯一不会抛出OutOfMemoryError的区域。
- 虚拟机栈(Java栈):这是“线程私有”区域里最容易出问题的。每个线程执行一个方法时,就会创建一个栈帧,栈帧里包含局部变量表、操作数栈、动态链接、方法返回地址。方法一层层调用,栈帧就一层层往上叠。深度过高或者栈帧过大,就会抛出StackOverflowError。我处理过一个案例,某同事写了一个递归查询菜单树的逻辑,没有正确设置递归终止条件,生产环境一触发就去查子节点,直接在分布式任务网关线程里把栈打爆,监控面板上那个线程的CPU瞬时冲到100%,其他请求排队大面积超时。后来我在代码规范里定了一条规矩:任何递归逻辑必须考虑深度上限和终止条件,不能依赖侥幸。
- 本地方法栈:为Native方法服务。在HotSpot里这两块是合到一起的。日常排查大多数场景接触不多,但用JNI做网络IO或者调底层C库时,这里的溢出表现和虚拟机栈类似。
这三块区域的生命周期都随线程走。“运行图”的第一条线,就是线程执行方法→创建栈帧→压栈/出栈→释放内存空间。这条线跑得飞快,也不参与GC,但一旦失控,影响比堆溢出还直接,因为它是线程卡死的重灾区。
2.2 堆与方法区:线程共享的“仓库”和“登记簿”
堆是JVM管理的最大一块内存,也是垃圾回收的主战场。几乎所有对象实例和数组都在这里分配。线程私有区域管“执行状态”,堆管“业务数据”,而方法区(HotSpot里用元空间实现)管的是“类型元信息”。
方法区常被误解成“存放静态变量的地方”。准确说,它存的是已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存。JDK 8之后永久代被移除,改成本地内存中的元空间,一个重要原因是永久代的内存上限不好控制,容易出现OOM,而元空间直接使用本地内存,默认情况下只受服务器物理内存限制,但这也带来一个隐患——如果类加载器不断被替换且旧类没被卸载,元空间同样会被撑爆。我之前在某个大数据组件里就见过类加载器频繁创建又无法释放导致的“java.lang.OutOfMemoryError: Metaspace”问题,查了半天最后定位到是某个内部工具类加载器没关流导致的泄漏。
把堆、元空间、栈放到一起看,运行图第二条线就清晰了:
- 编译期:Java源码编译成Class文件,含字节码和符号引用。
- 运行期:类加载子系统把类信息加载到元空间,在堆中生成对应的Class对象,实例对象按代码逻辑在堆中逐条分配。
- 栈帧记录方法调用关系,执行引擎一条条解释或编译执行字节码指令,通过动态链接把符号引用解析为直接引用。
这样从“类文件到堆里的对象,再到栈帧里的调用现场”就串起来,而不是只背一个四象限图。
2.3 三种重要内存参数的默认值与物理边界
不少刚接触JVM参数的人,一上来就乱调-Xms、-Xmx,不仅没解决问题,反而埋了新的坑。下面这张表是我总结的“认知基线”,实操前先对照一下:
| 参数 | 默认值(JDK 8+常见配置) | 含义 | 容易出现的问题 |
|---|---|---|---|
| -Xms | 物理内存的1/64 | 初始堆大小 | 设太小会导致频繁扩容,Full GC连续发生 |
| -Xmx | 物理内存的1/4 | 最大堆大小 | 设太大超过容器配额,会被系统杀掉 |
| -Xmn | 不设置时由JVM自动取堆的1/3到1/4 | 新生代大小 | 设置不当会压缩老年代空间,晋升频繁 |
| -XX:MetaspaceSize | 非平台相关(通常由平台默认) | 初始元空间阈值 | 默认值太小会在刚启动时触发Metaspace扩容到阈值 |
| -XX:MaxMetaspaceSize | 无上限(受物理内存限制) | 元空间公立上限 | 不设上限时类加载器泄漏直接拖垮进程 |
举个具体例子,一台4G内存的容器,-Xmx如果直接设成3G,再加上元空间和线程栈占用,整体内存很容易超过容器配额而被OOM Killer杀掉,这时候监控上看不到Java堆溢出,但进程“突然消失”,系统日志里全是“Killed process”。所以容器环境里第一原则是保留足够余量,我一般建议把堆最大值控制在容器内存的50%-65%以内,其余留给元空间、线程栈和JVM本身开销。
3. 对象的完整旅途:从创建到回收,这张“运行图”才是核心
3.1 对象的出生:分配、逃逸分析与栈上分配的博弈
很多人理解对象分配时,默认“对象一律分配在堆上”。这个说法在JVM开启逃逸分析之后并不完全准确。HotSpot有“栈上分配”优化——如果经过逃逸分析,确定一个对象不会被其他线程或方法外部访问,就可能在栈帧上直接分配内存,方法结束栈帧弹出时对象随之销毁,不需要GC参与。但注意,这个结论有很强的场景依赖,并非所有对象都能被优化到栈上,HotSpot的逃逸分析默认是开启的,但是否真的触发栈上分配要结合编译后的结果看。这个知识点在平时的dump分析里不会直接看到,但它解释了“为什么有的对象很多却GC压力不大”。
如果对象确实“逃逸”了,那它会进入堆内存,具体落在年轻代的Eden区,还是直接进入老年代,取决于它的体积和分配规则。大对象(比如大数组、大字符串)直接进老年代,这由-XX:PretenureSizeThreshold控制。这个阈值默认是0,表示不做大小判定,但你可以显式设置,比如2MB以上算大对象直接去老年代。为什么这样设计?因为新生代的复制算法对“大块头”很不友好,大对象在Eden和两个Survivor区之间复制会白白消耗大量内存和拷贝带宽,直接晋升老年代反而可以避开反复复制。
这里要特别注意:大对象直接进入老年代不等于“老年代就是大对象专属区”。绝大多数情况还是要控制大对象的产生频率,因为老年代的GC要么是Mark-Sweep要么是Mark-Compact,都比新生代的复制算法代价大得多。
3.2 新生代里的三次转移:Eden、S0、S1的角色分工
年轻代的“运行图”是GC调优最经典的话题:对象先进入Eden区,Minor GC发生时,把Eden区和当前使用的Survivor区中存活对象复制到另一个Survivor区,同时将对象年龄加1。默认的SurvivorRatio是8,也就是说Eden区占新生代的80%,两个Survivor各占10%。这意味着年轻代大部分对象活不过第一轮Minor GC——这正是分代假设的根基:绝大多数对象朝生夕灭。
如果你觉得这个复制流程有点抽象,可以想象一个自助餐厅的取餐台:Eden是主餐台,每天生产大量餐盘,用完就丢;S0和S1是回收站旁边的暂存区,每轮回收时服务员先把还吃得动的东西从主餐台搬到暂存区A,下一轮再从暂存区A挪到B,来回倒腾,倒腾到一定次数(默认15次)还没丢,就给这东西贴上“长期留存”的标签,搬去老年代。
在调优中,我看过不少团队把-Xmn调得过大,以为“新生代越大越好”。但新生代过大意味着老年代被压缩,对象晋升变得频繁,老年代GC频率反而上升。新生代过小又会导致对象没机会活到晋升,直接在Eden里反复经历Minor GC。比较务实的做法是:先保持默认,通过GC日志观察Minor GC后的存活对象比例,再决定新生代大小。如果Minor GC后经常有30%以上对象进入Survivor区,说明此Survivor区配比偏小,需要上调SurvivorRatio值或增大-Xmn。
3.3 晋升老年代:从“年轻”到“年老”的边界条件
对象什么时候会从新生代进入老年代?主要有四个触发条件:
- 年龄计数器达标:默认对象每经历一次Minor GC且存活下来,年龄+1,达到-XX:MaxTenuringThreshold(默认15)就晋升。
- 动态年龄判定:HotSpot并不是傻等满15岁。如果Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,那么年龄大于等于该年龄的对象就可以直接晋升,不需要继续等。
- 大对象直接进入:超过PretenureSizeThreshold设置值的对象,直接在老年代分配。
- Minor GC后存活对象放不下Survivor区:多出的部分会以“分配担保”的方式直接进入老年代。
这四点在大多数JVM文章里都会提到,但很少有人强调“动态年龄判定”带来的坑。实战中我发现,如果Survivor区设置得偏小,对象年龄积累不到15就可能因为“超过一半空间”而提前晋升,晋升数量一多,老年代压力陡增。这也是为什么我反复强调不要只调-Xmx和-Xms,Survivor配比与晋升策略的联动理解更重要。
3.4 判断对象“已死”的两种算法与GC Roots
对象回收,前提是先判断它“是不是垃圾”。两种判断方式网上资料很多:引用计数法和可达性分析。引用计数法实现简单,但解决不了循环引用问题,所以主流JVM都用可达性分析。可达性分析的思路是:从一组GC Roots对象出发,沿着引用链往下走,能走到的对象都是“活着的”,走不到的就标记为可回收。
哪些对象能当GC Roots?我总结过一份清单:
- 虚拟机栈中引用的对象(局部变量、入参等)
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- 被synchronized锁持有的对象
- JVM内部的引用(系统类加载器、基本类型的Class对象等)
这份清单的价值在于,当你做堆dump分析时,可以顺着GC Roots往下追踪,判断某个大对象是被谁引着不放。比如线上有一个几MB的HashMap一直释放不掉,你就该顺着GC Roots找它的“主人”,看究竟是Spring容器持有、ThreadLocal持有还是某个全局缓存持有。这是后续内存泄漏排查的关键路径,后面我还会再展开。
4. 垃圾回收器选型:吞吐量与延迟的取舍,没有“万能解”
4.1 GC的类型与核心指标:STW到底怎么量化
任何垃圾回收器都会产生“Stop The World”停顿。区别只在停顿时间长短与频率。GC调优最核心的两个指标就是吞吐量和延迟。吞吐量=用户线程运行时间/(用户线程运行时间+GC总时间),延迟则看单次GC的停顿时间是否可接受。
这个概念其实像外卖配送:吞吐量相当于一天配送800单,但最晚一单可能晚了3小时;延迟相当于每家都30分钟送到,但一天只能跑200单。你要的是“高峰期不爆仓”还是“用户体验稳定”,选型方向完全不同。
4.2 从CMS到G1与ZGC:一代GC的定位与切换逻辑
简单梳理一下常用GC的适用方向:
| 回收器 | 设计目标 | 适用场景 | 我个人的使用体会 |
|---|---|---|---|
| Serial / Parallel | 高吞吐优先 | 后台批处理、离线任务 | ParallelGC在JDK8到JDK10是默认,适合不在乎单次停顿的任务 |
| CMS | 低延迟优先 | Java8时代的老系统 | 已被废弃,且并发标记阶段对CPU敏感,容易promotion failed |
| G1 | 可预测停顿 | JDK9+默认,面向多核大堆 | 默认即优秀,先用默认值观测,不要开场就乱调Region大小 |
| ZGC | 超低停顿(毫秒级) | 超大堆、对延迟极度敏感的服务 | 吞吐量可能有轻微代价,但在大堆场景下效果明显 |
很多人看到“G1是默认垃圾回收器”就放心了,其实G1也有自己的调优参数需要关注。G1把堆划分为许多大小相等的Region,通过维护一个可预测的停顿时间模型——-XX:MaxGCPauseMillis,默认200ms——来控制GC节奏。如果你发现GC停顿远超200ms,不一定是一开始调参错了,也可能是Humongous Object(大对象,超过Region大小一半的对象)太多,导致回收策略失效。我碰到过一次:某服务有个20MB的缓存数组,直接占了好几个Region,G1每次要做混合回收时都要反复扫描这个大对象,GC停顿从几毫秒瞬间飙到400多毫秒。事后把缓存拆成小块存储,问题就解了。
4.3 为什么“默认参数”未必适合你的业务:三次实际选择
默认参数在多数场景下是“安全牌”,但业务特点差异很大。我遇到过三个典型选型决策,可以给大家参考:
- 场景一:某内部离线报表任务,每5分钟跑一次批量统计,单次运行长达40分钟,对单次响应时间完全无感,只关心跑得越快越好。这里我直接把GC切回ParallelGC,并关掉了偏向锁,整体运行时长反而降了几个百分点。
- 场景二:某实时价格服务,高峰期请求延迟的P99要求低于100ms,堆内存不大,只有2G。此时G1的Region重分配成本在停顿控制上没有明显优势,我选择了保留G1但把MaxGCPauseMillis压到100ms,同时保持-Xmn较小,宁可Minor GC频繁一些也不让它触发老年代GC。
- 场景三:某推荐系统的缓存实例,堆开到16G还动不动垃圾堆积。我换到了ZGC,并配了-XX:SoftRefLRUPolicyMSPerMB等参数,把停顿从上百毫秒压到个位数毫秒。代价是吞吐量略有下降,但对业务来说延迟优先。
“没有万能解”不是客套话,是需要你根据吞吐量和延迟对业务的实际意义来判断的。这个判断能力,比背任何参数表都值钱。
5. 内存溢出现场:5种OOM形态与一次完整的排查链路
5.1 五种常见的OutOfMemoryError形态
线上OOM不是只有一种表现。我按“内存区域”把见过的OOM形态归纳如下:
- java.lang.OutOfMemoryError: Java heap space:最常见,堆满了,或者堆本身设置不当。
- java.lang.OutOfMemoryError: Metaspace:元空间不够,通常是类加载器泄漏或加载的类过多。
- java.lang.OutOfMemoryError: GC overhead limit exceeded:JVM花了大量时间做GC却回收不了多少内存,且配置了-XX:+UseGCOverheadLimit时触发。这个提示非常误导人,本质上是“堆快满了且GC已经无效”,要从对象占用找根因。
- java.lang.OutOfMemoryError: unable to create native thread:线程数量超限,看似是代码问题,实则是操作系统线程数限制或线程栈内存累计过大。
- java.lang.OutOfMemoryError: Direct buffer memory:堆外内存(NIO的DirectByteBuffer)超出-XX:MaxDirectMemorySize。
很多人在日志里看到“GC overhead limit exceeded”,第一反应是“GC参数配错了”,其实这是表象。我见过一个案例,某后台服务每次推送完一批数据就在内存里缓存一份结果集,缓存量越来越大,最终JVM每分钟都在做Full GC,但能回收的对象少得可怜,系统几乎瘫痪。日志里就是这个提示。真正的解决办法不是关掉这个检查,而是去定位谁在持有对象。
5.2 定位步骤之一:从“日志+线程dump”锁定嫌疑线程
遇到OOM,我的排查顺序从来不是直接看代码,而是按“数据先行”的步骤走:
- 确认OOM出现的时间点,与发布记录、流量高峰对应。把时间线对齐,往往能第一时间排除变更导致的回归。
- 看GC日志,明确OOM前GC的频次、类型和堆使用趋势。如果是持续缓慢上升,大概率是泄漏;如果是一夜之间暴涨,大概率是流量突增或缓存策略失效。
- 抓线程dump,看所有线程都在干什么。如果是“unable to create native thread”,在系统的线程列表里可以看到几千个线程卡在某个地方——比如在等待某个数据库连接池返回、在无限创建定时器线程等。
- 再抓堆dump做对象级别的分析。
这里要提一个容易被忽略的细节:JVM在OOM时默认不会自动把堆dump下来。如果你没有预先加-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump,OOM以后几乎很难再复现现场,因为堆数据已经乱了。所以我一直建议,所有Java服务的启动参数里都应该固定加上这两个参数,成本极低,收益却是灾难恢复时的关键一手数据。
5.3 定位步骤之二:堆dump视图里的“对象占比”与“引用链”
拿到heap dump后,用MAT或者VisualVM打开,我会优先看三样东西:
- Histogram(对象直方图):按类统计实例数和占用内存。如果发现某个自定义业务对象的实例数量异常庞大,方向一下就清楚了。
- Dominator Tree(支配树):从GC Roots反向显示谁占用了最多内存。这里能看到一个顶层对象下挂着一堆子对象,容易发现“全局缓存没清理”之类的问题。
- 线程栈视图:看某个大对象被哪个线程引用。这一步能把“内存占用”和“业务执行路径”关联起来。
举一个真实但做过脱敏处理的例子:某定时任务服务的堆直方图里,有个ReferenceCountedByteBuf对象占用一直在上升,支配树显示其引用链指向一个Netty的Channel,而Channel又挂在某个全局Map里,这个Map的值是每次调用时往里塞的Response对象,从没移除过。后来在代码里补上了请求结束时remove的逻辑,堆占用曲线立刻回归平稳。
5.4 别忽略“线程栈溢出”这类非典型内存故障
OOM的排查不只有堆问题。“StackOverflowError”虽然不叫OutOfMemoryError,但同样属于运行图上的严重故障。我遇到过一个比较极端的案例:某服务使用了深度嵌套的JSON结构,递归解析时没有控制层级深度,一个正常的接口请求就能触发快速失败,但如果JSON特别深,栈帧叠到几万层,直接把线程栈撑爆,伴随的现象就是CPU飙高、接口全部卡死。
所以我在代码评审时特别关注递归调用、深层次对象嵌套和默认序列化工具的深度限制配置,这些细节在压力测试阶段很难暴露,生产环境一来流量就原形毕露。
6. 参数调优的正确打开方式:从“猜”到“量”,再到“验”
6.1 盯着这五个指标做初始判断
参数调优最怕的是凭感觉乱调。我一般先盯这五个指标:
- Minor GC频率与耗时:如果每秒都在Minor GC且耗时超30ms,说明新生代太小或存活对象太多。
- Full GC频率与耗时:如果几分钟一次且每次秒级停顿,老年代压力已到警戒线。
- 堆内存使用率趋势:稳定平台期正常,持续上升通常是泄漏。
- GC后存活对象大小:Minor GC后Survivor区的占用比例,直接反映晋升压力。
- 每秒分配速率:可以通过-XX:+PrintGCDetails结合外部监控推断,分配速率过快时的调优思路是增大年轻代或减少对象创建。
这些指标从GC日志里基本都能读出来,用-Xlog:gc*(JDK9+)或者-XX:+PrintGCDetails -XX:+PrintGCTimeStamps(JDK8)开启。日志格式有改动,但我建议至少保留最基础的gc、gc+heap、gc+promotion这几类输出,能覆盖大部分分析场景。
6.2 三个最实用的参数组合与计算公式
下面这套是我在多个项目里验证过的“初始值”组合,适合大多数Web服务起步阶段:
| 意图 | 参数建议 | 说明 |
|---|---|---|
| 堆初始与最大一致 | -Xms = -Xmx | 减少运行期扩容/收缩抖动,容器环境尤其推荐 |
| 年轻代占比 | -Xmn 约为 -Xmx的1/2到1/3 | 先按1/3起,再根据Minor GC后的存活对象比例调整 |
| 超大对象阈值 | -XX:PretenureSizeThreshold=1m到2m | 避免大对象在Survivor反复复制 |
| 元空间下限 | -XX:MetaspaceSize=256m | 防止启动期频繁扩容GC |
| 最大GC停顿 | -XX:MaxGCPauseMillis=200 | 仅G1有效,ZGC可用其它参数控制目标 |
计算公式方面,最常用的是“目标GC频率”的反推:如果希望General GC(Minor GC)每10秒一次,统计出平均每秒分配速率是100MB/s,那新生代容量就需要约1GB,再除以Eden占比0.8约1.25GB。这个办法听上去很机械,但对于“业务代码里大量生成临时对象”的场景非常有效,可以帮你快速判断内存该扩还是该优化代码。
6.3 别把“调参”当成“改代码不足”的遮羞布
我见过太多人遇到性能问题第一反应是“压GC参数”,实际上代码层面的优化收益往往大于参数调优。比如:
- 字符串频繁拼接用StringBuilder替代
- 日志框架的isDebugEnabled判断避免无谓拼接
- 大对象缓存改用弱引用
- 分批处理避免一次性加载全量数据
- 复用对象池减少短期临时对象分配速率
参数的极限是有限的,而代码浪费的优化空间是无限的。先量化瓶颈,再选择调参还是改码,才是理性做法。有些团队一到GC频繁就上ZGC,结果只是代码里有个地方无脑new对象,换了ZGC照样分配压力大。
6.4 调优验证的正确节奏
参数改完之后,不能光看业务恢复就说“调完了”。我习惯按“1小时短期观察 → 24小时趋势 → 1周稳定性”分三步验证:
- 1小时:确认GC频率、GC停顿、P99延迟是否达标。
- 24小时:观察是否有周期性的内存增长,例如每日缓存雪崩导致的堆积。
- 1周:对比发布前后GC吞吐量曲线、Full GC次数、OOM报警次数。
只有这三个窗口都通过了,我才会把参数沉淀到团队配置模板里,并在变更记录中写清楚“为什么这么改,验证依据是什么”。
7. 精准监控与告警设计:让“运行图”实时可见
7.1 监控指标的最小集合:不要一上来就堆几十张图
监控不是越多越好。我推荐刚开始只盯四组:
- JVM内存:堆各代使用率、元空间使用率、堆外Direct内存使用率。
- GC情况:GC次数、GC耗时、GC吞吐量、最近一次GC停顿时间。
- 线程:活跃线程数、线程阻塞数、线程死锁数。
- 系统资源:CPU、内存、磁盘IO、网络IO。
选指标的原则是“能直接反映业务体验和故障档位的指标优先”。GC吞吐量和GC停顿时间比“当前堆使用了多少”更能说明问题,因为使用率只是因果链条中的中间变量。
7.2 用JMX或字节码工具把关键指标暴露出来
Java服务最常见的监控暴露方式是通过JMX。无论是用Micrometer暴露到Prometheus,还是用Spring Boot Actuator的Health端点,都离不开JMX这把钥匙。如果你用非Spring框架,也可以自己写一个定时任务去读取ManagementFactory.getGarbageCollectorMXBean()等接口,把采集结果推到监控系统。
这些数据的意义在于:把运行图的“瞬时快照”变成一条有时间轴的曲线。CPU飙高时,你看GC曲线能判断是GC风暴导致还是业务线程死循环导致。这两者的处理方向完全不一样,一个调内存,一个查代码。
7.3 告警阈值怎么定:宁可少告,不可乱告
告警阈值是一个平衡艺术。阈值设太敏感,每天半夜被无关告警吵醒,久了就不再有警觉;阈值设太迟钝,故障发生了还没收到消息,工具就形同虚设。我的一般原则:
- Full GC次数:15分钟内大于3次,触发Warning;大于5次直接Critical。
- GC停顿时间:单次Full GC超过1秒,触发Warning;超过5秒,触发Critical。
- 堆使用率:老年代占比持续超过70%并持续上升,触发Warning;超过85%,触发Critical。
- 线程阻塞:阻塞线程数大于线程池核心线程数的50%,触发Warning。
这几个阈值在不同业务下会有差异,需要你们自己根据历史运行数据校准。我强调的只是:告警规则一定要围绕“可行动”来设计,收到告警后你能立刻知道下一步该看什么,而不是收到一个“JVM内存超阈值”不知道从哪里下手。
8. 几件值得反复提醒的“坑”与我的实操心得
这一路排障,我把踩过和见过的坑整理成了一张速查表,分享出来也算给大家省点时间:
| 现象 | 常见错误认知 | 实际情况 | 建议行动 |
|---|---|---|---|
| 进程突然消失,无OOM日志 | “一定是堆溢出了” | 可能被容器OOM Killer杀掉 | 查系统dmesg日志,核对-Xmx与容器配额 |
| 频繁Full GC但老年代占用不高 | “老年代设太大” | 可能是元空间或堆外内存压力反推 | 看Metaspace使用量与DirectBuffer统计 |
| GC日志很长时间没输出 | “GC没发生说明状态好” | 可能是日志开关没开或轮转策略不合适 | 确认-Xlog配置和文件大小滚动 |
| 堆dump分析不出大对象 | “工具坏了” | 可能大对象在堆外或线程栈上 | 补查堆外内存与Native内存工具 |
| 接口偶发超时但GC不频繁 | “不是GC问题” | 可能锁竞争或线程池任务队列堆积 | 看线程dump的BLOCKED状态 |
此外,我强烈建议你在压测环境里模拟一次OOM,检验“HeapDumpOnOutOfMemoryError + 监控告警 + 日志采集”整个链路是否真的会发生告警并留下有效现场。这种事情平时不做演练,真出事了只能靠运气。
我还想专门提一句:不要轻易关掉-XX:+HeapDumpOnOutOfMemoryError,我在不少公司的默认启动脚本里看到过这个参数被注释掉,理由是“dump文件太占磁盘”。但这个文件在故障当天可能就能直接告诉你根因,代价远小于你在线上一顿瞎猜。磁盘空间不够时可以设置-XX:HeapDumpPath到一个大分区,或者配合定时任务清理旧dump,但“关掉”真不是好选择。
JVM的“运行图”不是一张静态图,而是类、对象、引用、GC、线程这些元素在一台机器上不断交互的动态系统。能画出这个动态系统的人,写出的代码、设计的参数、排查的问题,都会比只会背结构图的人靠谱得多。我希望这篇文章能帮你把头脑里的那张图升级一遍。