WEBKT

Spring Boot 3 整合 Native Memory Tracking (NMT) 监控 JVM 堆外内存并推送到 Grafana

6 0 0 0

在容器化时代,Java 应用因 OOMKilled 被系统强杀的现象屡见不鲜。很多时候,我们通过 JVM 监控发现堆内存(Heap)还非常充足,但容器的物理内存却已经触顶。这种“幽灵”般的内存泄漏,通常发生在堆外内存(Off-Heap Memory)

虽然 Spring Boot 3 默认集成的 Micrometer 提供了基础的 JVM 内存指标(如 Metaspace、Direct Buffer 等),但它无法全面覆盖 JVM 内部的本地内存分配(如 GC 消耗、线程栈、编译器缓存、Symbol 等)。为了彻底看清 JVM 本地内存的去向,我们需要启用 JDK 自带的 Native Memory Tracking (NMT) 诊断工具,并将其数据无缝接入 Prometheus 和 Grafana。

本文将介绍一种在 Spring Boot 3 (Java 17/21) 中,无需额外安装 Agent,直接通过内部 MBean 编程方式拉取 NMT 数据、转换为 Micrometer 指标并推送到 Grafana 的生产级解决方案。


为什么传统的 JVM 监控不够用?

标准的 Prometheus JVM Exporter 依靠 java.lang.management 接口获取内存数据。但这部分数据存在盲区,它无法监控以下关键的本地内存开销:

  1. GC 结构:垃圾回收器本身占用的内存(例如 G1 的 Remembered Sets、Mark Stack 等)。
  2. 线程栈-Xss 设定的每个线程的栈空间(高并发下数千个线程会白白占用数 GB 物理内存)。
  3. 代码生成器(Code Heap):JIT 编译器编译热点代码占用的物理内存。
  4. 符号表(Symbol):String Table 和 JVM 内部符号表占用的空间。

核心架构设计

直接在容器中通过 Shell 定期执行 jcmd <pid> VM.native_memory 并解析输出极其低效,且不便于多实例统一收集。

我们的解决方案是:

  1. JVM 级启用 NMT
  2. 通过 JMX 诊断命令 MBean:在 Java 代码内部安全、高性能地调用 VM.native_memory 诊断命令。
  3. 自定义 Micrometer MeterBinder:实时解析 JMX 返回的文本数据,将其转换为标准的 Prometheus Gauge 指标。
  4. 防刷缓存设计:为了避免频繁调用 JVM 诊断命令带来额外的 CPU 开销,我们设计了内置缓存,确保每次抓取的时间间隔(TTL)不低于 15 秒。

第一步:启用 JVM NMT 参数

首先,必须在 Spring Boot 应用启动时开启 NMT。推荐在 JVM 启动参数中加入以下配置:

-XX:NativeMemoryTracking=summary

注意:

  • 生产环境强烈建议使用 =summary 级别。如果使用 =detail,会带来明显的性能损耗(约 5%~10% 的 CPU 损耗),而 summary 级别的开销通常小于 1%。
  • 启用 NMT 会导致 JVM 自身内存占用稍微增加(属于正常现象)。

第二步:编写 NMT 指标收集器(Java 17/21 语法)

在 Spring Boot 3 项目中,编写一个自定义的 MeterBinder。它会利用 com.sun.management:type=DiagnosticCommand 这个 MBean 来获取 NMT 输出。

package com.example.monitor.nmt;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.management.MBeanServer;
import javax.management.ObjectName;
import java.lang.management.ManagementFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * JVM Native Memory Tracking (NMT) 指标绑定器
 */
public class NmtMetricsBinder implements MeterBinder {
    private static final Logger log = LoggerFactory.getLogger(NmtMetricsBinder.class);
    
    private final MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
    private ObjectName diagnosticCommandName;

    // 缓存解析结果,防止 Prometheus 频繁拉取时频繁调用 JMX
    private long lastUpdateTime = 0;
    private final Map<String, Double> metricsCache = new HashMap<>();
    private static final long CACHE_TTL_MS = TimeUnit.SECONDS.toMillis(15);

    // NMT 常见的分类
    private static final String[] NMT_CATEGORIES = {
            "Java Heap", "Class", "Thread", "Code", "GC", "Compiler", "Internal", 
            "Symbol", "Native Memory Tracking", "Arena Chunk", "Logging", "Arguments", "Module"
    };

    public NmtMetricsBinder() {
        try {
            this.diagnosticCommandName = new ObjectName("com.sun.management:type=DiagnosticCommand");
        } catch (Exception e) {
            log.error("初始化 NMT MBean 失败,请检查是否为 HotSpot JVM", e);
        }
    }

    @Override
    public void bindTo(MeterRegistry registry) {
        if (diagnosticCommandName == null) {
            return;
        }

        for (String category : NMT_CATEGORIES) {
            String sanitizedCategory = category.toLowerCase().replace(" ", ".");
            
            // 注册 Reserved 内存指标
            Gauge.builder("jvm.nmt." + sanitizedCategory + ".reserved", () -> getMetric(category, "reserved"))
                    .description("JVM NMT " + category + " reserved memory")
                    .baseUnit("bytes")
                    .register(registry);

            // 注册 Committed 内存指标
            Gauge.builder("jvm.nmt." + sanitizedCategory + ".committed", () -> getMetric(category, "committed"))
                    .description("JVM NMT " + category + " committed memory")
                    .baseUnit("bytes")
                    .register(registry);
        }
    }

    private synchronized double getMetric(String category, String type) {
        long now = System.currentTimeMillis();
        // 缓存失效后重新拉取
        if (now - lastUpdateTime > CACHE_TTL_MS) {
            refreshMetrics();
            lastUpdateTime = now;
        }
        return metricsCache.getOrDefault(category + "_" + type, 0.0);
    }

    private void refreshMetrics() {
        try {
            // 相当于执行 jcmd <pid> VM.native_memory summary
            String nmtOutput = (String) mBeanServer.invoke(
                    diagnosticCommandName,
                    "vmNativeMemory",
                    new Object[]{new String[]{"summary"}},
                    new String[]{"[Ljava.lang.String;"}
            );
            parseNmtOutput(nmtOutput);
        } catch (Exception e) {
            log.warn("拉取 NMT 数据失败,请确认 JVM 启动参数是否开启了 -XX:NativeMemoryTracking=summary. Error: {}", e.getMessage());
        }
    }

    private void parseNmtOutput(String output) {
        if (output == null || output.isBlank()) return;

        // 匹配模式:-  Class (reserved=1048576KB, committed=1048576KB)
        // 支持匹配各种内存单位 (KB, MB, GB)
        Pattern pattern = Pattern.compile("-\\s+([A-Za-z ]+)\\s+\\(reserved=(\\d+)(KB|MB|GB),\\s+committed=(\\d+)(KB|MB|GB)\\)");
        String[] lines = output.split("\n");

        for (String line : lines) {
            Matcher matcher = pattern.matcher(line.trim());
            if (matcher.find()) {
                String category = matcher.group(1).trim();
                long reservedVal = Long.parseLong(matcher.group(2));
                String reservedUnit = matcher.group(3);
                long committedVal = Long.parseLong(matcher.group(4));
                String committedUnit = matcher.group(5);

                metricsCache.put(category + "_reserved", (double) convertToBytes(reservedVal, reservedUnit));
                metricsCache.put(category + "_committed", (double) convertToBytes(committedVal, committedUnit));
            }
        }
    }

    private long convertToBytes(long value, String unit) {
        return switch (unit) {
            case "KB" -> value * 1024;
            case "MB" -> value * 1024 * 1024;
            case "GB" -> value * 1024 * 1024 * 1024;
            default -> value;
        };
    }
}

第三步:注入 Spring IoC 容器

创建配置类,将自定义的 NmtMetricsBinder 注册为 Spring Bean。Spring Boot 3 旗下的 Micrometer 会自动发现所有 MeterBinder 实例并将其绑定到默认的 Registry 中。

package com.example.monitor.nmt;

import io.micrometer.core.instrument.binder.MeterBinder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnClass(MeterBinder.class)
public class NmtMonitorConfiguration {

    @Bean
    public MeterBinder nmtMetricsBinder() {
        return new NmtMetricsBinder();
    }
}

第四步:验证指标生成

启动服务后,首先确保应用暴露了 Prometheus 端点。如果没有配置,请在 application.yml 中添加:

management:
  endpoints:
    web:
      exposure:
        include: prometheus

访问本机的 Actuator 路径(例如 http://localhost:8080/actuator/prometheus),并搜索 jvm_nmt。你应该可以看到如下指标输出:

# HELP jvm_nmt_gc_committed_bytes JVM NMT GC committed memory
# TYPE jvm_nmt_gc_committed_bytes gauge
jvm_nmt_gc_committed_bytes 184549376.0
# HELP jvm_nmt_gc_reserved_bytes JVM NMT GC reserved memory
# TYPE jvm_nmt_gc_reserved_bytes gauge
jvm_nmt_gc_reserved_bytes 184549376.0
# HELP jvm_nmt_thread_committed_bytes JVM NMT Thread committed memory
# TYPE jvm_nmt_thread_committed_bytes gauge
jvm_nmt_thread_committed_bytes 45219840.0
# HELP jvm_nmt_thread_reserved_bytes JVM NMT Thread reserved memory
# TYPE jvm_nmt_thread_reserved_bytes gauge
jvm_nmt_thread_reserved_bytes 1258291200.0

第五步:Grafana 监控看板配置

一旦 Prometheus 成功抓取到这些指标,我们就可以在 Grafana 中绘制可视化仪表盘。以下是几个推荐配置的面板:

1. 堆外总提交内存 (Committed Non-Heap Total)

计算除 Java 堆(Java Heap)以外,JVM 真正向操作系统申请并分配的所有 Committed 内存总和:

sum(jvm_nmt_*_committed_bytes{job="your-app-job"}) - jvm_nmt_java_heap_committed_bytes{job="your-app-job"}

2. GC 占用内存趋势 (GC Overhead Memory)

展现垃圾回收器本身占用的内存(非常有助于诊断因为 G1 或者是 ZGC 配置不当导致的堆外内存暴涨):

jvm_nmt_gc_committed_bytes{job="your-app-job"}

3. 线程栈消耗内存 (Thread Stack Memory)

监控系统内部线程数量增加导致的物理内存消耗:

jvm_nmt_thread_committed_bytes{job="your-app-job"}

4. 堆外内存各个分区占比(饼图)

在 Grafana 中添加一个 Pie Chart,使用以下 Query,展示不同分区的堆外内存占用:

  • Query A: jvm_nmt_class_committed_bytes{job="your-app-job"} (Label: Class)
  • Query B: jvm_nmt_gc_committed_bytes{job="your-app-job"} (Label: GC)
  • Query C: jvm_nmt_thread_committed_bytes{job="your-app-job"} (Label: Thread)
  • Query D: jvm_nmt_compiler_committed_bytes{job="your-app-job"} (Label: Compiler)
  • Query E: jvm_nmt_internal_committed_bytes{job="your-app-job"} (Label: Internal)

生产环境运维经验分享

  1. JMX 权限限制:在部分高度安全化或者自研容器环境中,DiagnosticCommand 可能会被限制。如果抛出 SecurityException,需要确保执行应用的用户具有访问诊断 MBean 的权限。
  2. 警惕 JDK 版本的输出格式差异:虽然本文编写的正则能够兼容大部分主流 JDK 17 和 JDK 21(如 Temurin、OpenJDK、Oracle JDK),但如果使用的是魔改过的国产品牌 JDK,建议先在本地运行 jcmd VM.native_memory summary 检查输出格式是否吻合。
  3. 缓存 TTL:由于拉取 NMT 依然是一次轻量级的 JVM 安全点(Safepoint)操作,不推荐将缓存过期时间设置得过短(例如小于 5 秒)。设置为 15 到 30 秒能完美平衡监控实时性与系统开销。
码道探针 JVM性能优化Grafana监控

评论点评