JVM 系列理论三篇讲清了 JVM 内部长什么样、怎么干活。本篇作为全系列 6 篇中的工程落地续作,旨在解决另一半问题 —— 线上运行时,怎么实时看见它的状态,并据此定位问题。
知道了 JVM 有堆、有 GC、有线程,可当线上服务半夜内存飙高、Full GC 频发的时候,你总不能一台台机器去敲 jstat。
真正的工程做法是:让每个 JVM 持续把自己的状态暴露出来,集中采集、画成图、设好告警。这套最主流的组合就是 Micrometer / JMX Exporter(暴露指标)+ Prometheus(采集存储)+ Grafana(可视化)。这篇我们就把这条链路从头到尾走一遍,重点落在 每张图到底在说什么、怎么从图里看出问题。
本文是 JVM 系列的第 5 篇(共 6 篇)。 全系列:
- 入门篇 · 内存架构与类加载
- 核心篇 · 垃圾回收全解
- 进阶篇 · JIT、内存模型与调优
- JDK 版本差异精讲(8 → 25)
- Grafana JVM 监控分析(本篇)
- Arthas 诊断
一句话定位:通过 Micrometer 暴露指标、Prometheus 定时 Pull、Grafana 4701 看板可视化,将 JVM 内部的堆锯齿、老年代基线、GC 停顿与线程状态转化为可度量、可告警的线上观测闭环。
TL;DR
- 监控链路一句话:暴露(Micrometer / JMX Exporter)→ 采集(Prometheus 定时 Pull)→ 可视化(Grafana 看板)→ 告警(Alertmanager)。
- 暴露指标:Spring Boot 加
actuator + micrometer-registry-prometheus,放出/actuator/prometheus;非 Spring 挂 JMX Exporter agent。两条路线指标命名不同,导看板时要对上。 - 采集与看板:Prometheus 配
scrape_configs定时抓取,targetUP即通;Grafana 直接导入现成看板 ID(4701 最经典)。 - 读看板三条铁律:① 别被单个百分比吓到(Heap used 76% 拆到分代常是 Eden「虚高」,看老年代趋势才算数);② 业务指标和 JVM 指标要对照着看;③
No data多半是查询或标签问题,不是故障。 - 判断泄漏的金标准:老年代基线是否台阶式上涨;健康的堆是「能回到低位的锯齿」。
- 告警别成噪音:按 warning(趋势)/ critical(已影响)分级、给足
for滤毛刺、每条对应一个动作。
Table of contents
Open Table of contents
一、先看全景:数据怎么从 JVM 流到 Grafana
在动手配置之前,我们需要先在脑海中建立一张数据流动图。后续所有的配置动作,本质上都是在填充这张图里的某一环节:
监控链路全景:应用把指标挂成 HTTP 端点 → Prometheus 定时 Pull 存储 → Grafana 可视化 → Alertmanager 告警。
记住一个关键词:Pull(拉取)
Prometheus 是 主动定时去拉 各应用的指标端点(默认 15s 一次),而不是应用往它推。所以应用这边只需要「把指标挂在一个 HTTP 地址上」,剩下的交给 Prometheus 定时来取。
整个链路可以拆解为四个角色,它们各司其职:
| 角色 | 职责 | 核心动作 |
|---|---|---|
| Micrometer / JMX Exporter | 把 JVM 内部状态翻译成标准指标格式 | 暴露指标 |
| Prometheus | 定时抓取、存成时间序列、提供查询 | 抓取存储 |
| Grafana | 把时序数据画成直观的图表 | 查询展示 |
| Alertmanager | 指标越线时发通知 | 规则触发 |
二、第一步:把 JVM 指标暴露出来
要让外界看见 JVM,第一步是把内部指标暴露出来。目前业界有两条主流路线,具体取决于你的应用架构。
2.1 Spring Boot 应用:Micrometer + Actuator(推荐)
对于 Spring Boot 应用,自带的 Actuator 已经完美集成了 Micrometer(号称「监控界的 SLF4J」),只要加上依赖就能自动采集全套 JVM 指标。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
引入依赖后,还需要在 application.yml 中主动暴露 Prometheus 的 HTTP 端点:
management:
endpoints:
web:
exposure:
include: health,info,prometheus # 暴露 /actuator/prometheus
metrics:
tags:
application: ${spring.application.name} # 给所有指标打上应用名标签,多实例区分
应用启动后,访问 http://localhost:8080/actuator/prometheus,你会看到大量形如 jvm_memory_used_bytes{area="heap",...} 1.234E8 的纯文本。这些文本正是 Prometheus 能够解析的标准时序指标格式。
生产别裸暴露 Actuator 端点
/actuator/prometheus(乃至env、heapdump等)会泄露内部指标与敏感配置,绝不能直接暴露到公网。生产环境至少要做到以下一条:
- 只放内网:把 Actuator 端口与业务端口分开(
management.server.port),仅允许 Prometheus 所在内网或安全组访问;- 加鉴权:借助 Spring Security 给
/actuator/**配置拦截认证;- 最小暴露:
exposure.include必须按需声明,千万别图省事写*。
2.2 非 Spring 应用:JMX Exporter
至于老项目或非 Spring 体系的应用,最稳妥的方案是挂载一个 JMX Exporter 作为 Java Agent。它能将 JVM 内部原生的 JMX MBean 数据,实时转换成 Prometheus 兼容格式:
java -javaagent:/path/jmx_prometheus_javaagent.jar=9404:config.yaml -jar app.jar
启动后,相应的指标数据会暴漏在 http://localhost:9404/metrics。
两条路线的指标名不一样
这里必须强调一个极易踩坑的细节:Micrometer 和 JMX Exporter 暴露的核心指标命名截然不同。如果导入 Grafana 看板时发现没有数据,大概率是指标名没对上。
含义 Micrometer JMX Exporter 堆已用 jvm_memory_used_bytesjvm_memory_bytes_usedGC 耗时 jvm_gc_pause_secondsjvm_gc_collection_seconds本文后续探讨的 PromQL,将一律以 Micrometer 命名规范为准(这也是 Spring Boot 场景下最常见的配置)。
三、第二步:Prometheus 抓取配置
指标暴露就绪后,第二步是让 Prometheus 知道去哪里取数据。我们需要在 prometheus.yml 中新增一个抓取任务,明确目标地址与拉取频率:
scrape_configs:
- job_name: 'jvm-app'
metrics_path: '/actuator/prometheus' # Micrometer 端点路径
scrape_interval: 15s
static_configs:
- targets: ['10.0.0.11:8080', '10.0.0.12:8080'] # 多个实例
配置热加载后,打开 Prometheus 的 Status → Targets 页面。只要看到对应的 target 状态为 UP,就说明采集链路已经彻底打通。
验证数据已入库
在 Prometheus 自带的查询框中输入
jvm_memory_used_bytes并执行,只要能看到曲线,就代表底盘打稳了。如果读不出数据,请依次排查三处:应用端点能否正常访问、target 是否呈 UP 状态、防火墙或网络组策略是否放行。
四、第三步:Grafana 看板
到了可视化这一步,我们大可不必从零手绘。Grafana 官方社区沉淀了大量成熟的 JVM 看板,只需直接导入对应的面板 ID 即可开箱即用:
| 看板 ID | 适用 | 说明 |
|---|---|---|
| 4701 | Micrometer | 最经典的「JVM (Micrometer)」看板,面板齐全 |
| 11378 | Micrometer | Spring Boot 2.1+ 增强版 |
| 8563 | JMX Exporter | 配合 JMX Exporter 使用 |
操作路径:Grafana 菜单进入 Dashboards → Import → 输入 4701 → 选择 Prometheus 数据源 → Import。
需要注意的是,导入完成后,通常还要在看板顶部的下拉框中选对 application 与 instance 变量,这样图表才会精确渲染出你关心的目标实例。
4.1 十分钟本地跑通(docker-compose 最小栈)
如果你想在本地亲手复现接下来要解读的看板,这套基于 Docker Compose 的最小化验证栈,只需十分钟就能搭建完毕。它将直接抓取你本机已开启 Actuator 的 Java 应用。
新建 prometheus.yml:
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'jvm-app'
metrics_path: '/actuator/prometheus'
static_configs:
# host.docker.internal 让容器访问宿主机上的应用
- targets: ['host.docker.internal:8080']
再建 docker-compose.yml:
services:
prometheus:
image: prom/prometheus:latest
ports: ["9090:9090"]
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
extra_hosts: ["host.docker.internal:host-gateway"] # Linux 需要这行
grafana:
image: grafana/grafana:latest
ports: ["3000:3000"]
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
三步跑通:
- 执行
docker compose up -d启动 Prometheus(:9090)与 Grafana(:3000); - 打开
http://localhost:9090/targets,确认jvm-app处于UP状态(若不 UP 请回顾第三章的排查指南); - 打开
http://localhost:3000(账号密码均为 admin)→ 配置数据源指向http://prometheus:9090→ 依据上文步骤导入看板 4701 → 在顶部切换正确的application变量。
做完这三步,只要能在屏幕上看到堆内存的锯齿曲线开始跳动,整条监控链路便大功告成了。至于怎么读懂这张庞大图表里的信息,这正是第五章要解决的核心问题。
五、读懂看板:面板含义 × 实战数据
原理干讲往往枯燥难记。为了把理论落到实处,我们换一种思路:直接拿一台真实线上服务的看板(基于经典的 4701 模板)进行自上而下的逐区段拆解。在每个区段中,我们会先展示实景截图,随后用表格将「面板含义与正常形态」同「本例实测读数」并排对比,最后给出健康状况的最终判定。理论对照着实战数据看,往往一遍就能打通心智。
(常见异常现象「长什么样、怎么处理」,将单独抽到 第六章 提炼成速查表。)
先亮这台机器的「底牌」
- 宿主规格:8 vCPU / 16 GiB 内存
- 堆上限:
-Xmx = 10 GiB(committed 也已顶到 10 GiB)- 已运行:3.0 天(Start time
2026-06-17 17:47:10)一句话先记住:堆空间配置了 10 GiB,而整机物理内存一共才 16 GiB。既然堆、非堆、直接内存以及操作系统本身都要挤在这 16 GiB 里,我们在评估「堆用量是否合理」时,就必须时刻把 16 GiB 这个物理硬顶挂在心上。
没空全看?先抓这 3 张
如果是线上突发告警、排障时间紧迫,优先盯住以下三张图,便能给当前系统状态定性八成:
- Quick Facts(5.1) —— 30 秒看清 uptime 与堆 / 非堆水位;
- 堆内存池的 Old Gen(5.5) —— 判断系统有没有内存泄漏的「金标准」;
- GC 的 Pause Durations(5.7) —— 直接关系前端用户体感的停顿。
至于其余的辅助面板,通常是在这三张图上察觉到异常后,为了「顺藤摸瓜」锁定根因而逐一排查的。
为了让你快速建立全局感,我将 9 个核心区段的观测重点浓缩成了下面这张总表,不妨先览全貌,再挑选感兴趣的细节深入钻研:
| 区段 | 一句话看什么 | 本例结论 |
|---|---|---|
| 5.1 Quick Facts | uptime 与堆 / 非堆水位 | Heap 76%(橙)需下探,其余 OK |
| 5.2 I/O Overview | 业务压力(QPS / 状态码 / 耗时) | ~1.3K QPS 健康,但有「小时级」长尾 |
| 5.3 JVM Memory | 堆是不是「能回落的锯齿」 | 是健康锯齿;committed=max=10G,预算偏紧 |
| 5.4 JVM Misc | CPU / 线程 / 日志 | CPU 三成、blocked ≈ 0;日志量惊人 |
| 5.5 堆内存池 | 老年代趋势(泄漏金标准) | 老年代 1.85/10G 平直 → 无泄漏 |
| 5.6 非堆内存池 | Metaspace 有没有爬坡 | 平直健康;No data 是查询坑 |
| 5.7 垃圾回收 | GC 频率 / 停顿 / 晋升 | Young GC 约 10s 一次、停顿 25~50 ms、晋升 ≈ 0 |
| 5.8 类加载 | 类数是否只增不减 | 约 25K 平直 → 健康 |
| 5.9 Buffer Pools | 堆外内存是否失控 | ~286 MiB 平稳,计入内存预算 |
5.1 Quick Facts:30 秒抓住整体状态

这四个位于顶部的 Stat 计数器,构成了整张看板的「体检首页」,让我们得以在 30 秒内摸清系统的底线:
| 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| Uptime | 进程连续运行时长 | 3.0 天 | 没崩溃 / 没频繁重启 |
| Start time | 本次启动时刻 | 2026-06-17 17:47:10 | 排障时可对齐发布 / 重启时间 |
| Heap used | 堆已用 ÷ -Xmx | 76.49%(橙) | 偏高、被标成警示色,需往下查 |
| Non-Heap used | 元空间 + Code Cache 等使用率 | 21.34%(绿) | 很宽裕 |
判断:先别慌,但要往下查
绿色底纹的 Non-Heap 与足够长的 uptime 毫无疑问是个好现象。真正扎眼的是 Heap used 76.49%(橙色) —— 乍一看这个数字,很容易让人误以为「堆快满了、马上要 OOM了」。 然而,必须强调的是,Stat 面板呈现的仅仅是当前这一秒的瞬时快照,非常具有欺骗性。这个 76% 极有可能只是恰好卡在某次 Young GC 爆发前、Eden 区快被填满的瞬间。要判断它是「真的逼近上限」还是「Eden 涨上去马上会被回收」,必须结合 5.3 的趋势图与 5.5 的分代拆解才能下结论(这里先剧透一下:本例中的 76% 完全是 Eden 正常堆积撑起来的虚高,老年代其实非常空)。
5.2 I/O Overview:先看「业务压力」有多大
这一段展示的并非 JVM 内部指标
图表中的请求速率(Rate)、状态码(Http Code)以及响应耗时(Duration),实际上反映的是 应用层的 HTTP 流量表现(来自 Micrometer 的
http.server.requests)。4701 看板特意将它置于页首,其实蕴含着一个排障逻辑:我们得先摸清当前服务正承受着多大的业务压力,再去向下比对 JVM 的底层表现,两者结合起来看才具备实战意义。

| 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| Rate | 总请求速率 | 1.35K ops/s(1K~2.5K 波动) | 平稳、有昼夜节律 |
| Http Code | 按状态码分布 | 204 ≈ 1.29K、200 ≈ 52.5、4xx ≈ 9.77 ops/s,无 5xx | 成功率高(204 = 成功但无返回体,多为心跳 / 上报) |
| Duration | 响应耗时 | 200-AVG 14.8 ms、200-MAX 61.2 ms、HTTP-MAX 209 ms;纵轴顶到 2.78 小时、有一根冲天尖刺 | 日常极快,但有 极端长尾 |
| AE Req/m | 每分钟请求量 | 昼夜波峰(峰值约 2.5 万);TRY 10920、Process 6527 | 规律波动 |
判断:吞吐健康,但有「极端长尾」要追查
~1.3K 的并发 QPS 以及几乎挂零的错误码,足以说明服务承压非常健康。但这其中埋了两个必须注意的线索:
- 那根冲到「小时级」的 Duration 尖刺绝不能放过。排障时需要把它的爆发时间点与 5.7 的 GC「Pause Durations」、5.4 的 Log Events 尖峰以及 5.3 的堆锯齿走势对齐,综合判断究竟是因为发生了 STW 长停顿,还是仅仅是因为一个外依赖超时或者慢查询脚本卡住;
- 流量存在鲜明的昼夜节律。后续观测 GC 回收频次以及 Eden 分配速率时要记住一个大前提:资源占用的波形跟着业务流量走是极其正常的现象,不能草木皆兵地当作异常。
这正是把 I/O 监控和 JVM 指标塞进同一张大盘的核心价值所在:业务表象与 JVM 内因,一眼对照。
怎么追那根「小时级」长尾
在评估延迟时,AVG 和 MAX 往往具有极强的误导性。海量的正常快请求会把 AVG 拉得很低;而孤零零的一个异常极值又会完全主导 MAX 柱线。真正能反映真实业务体感的,是 P99 或 P999 这种长尾分位数(Micrometer 的
http_server_requests_seconds直方图配合 PromQL 的histogram_quantile(0.99, ...))。更进一步,要想彻底锁定「究竟是哪一笔请求卡了三个多小时」,单纯依赖时序聚合视图无异于大海捞针,最终还是得靠 分布式追踪(TraceId) 或者业务侧的 慢请求日志 来一锤定音。
5.3 JVM Memory:堆的「锯齿」是不是健康的

在这个区段,最该紧盯的就是最左侧的 JVM Heap 趋势图。它的底层指标是:jvm_memory_used_bytes{area="heap"}(已用)、jvm_memory_committed_bytes(已申请)、jvm_memory_max_bytes(上限 -Xmx)。在健康的系统里,它的常态必定是一条规律波动的「锯齿线」:随着请求涌入,对象被持续分配,内存使用率稳步爬升;紧接着一次 GC 触发,大量短命垃圾被清空,曲线迅速回落到低位,周而复始。
| 线 / 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| Heap · used(绿) | 真正存放了对象的量,呈锯齿 | Max 8.12 / Current 7.49 GiB | 在 ~4.66~9.31 GiB 间剧烈锯齿 |
| Heap · committed(黄) | 已向 OS 申请锁定的堆 | 10 GiB | 已顶满 |
| Heap · max(蓝) | 上限 -Xmx | 10.00 GiB | 天花板 |
| JVM Non-Heap | 元空间 / Code Cache 等 | used 270 MiB / max 1.23 GiB | 一条水平线(详见 5.6) |
| JVM Total | 堆 + 非堆 | used 7.75 GiB / committed 10.3 GiB | 跟堆同步锯齿 |
| JVM Process Memory | 进程总内存 | No data | 查询标签不匹配(同 5.6) |
常用 PromQL —— 堆使用率:
sum(jvm_memory_used_bytes{area="heap"}) by (instance)
/
sum(jvm_memory_max_bytes{area="heap"}) by (instance)
三条线怎么看:
used ≤ committed ≤ max(以及-Xms的影响)如果要从操作系统与 JVM 内存管理的视角看透这三条线,逻辑其实非常清晰:max 代表允许申请的绝对天花板(也就是
-Xmx = 10 GiB);committed 代表 JVM 已经向操作系统圈地并锁定的内存配额(即使内部对象已被回收,这部分物理额度也归 JVM 进程独占);而 used 则代表当前切切实实承载了存活实例的空间,伴随对象的朝生夕灭,呈现出剧烈的锯齿波动。在本例中,黄线的 committed 已经完全贴合了蓝线的 max,也就是并轨在了 10 GiB。这说明堆空间已被彻底扩容锁定,要么是启动时强制配置了
-Xms = -Xmx,要么是在运行期一路被压力撑满且未再缩容。对于高频调用的在线服务而言,这避免了运行时向 OS 讨要内存的停顿开销,绝非坏事;但这同时意味着,这整整 10 GiB 预算已经被雷打不动地划走,我们要随时掂量 16 GiB 整机所剩无几的系统盈余。(注:这里的 committed 属于 OS 的信用额度承诺,不代表物理内存已全部硬驻留,除非开启了-XX:+AlwaysPreTouch。)换言之,能否在图上清楚看到 committed 这条独立于 max 之外的中间线,完全取决于
-Xms的启动策略。只有当-Xms明显小于-Xmx时(例如2G / 10G),你才会看到 committed 线从底部阶梯式攀升,此时才会呈现出used < committed < max三线清晰分离的结构。
判断:锯齿本身是健康的,但要高度警惕「内存预算」
值得庆幸的好消息是,used 曲线是一条 能大幅回落的健康锯齿(一路能跌谷至 ~4.66 GiB),而不是一条「只涨不回」的绝望爬坡。这充分证明 GC 回收机制效能良好,不存在典型的内存泄漏表征。回过头看 5.1 面板那个红彤彤的 76%,无非是快照碰巧抓拍到了这条锯齿攀升至 Eden 满载峰值的瞬间而已(具体拆解详见 5.5)。
但我们必须盯住暗藏的 物理内存预算。10 GiB 的堆空间已被全额 committed,外加约 0.3 GiB 的非堆与 0.3 GiB 的直接内存,整个 JVM 进程的常驻版图已逼近 11 GiB,而宿主机总内存只有 16 GiB。这就意味着留给操作系统自身与 page cache 的余量被压缩得很紧,资源池的冗余度已经踩在了安全红线的边缘。
5.4 JVM Misc:CPU、线程与日志

底层核心指标:process_cpu_usage / system_cpu_usage、jvm_threads_live_threads / _daemon_threads / _peak_threads、jvm_threads_states_threads{state="..."}。
| 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| CPU Usage | 进程 / 整机 CPU 占用 | system 31.78%(max 54.38%)、process 34.59% | 8 核机器只用三成多,没吃满 |
| Load | 系统负载 vs 核数 | cpus=8、system-1m 3.50(max 8.61) | 平时轻松,但峰值短暂触顶 |
| Threads | 存活 / 守护 / 峰值线程 | live 1.3K、daemon 617、peak 1.3K | 一条平直线,3 天无增长(无泄漏) |
| Thread States | 按状态分布 | blocked 0、runnable ≈ 449,其余等待中 | 无明显的锁争用或死锁 |
| Log Events | 每分钟各级别日志条数 | info 3.4M ops/m(峰值 6.4M)、warn 267.3K、error 1.3 | 日志量极为惊人 |
| File Descriptors | 打开句柄 / 上限 | open 2.3K / max 1.0 Mil | 离上限天花板非常遥远 |
读懂 CPU Usage 的三条线:system / process / process-1h
实际上它的底层只有两个原生的 CPU 指标,第三条
process-1h无非是给 process 套了一个 1 小时的平均降噪计算,用以抹平短期毛刺:
图例 底层指标 含义 回答的问题 system system_cpu_usage整台物理机器 涵盖所有进程的 CPU 耗用 这台 8 核宿主机此刻到底忙不忙? process process_cpu_usage仅计算当前 JVM 进程 的 CPU 开销 这台机器的算力是不是都被我的应用自己吃掉了? process-1h process_cpu_usage的 1 小时平均process 线的 长效趋势基线(滤掉瞬时抖动) 最近一小时的情况是持续高压,还是偶尔抽风抖一下? 掌握这两个关键认知,你就不会读错数据:
- 均已按核数归一化:这两个百分比值早已完成了基于物理核数的均摊计算。0–100% 代表的是 整机所有算力的综合使用率。本例中
process 34.59%在这台 8 核机器上,折算下来意味着大约8 × 0.3459 ≈ 2.8个核处于满载奔跑,而非谬判为「其中某一个核心吃掉了 34% 的算力」。正因如此,它必须和 Load(cpus=8、且峰值达到 8.61)搭配研判。- system 与 process 出现轻微倒挂纯属日常噪声:你会发现本例中的 process(34.59%)居然比整机 system(31.78%)还要高。大可不必惊慌,它们分别源自 两套不同的 OS 计数器体系且各自独立采样,瞬间对不齐非常正常。只有当两者出现 持续且巨幅的倒挂,才需要认真排查(此类问题多发于容器环境下的资源视图隔离缺陷)。
排障口诀请牢记:若 process 与 system 双双冲顶,说明是我们的代码逻辑在狂吃 CPU,顺着
top -Hp配合jstack的火焰图去抓现行;若 system 居高不下但 process 很克制,说明底层算力被其他无关进程野蛮抢占;若 process-1h 连绵抬头,那意味着整体性能正在发生不可逆的衰退,绝不是什么并发尖峰的暂时波动。
判断:CPU、句柄安然无恙,无死锁困扰;但有两头「灰犀牛」要防
先说正面的定性信号:算力绰绰有余、blocked 线程基本归零(代表代码里既没死锁也没有深度的锁饥饿)、FD 上限充沛、线程数画出了一条令人心安的平直线。 然而,有两处伏笔绝对不容忽视:
- 吞吐量惊骇的日志洪流:INFO 级别日志每分钟疯狂输出三百多万行,峰值更是一路冲破六百多万行。如此排山倒海的字符串拼接与高频对象装配,极有可能正是导致后文 5.5 中 Eden 区遭遇高频绞杀、5.7 面板里分配速率逼近 ~1 GB/s 的 幕后推手。如能在生产环境将冗余的日志收缩至 WARN 级别,不仅能为底层 I/O 硬件松绑,更是对高频 GC 最有效的直接减负;
- Load 曾短兵相接打满核心限制:系统虽在日常波段游刃有余,但 8.61 的峰值 Load 已然暴露出:一旦赶上 5.2 揭示的流量高潮,8 个 CPU 会出现瞬间供不应求的挤兑。因此,必须对齐峰值期间的前端延迟报表,确认是否还能守住服务承诺。
5.5 堆内存池:Eden / Old / Survivor(全场最关键)

本节正是揭开 5.1 面板「76% 高水位」悬案的关键破局点。当我们把整个堆按内存池横向拆解成这三张细节大图时,它完美契合了《核心篇 · 垃圾回收全解》中深度剖析的分代模型:
| 内存池 | 对应分代 / 含义 | 本例实测 | 解读 |
|---|---|---|---|
| G1 Eden Space | 年轻代 Eden,新对象出生地 | used 5.49 GiB(committed 6.29、峰值 ~6.98) | 剧烈高频的锯齿,涨满即被 Young GC 清空 |
| G1 Old Gen | 老年代,长寿对象 —— 抓泄漏的根据地 | used 1.85 GiB / max 10 GiB | 死如止水的低位平直,历经 3 天纹丝不动 |
| G1 Survivor Space | 年轻代幸存区 | used 156 MiB(峰值 428) | 周期性的矩形方块,每轮 GC 吞吐而后回落 |
判断:真相大白 —— 所谓的 76% 警报不过是 Eden 吹起的「虚高」泡沫
让我们来算一笔总账:Eden 占用 5.49 GiB 加上老年代的 1.85 GiB,再并上 Survivor 微不足道的 0.16 GiB,三者总计约为 7.5 GiB。这一数字与 5.3 面板里堆 Current 指针报出的 7.49 GiB 严丝合缝。问题的核心在于,这七个多 G 之中,足足有 5.49 GiB 都拥挤在朝生夕灭的 Eden 区,它们随时会被下一次 Young GC 横扫一空。
故而,5.1 看板那抹扎眼的 76% 橙色,无非是快照快门恰巧按在了 Eden 被涨潮塞满的巅峰时刻。反观真正能够用来锚定系统「沉淀内存压力」的老年代,实际仅仅消耗了区区 1.85 GiB 的微小配额(约占总额 10 GiB 的 18%),并且在长达三天的高压运行中展现出了堪称教科书级别的平直度 —— 这才是给当前系统颁发「无泄漏、极致健康」认证的最硬核底气。
教训:在操刀线上排障时,永远不要因为一句笼统的「Heap used 百分比过高」就手忙脚乱地妄下定论,必须把探针扎进老年代的演化趋势里才算数。
5.6 非堆内存池:Metaspace 有没有泄漏爬坡

先理清思路:看板上的「Non-Heap」里到底装了什么
一句话概括核心壁垒:堆负责存放业务产出的实例对象,而非堆则是用来安顿 JVM 维持自身运转的基础设施 —— 最主要的两大核心便是 Metaspace(类的元信息,也就是常说的方法区),以及 Code Cache(由 JIT 编译产出的热点机器码缓存)。正由于这批元信息一旦加载落定便极少动荡更迭,所以我们在图表上看到的非堆走势,往往会呈现为 接近水平的直线,绝不会像堆内存那般暴起暴落(具体底层映射机制可参见《入门篇 · 内存架构与类加载》)。
底层指标:jvm_classes_loaded_classes、jvm_memory_used_bytes{area="nonheap", id="Metaspace"}。
| 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| Metaspace | 类元信息(方法区) | used 153 MiB、committed 179、max -1(不限) | 典型的死水平线,无类爆炸隐患 |
| Compressed Class Space | 压缩类指针专有空间 | used 16.0 MiB / max 1 GiB | 极低的恒定平线 |
| CodeHeap ×3 | JIT 机器码缓存(分段) | No data | 无需惊慌,查询标签不匹配 |
判断:Metaspace 稳健如磐;至于「No data」则是看板标签设下的陷阱,不是故障
Metaspace 极其平直地咬死在 153 MiB 刻度线上,这一现象斩钉截铁地排除了系统内正在上演因为滥用动态反射、CGLIB 代理抑或无节制热部署所引发的类加载灾难(必须牢记,假若此处呈现出 只增不减、不断爬坡 的异象,切记拉响一级警报,它的最终归宿必然是元空间 OOM)。
而对于那三个惨遭「No data」涂白的 Code Cache 分段区,其实是一场乌龙:这是由于看板预设的 PromQL 查询语句,未能与当前 JDK 版本生成的指标标签相契合(分段 Code Cache 的具体指标命名规范在各代 JDK 间有差异)。这仅仅触及了 2.2 章节里反复提示过的「命名不一致」坑,动手修补查询语句即可,与 JVM 本身毫无干系。
5.7 垃圾回收:G1 跑得怎么样

底层指标:jvm_gc_pause_seconds(Timer 型指标,内置 _count / _sum / _max 后缀,含 cause 标签)、jvm_gc_memory_allocated_bytes_total、jvm_gc_memory_promoted_bytes_total。
| 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| Collections | GC 频率 / 类型 | ~0.1 ops/s(即 10s 一波),全 G1 Evacuation(Young) | 节奏迟缓平稳、无 Full GC |
| Pause Durations | 单次停顿时长 | 核心分布在 25~50 ms,局部峰值震荡至 ~75~100 ms | 对在线服务而言堪称丝滑 |
| Allocated / Promoted | 分配 / 晋升速率 | allocated 高达 0.5~1 GB/s,promoted 却几近于 ≈ 0 | 揭示了绝大部分对象都在年轻代中朝生夕灭 |
常用 PromQL(利用 rate(x[1m]) 可以对最近 1 分钟窗口计算 x 的每秒平均增量,是把「单向递增计数器」折算为「实时瞬动速率」的标准招式):
# 平均单次 GC 停顿(秒)= 这段时间累加的 STW 总耗时 ÷ 这段时间触发的 GC 总次数
rate(jvm_gc_pause_seconds_sum[1m]) / rate(jvm_gc_pause_seconds_count[1m])
# 分配速率 / 晋升速率(字节每秒)
rate(jvm_gc_memory_allocated_bytes_total[1m])
rate(jvm_gc_memory_promoted_bytes_total[1m])
判断:G1 调度良好,未见卡顿端倪
面对庞杂的 GC 指标,务必将这条因果链刻入脑海:当分配速率居高不下,Eden 必将被火速填满,迫使系统陷入频发的 Young GC 泥沼;同理,若代表幸存逃离的晋升速率长期居高不下,无异于老年代的慢性毒药,系统被拖入 Full GC 的深渊不过是时间问题。 反观本例中「分配高、晋升 ≈ 0」的鲜明反差,恰恰构成了极其健康的黄金平衡线。这有力地印证了当前系统产生的大量对象都是朝生夕灭,并未形成跨代沉积的负担(这也顺理成章地解释了前文 5.5 章节中老年代能安稳维持 1.85 GiB 低位基线的原因)。单次仅仅几十毫秒的暂停,足以胜任大多数传统在线系统(假如你的场景属于对延迟极其敏感的金融或游戏领域并追求 < 10 ms 极限时延,那便应当换将让 ZGC 披挂上阵)。
若要谋求进一步调优,我们留了两处线索可挖:① 还记得 5.4 章节披露的海量日志吗?它们构成了 ~1 GB/s 骇人分配速率的核心发动机。降日志 = 斩断无谓分配 = 降 GC 频率;② 若还想要彻底排除 5.2 那个卡顿了一小时的长尾死神的 GC 嫌疑,可以将此处的 Pause 极值点切进那个时间窗进行同轴核对。若未见停顿暴增,便可将延迟黑锅直接转嫁给外联阻塞;抑或去调整
MaxGCPauseMillis甚至审视全面切入 ZGC。
5.8 类加载:类的数量稳住了吗

| 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| Classes loaded | 当前已加载类数 | 约 25K 条水平直线 | 启动加载完就稳定,3 天未爬升 |
| Class delta | 每分钟加载 / 卸载类数 | 彻底归零为 0,仅启动初期有 +25 / -75 | 启动收尾后再无动荡 |
判断:健康无比
评估类加载健康度的内在心法,与推敲 Metaspace 的演化逻辑如出一辙。一旦发觉
loaded曲线展现出 持续往上爬 的势头,务必立刻彻查系统内是否有代码处于反射狂欢、CGLIB 疯狂派生代理或是存在无节制的热部署现象(这与 5.6 章节提出的预警形成首尾呼应,其终点皆为 Metaspace OOM)。所幸在此实例中,系统度过短暂的启动期后便画出了没有波澜的直线,其 delta 数值也迅速沉淀归零。
5.9 Buffer Pools:堆外内存有没有失控

Buffer Pools 区块反映的是 JVM 常规堆之外 的直接内存用量,主要来自底层 NIO 与 Netty:
| 面板 | 含义 | 本例实测 | 解读 |
|---|---|---|---|
| Direct Buffers(used) | NIO / Netty 直接内存用量 | ~286 MiB 平直线(used ≈ capacity) | 稳定且不增长 |
| Direct Buffers(count) | 直接申请的缓冲个数 | 约 1.5K 个 | 数值平稳 |
| Utilisation | 底层使用率计算 | No data | 查询标签不匹配(同 5.6) |
| Mapped Buffers | 内存映射文件(mmap) | 0 | 本服务未动用 mmap |
判断:运行如常,但要计入「内存预算」
直接内存稳定游走于 ~286 MiB,说明系统内部的 NIO / Netty 堆外缓冲体系非常健康,不存在脱缰隐患。
但这里必须发出两点警示:其一,这片空间 彻底不受
-Xmx指令的物理管束。如果在底层发生泄漏,你的 JVM 看板堆内存可能看着毫无异样,但整个进程却会被操作系统的 OOM Kill 强行绝杀。因此,一旦观察到 Direct Buffers 呈现出 只增不减 的凶兆,请火速排查未释放的DirectByteBuffer(这也正好呼应了《核心篇 · 垃圾回收全解》中关于依靠虚引用间接打理堆外内存的探讨);其二,请将这并不起眼的 ~0.3 GiB 一并登入 5.3 章节算的那笔总体内存细账:堆 10 GiB 配合非堆 0.3 GiB 再加上堆外的 0.3 GiB,整个系统的常驻身位约 10.9 GiB,已经逼近了 16 GiB 整机近七成疆域。
5.10 一句话体检结论
这台 JVM(8 vCPU / 16 GiB,-Xmx 10 GiB,已跑 3 天)健康吗?—— 整体非常硬朗,但确实掩藏了几处亟待整治的灰犀牛。
- 那扎眼的「76% 堆」不过是虚惊一场:剖开底层才发现,那只是 6 GiB 大的 Eden 涨潮瞬间被快照收录罢了。其真正的命脉——老年代仅仅占用 1.85 / 10 GiB 且经受了三日的平直检验 —— 结论是,未见任何泄漏;
- GC 子系统运转从容:约 10 秒一次无痛纯 Young GC,停顿收敛在 25~50 ms,外加晋升指标濒临于 0(绝大多数新生对象都被有效地绞杀在年轻代);
- 运行态势稳健:CPU ~32%、Load 平时 3.5、blocked ≈ 0(无明显的锁争用)、线程 / 类 / 直接内存均保持平直、FD 充沛;
- 业务侧的反馈亦是坦途:轻松接盘 ~1.3K 的高频 QPS 阵仗且几乎全部斩获纯粹的 2xx / 204;
落实到后续的防范与治理动作上,优先级建议如下:
- 整治海量日志泥沼:info 级别每分钟泼洒 300+ 万行,这恰恰是致使分配速率逼近 ~1 GB/s 以及 Eden 区剧烈锯齿跳动的元凶。收敛生产日志级别可一举降低 I/O 阻塞乃至 GC 压力;
- 锁定极端长尾刺客:回溯 5.2 章节那一根惊悚的「小时级」Duration 尖刺,必须找准它的落点时刻,并同期的 GC 停顿或慢查询任务予以对齐排查;
- 审视逼仄的内存空间博弈:进程吃掉 ~10.9 GiB 将近 16 GiB 七成身家的现实下,OS 与 page cache 的留白已现局促,日后在探讨扩容或微调
-Xmx前必须三思;- 警惕 Load 临界点的系统失能:尽管常态游刃有余,但 8 核心被短暂跑满的峰值依然值得通过补测延迟曲线来查验业务侧体感;
- 修缮脱节的看板:由于 PromQL 标签体系与对应 JDK 规范发生断层,使得 CodeHeap / Process Memory / Utilisation 陷入「No data」。修正查询规则即可复苏图表。
六、常见现象的分析与处理
看完了第五章那一套完备的「标准体检报告」,这第六章我们来反向推演:面对线上千奇百怪的「异常形态」,究竟该怀疑哪里、下一步该执行什么动作? 下方汇编的这份实战排障速查表,每一行都能精准映射回前文解读的核心面板。
| 看板上的现象 | 大概率原因 | 下一步动作 |
|---|---|---|
| 老年代基线出现台阶式上涨、GC 拉不回(看 5.5 Old Gen) | 内存泄漏 | jmap 导 heap dump,用 MAT 看支配树 |
| Full GC 频繁、晋升速率高(看 5.7) | 大对象 / Survivor 太小 / 过早晋升 | 调 -Xmn、SurvivorRatio,加 -XX:+PrintTenuringDistribution |
| Metaspace 与类数持续爬坡(看 5.6 / 5.8) | 动态生成类太多 | 排查反射 / CGLIB / 热部署 |
| 线程数只增不减(看 5.4) | 线程泄漏 | jstack 看线程,检查线程池配置 |
大量 BLOCKED 线程(看 5.4 Thread States) | 锁竞争 / 死锁 | jstack 找 BLOCKED 链,对照《进阶篇 · JIT、内存模型与调优》的底层并发模型 |
| GC 时间占比高、堆频繁满(看 5.3 / 5.7) | 堆太小 / 分配速率过高 | 加大 -Xmx,或优化高频对象分配(如降日志) |
process_cpu_usage 飙高(看 5.4) | 热点代码 / 死循环 | top -Hp 定位线程 → jstack / 火焰图 |
| Direct Buffers 只增不减(看 5.9) | 堆外内存泄漏 | 排查 DirectByteBuffer 未释放,留意进程是否遭 OOM Kill |
| 响应长尾但堆 / GC 正常(看 5.2 vs 5.7) | 慢查询 / 外部依赖 / 批处理 | 对齐时间点排查,而非一味盲调 JVM 参数 |
监控的真正价值
监控手段再强,它也没法代替你直接修改代码。但它的战略价值在于:让你能在故障爆发的第一时间感知异动,并凭借面板间的关联印证,迅速将排查范围收拢到某一类。 纵观《进阶篇 · JIT、内存模型与调优》里面所倡导的「度量 → 分析 → 调整 → 验证」调优闭环,这一整套基于 Prometheus 与 Grafana 的可视化链路,正是这套理论在工程前线的具象化落地。
6.1 配告警时的「参考水位」
当然,人眼不可能 24 小时死死盯着看板。要想真正做到防患于未然,我们必须把速查表里最致命的几个特征,转化成 Prometheus 的告警规则。下面这份阈值清单,可以作为搭建告警体系的起步基准(具体实战中务必结合自家业务基线微调):
| 指标 | 预警(warning,趋势性) | 危急(critical,已影响) |
|---|---|---|
| 老年代使用率 | 持续 > 70% | 持续 > 90% |
| 平均 GC 停顿 | > 100 ms | > 500 ms |
| Full GC 频率 | 偶发 | 分钟级频发 |
| Metaspace | 缓慢上涨 | 持续上涨 / 逼近上限 |
| 线程数 | 缓慢上涨 | 只增不减、逼近 ulimit |
| 文件句柄 | > 50% 上限 | > 80% 上限 |
| Direct Buffers | 缓慢上涨 | 只增不减 |
| process CPU | 持续 > 70% | 持续 ≈ 100% |
告警别配成噪音
在配置告警时,切忌搞出满天飞的噪音。请务必恪守三条原则:① 利用
for参数(如for: 5m)给足缓冲时间,滤掉瞬时毛刺;② 严格分级 —— warning 是「趋势预警、可白天处理」,critical 是「已影响、要立刻响应」,走不同通知渠道;③ 每条发出的告警都必须附带明确的行动建议,收到通知后能立刻对应上速查表里的排查动作。否则,迟早会被当作狼来了的噪音忽略。
落到可直接被解析的规则,长这样(Prometheus rule 文件格式,以老年代、GC 停顿两条为例):
groups:
- name: jvm-alerts
rules:
# 老年代使用率持续偏高 —— 疑似泄漏或堆偏小(对应 §6 速查表第 1 行)
- alert: JvmOldGenHigh
expr: |
sum by (instance) (jvm_memory_used_bytes{area="heap", id=~".*Old.*"})
/ sum by (instance) (jvm_memory_max_bytes{area="heap", id=~".*Old.*"})
> 0.9
for: 5m
labels: { severity: critical }
annotations:
summary: "{{ $labels.instance }} 老年代使用率 > 90% 持续 5m"
description: "疑似内存泄漏或堆偏小,建议 jmap 导 dump + MAT 分析支配树"
# 平均单次 GC 停顿过长 —— 直接影响用户体感延迟(对应速查表 GC 行)
- alert: JvmGcPauseHigh
expr: |
rate(jvm_gc_pause_seconds_sum[5m]) / rate(jvm_gc_pause_seconds_count[5m])
> 0.1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.instance }} 平均 GC 停顿 > 100ms"
一个细节:id=~".*Old.*" 这里用正则匹配老年代池名,是因为不同收集器对其命名天差地别(G1 是 G1 Old Gen、Parallel 则是 PS Old Gen)—— 这正是 2.2 小节里那个关于「命名不一致」避坑指南在告警配置时的翻版。
七、小结
行文至此,让我们把整条监控链路浓缩为工程上的四个步骤:
全文脉络回顾
- 暴露:Spring Boot 用 Micrometer + Actuator,其余阵营用 JMX Exporter,把 JVM 状态挂成 HTTP 指标端点;
- 采集:让 Prometheus 定时主动 Pull、存成时序数据;
- 可视化:Grafana 导入现成看板(如 4701),自上而下盯 I/O、堆与分代、GC、线程、类、堆外等核心面板;
- 解读:健康的堆是「能回到低基线的锯齿」,排查时重点警惕老年代爬坡、Full GC 频发、以及线程和类的只增不减。
与此同时,也希望大家把第五章实战拆解中提炼出的这三条「读图铁律」,刻进日后的排障直觉里:
看板解读三条铁律
- 别被单个百分比吓到:
Heap used 76%看着危险,拆开才发现只是 Eden 爆发的「虚高」、老年代实则很空。判断内存健康,永远要看老年代的趋势基线,而不是混合堆总量的百分比。- 业务指标和 JVM 指标要对照着看:遇到响应慢、有长尾,第一反应应是把 I/O 的案发时间点拿去和 GC 停顿、堆锯齿、乃至日志尖峰对齐 —— 把「业务虚象」无缝连结于底层的「JVM 根因」。
No data多半是查询问题,不是故障:面板空白通常是因为当前 JDK 版本与看板 PromQL 之间的指标标签对不上(见 2.2),先怀疑看板本身,别贸然怪罪系统崩溃。
原理 → 监控 → 诊断,三步连起来
理论三篇《入门篇 · 内存架构与类加载》等教我们认清了 JVM 的内部运作机制(知其所以然),而本篇则补齐了工程视角的拼图——教你在线上怎么远远看见它的运作(出了事能预警、能缩小排查范围)。当看板把你领到某台机器门口、却答不上「具体是哪段代码写崩了」时,就该轮到 Arthas 这类在线诊断工具 登场了 —— 不重启、不改代码地直接钻进进程做现场探查。原理 → 监控 → 诊断,三者合起来才是从「会写 Java」到「扛得住线上」的完整技术闭环。
延伸阅读
- Grafana Labs. JVM (Micrometer) dashboard · ID 4701 —— 本文逐区段拆解的看板本体。
- Micrometer. Micrometer Prometheus registry —— Spring Boot 指标暴露的官方说明。
- Prometheus. Configuration · scrape_configs —— 抓取任务与告警规则配置参考。
- Prometheus. Querying · functions(rate / histogram_quantile) —— 本文 PromQL 用到的核心函数。