WEBKT

深入 JVM 堆外内存监控:基于 Prometheus 与 Grafana 的排障与落地实践

2 0 0 0

在容器化(Docker/Kubernetes)时代,许多 Java 开发者都遇到过进程被系统 OOM Killed 的诡异现象:明明 JVM 堆内存(Heap)非常充足,甚至远未达到触发 Full GC 的阈值,但整个容器的内存使用率却触顶了。

这种现象背后,绝大多数原因都是 JVM 堆外内存(Off-Heap Memory) 泄露或者配置不当。传统的 jstatjmap 只能让我们看清堆内情况,而要看清堆外内存,我们需要一套完整的、可长期监控的 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  | |
| +-------------------------+ +-----------------------------------------------+ |
+-------------------------------------------------------------------------------+
  1. 直接内存(Direct Memory): 主要由 NIO(如 Netty、gRPC、HTTP 客户端)通过 ByteBuffer.allocateDirect() 分配。受 -XX:MaxDirectMemorySize 参数限制。
  2. 本地内存(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 提交内存趋势,一目了然看清到底是哪个部分蚕食了容器内存。

五、 生产排查:如何定位堆外泄漏?

有了监控,我们能够第一时间发现故障。一旦报警被触发,可以按照以下标准排查手册进行定位:

  1. 观察 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 直接向操作系统申请内存。
  2. 核心工具链诊断命令:

    • 打印 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
      

六、 总结

堆外内存监控是打通 JVM 诊断“最后一公里”的必经之路。通过结合 Spring Boot Actuator 与 JVM NMT 机制,并借助 Prometheus 将其常态化采集,我们不仅能在 Grafana 上实时监控这些隐藏在冰山之下的内存状态,更能在进程崩溃前及时预警,真正做到故障的提前发现与快速收敛。

SRE拓荒者 JVMPrometheus堆外内存监控

评论点评