Skip to content
Charles Shao
Go back

Grafana JVM 监控分析:从指标采集到看板解读的实战指南

–views

JVM 系列理论三篇讲清了 JVM 内部长什么样、怎么干活。本篇作为全系列 6 篇中的工程落地续作,旨在解决另一半问题 —— 线上运行时,怎么实时看见它的状态,并据此定位问题。

知道了 JVM 有堆、有 GC、有线程,可当线上服务半夜内存飙高、Full GC 频发的时候,你总不能一台台机器去敲 jstat。

真正的工程做法是:让每个 JVM 持续把自己的状态暴露出来,集中采集、画成图、设好告警。这套最主流的组合就是 Micrometer / JMX Exporter(暴露指标)+ Prometheus(采集存储)+ Grafana(可视化)。这篇我们就把这条链路从头到尾走一遍,重点落在 每张图到底在说什么、怎么从图里看出问题。

本文是 JVM 系列的第 5 篇(共 6 篇)。 全系列:

  1. 入门篇 · 内存架构与类加载
  2. 核心篇 · 垃圾回收全解
  3. 进阶篇 · JIT、内存模型与调优
  4. JDK 版本差异精讲(8 → 25)
  5. Grafana JVM 监控分析(本篇)
  6. Arthas 诊断

一句话定位:通过 Micrometer 暴露指标、Prometheus 定时 Pull、Grafana 4701 看板可视化,将 JVM 内部的堆锯齿、老年代基线、GC 停顿与线程状态转化为可度量、可告警的线上观测闭环。

TL;DR

Table of contents

Open Table of contents

一、先看全景:数据怎么从 JVM 流到 Grafana

在动手配置之前,我们需要先在脑海中建立一张数据流动图。后续所有的配置动作,本质上都是在填充这张图里的某一环节:

JVM 监控数据流动图:你的 Java 应用里 JVM 运行时(堆 / GC / 线程 / 类)把状态送到指标暴露端点(Micrometer 或 JMX Exporter),端点通过 HTTP /actuator/prometheus 被 Prometheus 定时拉取、抓取并存成时序数据;Prometheus 再经 PromQL 查询把数据交给 Grafana 做看板可视化与告警,并按规则触发 Alertmanager 发出告警通知 监控链路全景:应用把指标挂成 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 看板时发现没有数据,大概率是指标名没对上。

含义MicrometerJMX Exporter
堆已用jvm_memory_used_bytesjvm_memory_bytes_used
GC 耗时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适用说明
4701Micrometer最经典的「JVM (Micrometer)」看板,面板齐全
11378MicrometerSpring Boot 2.1+ 增强版
8563JMX 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

三步跑通:

  1. 执行 docker compose up -d 启动 Prometheus(:9090)与 Grafana(:3000);
  2. 打开 http://localhost:9090/targets,确认 jvm-app 处于 UP 状态(若不 UP 请回顾第三章的排查指南);
  3. 打开 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 张

如果是线上突发告警、排障时间紧迫,优先盯住以下三张图,便能给当前系统状态定性八成:

  1. Quick Facts(5.1) —— 30 秒看清 uptime 与堆 / 非堆水位;
  2. 堆内存池的 Old Gen(5.5) —— 判断系统有没有内存泄漏的「金标准」;
  3. GC 的 Pause Durations(5.7) —— 直接关系前端用户体感的停顿。

至于其余的辅助面板,通常是在这三张图上察觉到异常后,为了「顺藤摸瓜」锁定根因而逐一排查的。

为了让你快速建立全局感,我将 9 个核心区段的观测重点浓缩成了下面这张总表,不妨先览全貌,再挑选感兴趣的细节深入钻研:

区段一句话看什么本例结论
5.1 Quick Factsuptime 与堆 / 非堆水位Heap 76%(橙)需下探,其余 OK
5.2 I/O Overview业务压力(QPS / 状态码 / 耗时)~1.3K QPS 健康,但有「小时级」长尾
5.3 JVM Memory堆是不是「能回落的锯齿」是健康锯齿;committed=max=10G,预算偏紧
5.4 JVM MiscCPU / 线程 / 日志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 秒抓住整体状态

Grafana 4701 看板顶部 Quick Facts 概览:四个 Stat 面板并排显示 Uptime 3.0 天、Start time 2026-06-17 17:47:10、Heap used 76.49%(橙色警示)、Non-Heap used 21.34%(绿色)

这四个位于顶部的 Stat 计数器,构成了整张看板的「体检首页」,让我们得以在 30 秒内摸清系统的底线:

面板含义本例实测解读
Uptime进程连续运行时长3.0 天没崩溃 / 没频繁重启
Start time本次启动时刻2026-06-17 17:47:10排障时可对齐发布 / 重启时间
Heap used堆已用 ÷ -Xmx76.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 的底层表现,两者结合起来看才具备实战意义。

Grafana 4701 看板 I/O Overview 区段:展示 HTTP 流量的请求速率 Rate、按状态码分布 Http Code、响应耗时 Duration(含一根冲天长尾尖刺)与每分钟请求量 AE Req/m 曲线

面板含义本例实测解读
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 以及几乎挂零的错误码,足以说明服务承压非常健康。但这其中埋了两个必须注意的线索:

  1. 那根冲到「小时级」的 Duration 尖刺绝不能放过。排障时需要把它的爆发时间点与 5.7 的 GC「Pause Durations」、5.4 的 Log Events 尖峰以及 5.3 的堆锯齿走势对齐,综合判断究竟是因为发生了 STW 长停顿,还是仅仅是因为一个外依赖超时或者慢查询脚本卡住;
  2. 流量存在鲜明的昼夜节律。后续观测 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:堆的「锯齿」是不是健康的

Grafana 4701 看板 JVM Memory 区段:左侧 JVM Heap 呈现 used(绿,锯齿)、committed(黄)、max(蓝)三条线,右侧是 JVM Non-Heap、JVM Total 与 JVM Process 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(蓝)上限 -Xmx10.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、线程与日志

Grafana 4701 看板 JVM Misc 区段:包含 CPU Usage(system / process / process-1h 三条线)、Load、Threads、Thread States、Log Events 与 File Descriptors 面板

底层核心指标: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 小时的平均降噪计算,用以抹平短期毛刺:

图例底层指标含义回答的问题
systemsystem_cpu_usage整台物理机器 涵盖所有进程的 CPU 耗用这台 8 核宿主机此刻到底忙不忙?
processprocess_cpu_usage仅计算当前 JVM 进程 的 CPU 开销这台机器的算力是不是都被我的应用自己吃掉了?
process-1hprocess_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 上限充沛、线程数画出了一条令人心安的平直线。 然而,有两处伏笔绝对不容忽视:

  1. 吞吐量惊骇的日志洪流:INFO 级别日志每分钟疯狂输出三百多万行,峰值更是一路冲破六百多万行。如此排山倒海的字符串拼接与高频对象装配,极有可能正是导致后文 5.5 中 Eden 区遭遇高频绞杀、5.7 面板里分配速率逼近 ~1 GB/s 的 幕后推手。如能在生产环境将冗余的日志收缩至 WARN 级别,不仅能为底层 I/O 硬件松绑,更是对高频 GC 最有效的直接减负;
  2. Load 曾短兵相接打满核心限制:系统虽在日常波段游刃有余,但 8.61 的峰值 Load 已然暴露出:一旦赶上 5.2 揭示的流量高潮,8 个 CPU 会出现瞬间供不应求的挤兑。因此,必须对齐峰值期间的前端延迟报表,确认是否还能守住服务承诺。

5.5 堆内存池:Eden / Old / Survivor(全场最关键)

Grafana 4701 看板堆内存池区段:G1 Eden Space(高频锯齿)、G1 Old Gen(低位平直不涨)、G1 Survivor Space(周期方块)三张图并排

本节正是揭开 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 有没有泄漏爬坡

Grafana 4701 看板非堆内存池区段:Metaspace(水平线)、Compressed Class Space(低平线)与三个分段 CodeHeap 面板(No data)

先理清思路:看板上的「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 ×3JIT 机器码缓存(分段)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 跑得怎么样

Grafana 4701 看板 GC 区段:Collections(GC 频率与类型)、Pause Durations(单次停顿时长)与 Allocated / Promoted(分配 / 晋升速率)三张图

底层指标:jvm_gc_pause_seconds(Timer 型指标,内置 _count / _sum / _max 后缀,含 cause 标签)、jvm_gc_memory_allocated_bytes_total、jvm_gc_memory_promoted_bytes_total。

面板含义本例实测解读
CollectionsGC 频率 / 类型~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 类加载:类的数量稳住了吗

Grafana 4701 看板类加载区段:Classes loaded(约 25K 条水平线)与 Class delta(每分钟加载 / 卸载类数,基本为 0)

面板含义本例实测解读
Classes loaded当前已加载类数约 25K 条水平直线启动加载完就稳定,3 天未爬升
Class delta每分钟加载 / 卸载类数彻底归零为 0,仅启动初期有 +25 / -75启动收尾后再无动荡

判断:健康无比

评估类加载健康度的内在心法,与推敲 Metaspace 的演化逻辑如出一辙。一旦发觉 loaded 曲线展现出 持续往上爬 的势头,务必立刻彻查系统内是否有代码处于反射狂欢、CGLIB 疯狂派生代理或是存在无节制的热部署现象(这与 5.6 章节提出的预警形成首尾呼应,其终点皆为 Metaspace OOM)。所幸在此实例中,系统度过短暂的启动期后便画出了没有波澜的直线,其 delta 数值也迅速沉淀归零。

5.9 Buffer Pools:堆外内存有没有失控

Grafana 4701 看板 Buffer Pools 区段:Direct Buffers(used / count,平直不增长)、Utilisation(No data)与 Mapped Buffers(为 0)面板

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;

落实到后续的防范与治理动作上,优先级建议如下:

  1. 整治海量日志泥沼:info 级别每分钟泼洒 300+ 万行,这恰恰是致使分配速率逼近 ~1 GB/s 以及 Eden 区剧烈锯齿跳动的元凶。收敛生产日志级别可一举降低 I/O 阻塞乃至 GC 压力;
  2. 锁定极端长尾刺客:回溯 5.2 章节那一根惊悚的「小时级」Duration 尖刺,必须找准它的落点时刻,并同期的 GC 停顿或慢查询任务予以对齐排查;
  3. 审视逼仄的内存空间博弈:进程吃掉 ~10.9 GiB 将近 16 GiB 七成身家的现实下,OS 与 page cache 的留白已现局促,日后在探讨扩容或微调 -Xmx 前必须三思;
  4. 警惕 Load 临界点的系统失能:尽管常态游刃有余,但 8 核心被短暂跑满的峰值依然值得通过补测延迟曲线来查验业务侧体感;
  5. 修缮脱节的看板:由于 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 频发、以及线程和类的只增不减。

与此同时,也希望大家把第五章实战拆解中提炼出的这三条「读图铁律」,刻进日后的排障直觉里:

看板解读三条铁律

  1. 别被单个百分比吓到:Heap used 76% 看着危险,拆开才发现只是 Eden 爆发的「虚高」、老年代实则很空。判断内存健康,永远要看老年代的趋势基线,而不是混合堆总量的百分比。
  2. 业务指标和 JVM 指标要对照着看:遇到响应慢、有长尾,第一反应应是把 I/O 的案发时间点拿去和 GC 停顿、堆锯齿、乃至日志尖峰对齐 —— 把「业务虚象」无缝连结于底层的「JVM 根因」。
  3. No data 多半是查询问题,不是故障:面板空白通常是因为当前 JDK 版本与看板 PromQL 之间的指标标签对不上(见 2.2),先怀疑看板本身,别贸然怪罪系统崩溃。

原理 → 监控 → 诊断,三步连起来

理论三篇《入门篇 · 内存架构与类加载》等教我们认清了 JVM 的内部运作机制(知其所以然),而本篇则补齐了工程视角的拼图——教你在线上怎么远远看见它的运作(出了事能预警、能缩小排查范围)。当看板把你领到某台机器门口、却答不上「具体是哪段代码写崩了」时,就该轮到 Arthas 这类在线诊断工具 登场了 —— 不重启、不改代码地直接钻进进程做现场探查。原理 → 监控 → 诊断,三者合起来才是从「会写 Java」到「扛得住线上」的完整技术闭环。

延伸阅读


–views
Share this post on:

Previous Post
JMM 与可见性:volatile、happens-before 规则与指令重排序
Next Post
JVM 深入(四):JDK 核心版本演进 —— 从 8 到 25 的 LTS 变迁