Spring Boot 3 整合 Native Memory Tracking (NMT) 监控 JVM 堆外内存并推送到 Grafana
在容器化时代,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 接口获取内存数据。但这部分数据存在盲区,它无法监控以下关键的本地内存开销:
- GC 结构:垃圾回收器本身占用的内存(例如 G1 的 Remembered Sets、Mark Stack 等)。
- 线程栈:
-Xss设定的每个线程的栈空间(高并发下数千个线程会白白占用数 GB 物理内存)。 - 代码生成器(Code Heap):JIT 编译器编译热点代码占用的物理内存。
- 符号表(Symbol):String Table 和 JVM 内部符号表占用的空间。
核心架构设计
直接在容器中通过 Shell 定期执行 jcmd <pid> VM.native_memory 并解析输出极其低效,且不便于多实例统一收集。
我们的解决方案是:
- JVM 级启用 NMT。
- 通过 JMX 诊断命令 MBean:在 Java 代码内部安全、高性能地调用
VM.native_memory诊断命令。 - 自定义 Micrometer
MeterBinder:实时解析 JMX 返回的文本数据,将其转换为标准的 Prometheus Gauge 指标。 - 防刷缓存设计:为了避免频繁调用 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)
生产环境运维经验分享
- JMX 权限限制:在部分高度安全化或者自研容器环境中,
DiagnosticCommand可能会被限制。如果抛出SecurityException,需要确保执行应用的用户具有访问诊断 MBean 的权限。 - 警惕 JDK 版本的输出格式差异:虽然本文编写的正则能够兼容大部分主流 JDK 17 和 JDK 21(如 Temurin、OpenJDK、Oracle JDK),但如果使用的是魔改过的国产品牌 JDK,建议先在本地运行
jcmd VM.native_memory summary检查输出格式是否吻合。 - 缓存 TTL:由于拉取 NMT 依然是一次轻量级的 JVM 安全点(Safepoint)操作,不推荐将缓存过期时间设置得过短(例如小于 5 秒)。设置为 15 到 30 秒能完美平衡监控实时性与系统开销。