深入 JVM 堆外内存监控:基于 Prometheus 与 Grafana 的排障与落地实践
在容器化(Docker/Kubernetes)时代,许多 Java 开发者都遇到过进程被系统 OOM Killed 的诡异现象:明明 JVM 堆内存(Heap)非常充足,甚至远未达到触发 Full GC 的阈值,但整个容器的内存使用率却触顶了。
这种现象背后,绝大多数原因都是 JVM 堆外内存(Off-Heap Memory) 泄露或者配置不当。传统的 jstat、jmap 只能让我们看清堆内情况,而要看清堆外内存,我们需要一套完整的、可长期监控的 Prometheus + Grafana 方案。
本文将拆解如何监控 JVM 堆外内存的两个核心维度:直接内存(Direct Memory) 与 本地内存(Native Memory),并给出可直接在生产环境落地的数据采集、Prometheus 配置、Grafana 仪表盘配置以及预警规则。
一、 JVM 堆外内存分类与监控痛点
在开始配置之前,我们必须厘清堆外内存的构成:
+-------------------------------------------------------------------------------+
| JVM Process Memory |
| +-------------------------+ +-----------------------------------------------+ |
| | Heap Memory | | Off-Heap Memory | |
| | (Young / Old Gen, etc.) | | Metaspace | Thread Stacks | Direct Buffer | |
| | | | GC Engines | Code Cache | JNI / C Heap | |
| +-------------------------+ +-----------------------------------------------+ |
+-------------------------------------------------------------------------------+
- 直接内存(Direct Memory): 主要由 NIO(如 Netty、gRPC、HTTP 客户端)通过
ByteBuffer.allocateDirect()分配。受-XX:MaxDirectMemorySize参数限制。 - 本地内存(Native Memory): 包含元空间(Metaspace)、线程栈(Thread Stack)、Jit 代码缓存(Code Cache)、GC 数据结构、JNI 分配的 C-Heap 等。不受
-XX:MaxDirectMemorySize限制,仅受物理内存/容器内存限制。
监控痛点:
- 标准的 JMX 只能比较方便地拿到 Direct Memory 的指标。
- Native Memory 必须开启 JVM 自带的 NMT(Native Memory Tracking)工具,但 NMT 的数据默认只能通过
jcmd命令行交互查看,无法直接输出给 Prometheus。
二、 方案设计与指标采集
我们将通过两种路径实现完整的堆外监控:
- 路径 A(Direct Memory):利用 Spring Boot Actuator 或 JMX Exporter 自动暴露指标。
- 路径 B(Native Memory):通过开启 JVM NMT 并利用自定义 Exporter(或脚本)将
jcmd数据转化为 Prometheus 标准指标。
1. 直接内存(Direct Memory)的采集落地
方案 1:如果你的应用是 Spring Boot (Micrometer 体系)
Spring Boot 2.x/3.x 默认集成的 Micrometer 已经内置了 JVM 缓冲池指标,无需额外开发。
- 访问端点:
/actuator/prometheus - 对应 Prometheus 指标:
jvm_buffer_memory_used_bytes{id="direct"}(已使用的直接内存)jvm_buffer_total_capacity_bytes{id="direct"}(直接内存总容量上限)jvm_buffer_count_total{id="direct"}(直接内存 Buffer 计数)
方案 2:如果是传统 Java 应用 (使用 Prometheus JMX Exporter)
如果你使用的是 jmx_prometheus_javaagent,需要在配置文件中加入对 java.nio.BufferPool 规则的解析:
# jmx_exporter_config.yaml
rules:
- pattern: 'java.nio<type=BufferPool, name=direct><>Count'
name: jvm_buffer_pool_direct_count
type: GAUGE
- pattern: 'java.nio<type=BufferPool, name=direct><>MemoryUsed'
name: jvm_buffer_pool_direct_used_bytes
type: GAUGE
- pattern: 'java.nio<type=BufferPool, name=direct><>TotalCapacity'
name: jvm_buffer_pool_direct_total_capacity_bytes
type: GAUGE
2. 本地内存(Native Memory)的采集落地
要获取元空间、GC 结构、线程等底层的本地内存,必须启用 JVM 的 NMT(Native Memory Tracking)。
第一步:启动参数配置
在 Java 启动参数中加入以下配置。
注意:生产环境建议使用
summary级别,对性能损耗极小(约 1%~2%)。切忌使用detail,会有明显的 CPU 开销。
java -XX:NativeMemoryTracking=summary -XX:MaxDirectMemorySize=1G -jar app.jar
第二步:通过自定义 Exporter 获取 NMT 指标
由于 JVM 没把 NMT 放入标准 JMX MBean,目前主流落地方式有两种:
- 方式(一):使用开源的 jcmd-exporter (推荐生产使用)
社区有成熟的 Sidecar 镜像或 Agent 能够定时拉取jcmd <pid> VM.native_memory并解析。 - 方式(二):写一个定时任务 Shell 脚本 + Prometheus Pushgateway
在容器内运行一个 Cron 任务或后台死循环脚本,定期生成指标推送到 Pushgateway。下面是一个核心解析脚本示例:
#!/bin/bash
# nmt_exporter.sh
PID=$(pgrep -f "app.jar") # 替换为你的Java进程特征
if [ -z "$PID" ]; then exit 1; fi
# 执行 jcmd 获取 NMT 数据
NMT_OUT=$(jcmd $PID VM.native_memory summary)
# 提取关键数据并格式化为 Prometheus 指标
# 提取 Class 内存 (Metaspace + Class Space)
METASPACE_USED=$(echo "$NMT_OUT" | grep -A 1 "Class" | grep "committed" | awk '{print $3}' | sed 's/KB//g')
# 提取 Thread 内存
THREAD_USED=$(echo "$NMT_OUT" | grep -A 1 "Thread" | grep "committed" | awk '{print $3}' | sed 's/KB//g')
# 提取 GC 内存
GC_USED=$(echo "$NMT_OUT" | grep -A 1 "GC" | grep "committed" | awk '{print $3}' | sed 's/KB//g')
# 提取 Internal 内存 (如 Unsafe.allocateMemory 分配的)
INTERNAL_USED=$(echo "$NMT_OUT" | grep -A 1 "Internal" | grep "committed" | awk '{print $3}' | sed 's/KB//g')
# 将 KB 转换为 Bytes,并输出给 Node Exporter Textfile 目录或 Pushgateway
PROM_DIR="/var/lib/node_exporter/textfile_collector"
echo "jvm_nmt_class_committed_bytes $((METASPACE_USED * 1024))" > $PROM_DIR/jvm_nmt.prom
echo "jvm_nmt_thread_committed_bytes $((THREAD_USED * 1024))" >> $PROM_DIR/jvm_nmt.prom
echo "jvm_nmt_gc_committed_bytes $((GC_USED * 1024))" >> $PROM_DIR/jvm_nmt.prom
echo "jvm_nmt_internal_committed_bytes $((INTERNAL_USED * 1024))" >> $PROM_DIR/jvm_nmt.prom
(注:Node Exporter 的 textfile 模块可以非常轻量地读取本地 .prom 文件并将其转化为 Prometheus 标准指标。)
三、 Prometheus 报警规则设计
当指标收集完毕后,我们需要配置预警规则(Alert Rules),防止堆外内存将宿主机或容器物理内存撑爆。
1. 直接内存接近上限报警
如果直接内存设置了 -XX:MaxDirectMemorySize=1G(假设此处配置为 1073741824 字节),如果使用率长期超过 85%,说明存在 Netty 内存泄露或连接未释放。
groups:
- name: jvm_off_heap_alerts
rules:
- alert: JVMDirectMemoryHighUsage
expr: jvm_buffer_memory_used_bytes{id="direct"} / jvm_buffer_total_capacity_bytes{id="direct"} > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "JVM 直接内存(Direct Memory)使用率过高 (Instance: {{ $labels.instance }})"
description: "当前直接内存已用 {{ $value | humanize1024 }},超过分配上限的 85%,可能存在内存泄漏。"
2. 线程数激增导致本地内存吃满
每个线程栈默认占用 1MB(-Xss1m)。如果应用无节制地创建线程,Native Memory 很快就会干掉物理机。
- alert: JVMThreadCountSpike
expr: jvm_threads_live_threads > 1500
for: 2m
labels:
severity: warning
annotations:
summary: "JVM 活动线程数异常飙升 (Instance: {{ $labels.instance }})"
description: "当前活动线程数达到 {{ $value }},可能导致 JVM 本地内存(Thread Stack)过载。"
四、 Grafana 监控大屏配置
在 Grafana 中,我们可以新建一个 “JVM Off-Heap Diagnostics” 面板。以下是推荐配置的核心图表:
Panel 1: 直接内存趋势图 (Direct Memory Trend)
- Type: Time Series
- PromQL Query:
sum(jvm_buffer_memory_used_bytes{id="direct", instance=~"$instance"}) by (instance) - Unit: bytes (IEC)
- Description: 监控 DirectBuffer 动态变化,重点观察波峰后是否回落,若一条直线平稳上升通常代表连接未正常 close 造成的内存泄漏。
Panel 2: 元空间与本地内存明细 (NMT Breakdown)
- Type: Stacked Area Chart (堆叠面积图)
- PromQL Query:
- 元空间:
jvm_nmt_class_committed_bytes - 线程栈:
jvm_nmt_thread_committed_bytes - GC 数据区:
jvm_nmt_gc_committed_bytes - 内部数据:
jvm_nmt_internal_committed_bytes
- 元空间:
- Unit: bytes (IEC)
- Description: NMT 提交内存趋势,一目了然看清到底是哪个部分蚕食了容器内存。
五、 生产排查:如何定位堆外泄漏?
有了监控,我们能够第一时间发现故障。一旦报警被触发,可以按照以下标准排查手册进行定位:
观察 Grafana 指标类型:
jvm_buffer_memory_used_bytes上升 $\rightarrow$ 重点排查 Netty、自定义 NIO 传输、客户端连接池(如 HTTP/gRPC、Redis 客户端)。jvm_nmt_class_committed上升 $\rightarrow$ 动态代理或反射生成了太多的动态类(例如过度使用 cglib,或者不断重复加载同一张热更脚本)。jvm_nmt_internal_committed上升 $\rightarrow$ 检查是否有 JNI 代码,或者第三方 C 库通过 Unsafe 绕过 JVM 直接向操作系统申请内存。
核心工具链诊断命令:
- 打印 NMT 增量差异(黄金命令):
# 1. 建立基准 jcmd <pid> VM.native_memory baseline # 2. 运行一段时间后对比基准,直观看到哪一部分内存涨了 jcmd <pid> VM.native_memory detail.diff - 诊断 C 层堆内存泄漏工具:
# 使用 gdb、jemalloc 或 valgrind 分析 C 堆 MALLOC_CONF="prof:true,lg_prof_interval:30" java -jar app.jar
- 打印 NMT 增量差异(黄金命令):
六、 总结
堆外内存监控是打通 JVM 诊断“最后一公里”的必经之路。通过结合 Spring Boot Actuator 与 JVM NMT 机制,并借助 Prometheus 将其常态化采集,我们不仅能在 Grafana 上实时监控这些隐藏在冰山之下的内存状态,更能在进程崩溃前及时预警,真正做到故障的提前发现与快速收敛。