WEBKT

用 eBPF 精准定位 JVM 缺页中断(Page Fault)的实践指南

1 0 0 0

在 JVM 性能调优的深水区,很多开发者都会遇到一些“幽灵抖动”:GC 日志显示回收只花了 5 毫秒,但应用层监控(如 APM 拦截器)却记录了超过 100 毫秒的卡顿;或者伴随着物理机 CPU Sys 占比莫名增高,JVM 进程的 RSS 内存出现异常阶梯式上涨。

这种“应用层无感知、系统层被动承压”的现象,背后最常见的推手之一就是内核缺页中断(Page Fault)

传统的 JVM 监控手段(如 JMX、jstat)止步于用户态,对这种内核层面的事件无能为力。而借助 eBPF(Extended Berkeley Packet Filter),我们可以在不侵入 JVM 源码、不重启服务的情况下,实现微秒级的缺页中断来源追踪。本文将完整梳理基于 eBPF 监控 JVM 缺页中断的具体技术路径。


一、 为什么 JVM 会频繁发生 Page Fault?

当 JVM 申请内存时(例如 new Object() 或初始化物理堆),操作系统出于效率考虑,通常只分配了一段虚拟内存地址,并没有真正映射到物理内存。

只有当 JVM 线程第一次对这块地址进行“读/写”操作时,CPU 硬件才会触发一个缺页异常(Page Fault),暂停当前线程,将控制权交给 Linux 内核。内核负责分配物理页面并建立 MMU 映射,然后再恢复 JVM 线程。

在 JVM 运行期间,以下场景会密集触发 Page Fault:

  1. 未启用 AlwaysPreTouch:JVM 启动时只分配虚拟内存,随着业务流量涌入,堆内存按需进行物理分配,导致服务刚上线时 RT 出现大面积毛刺。
  2. MappedByteBuffer 频繁读写:Java 使用 NIO 进行大文件读写(如 RocketMQ/Kafka 的 CommitLog)时,严重依赖 mmap。如果物理内存不足,Page Cache 被回收,后续读写将产生大量的 Major Page Fault(硬缺页,需要读盘)。
  3. 动态类加载与 JIT 编译:元空间(Metaspace)的频繁扩容,或 JIT 编译器动态生成本地机器码并写入 CodeCache,都会触发 Minor Page Fault(软缺页,无需读盘,但存在内核态切换开销)。
  4. 宿主机内存挤压(OS Swap):当系统内存紧张触发 Swap 时,JVM 堆内存被置换到磁盘,垃圾回收器(GC)一旦启动扫描,会引发排山倒海般的硬缺页中断。

二、 eBPF 监控的技术栈选型

要在内核态捕捉 Page Fault 并在用户态关联 JVM 线程,我们需要解决两个核心问题:事件捕获符号化解析

+-------------------------------------------------------------+
|                        JVM (用户态)                         |
|   Java 线程 -> 调用栈 -> JIT 符号表 (/tmp/perf-PID.map)     |
+-------------------------------------------------------------+
                               | (用户态/内核态映射)
+-------------------------------------------------------------+
|                       Kernel (内核态)                       |
|   Tracepoint (exceptions:page_fault_user)  --> eBPF Prog    |
+-------------------------------------------------------------+

1. 监测点的选择

Linux 内核提供了专门的 Tracepoint 来观测缺页中断。我们主要关注用户态触发的缺页:

  • tracepoint:exceptions:page_fault_user:当用户态程序(如 JVM 线程)发生缺页中断时触发。

之所以不选用 kprobes(如 kprobe:handle_mm_fault),是因为 Tracepoint 更加稳定,不会随着内核版本的微调而失效,且运行时开销极低。

2. 用户态符号化的难点

eBPF 处于内核态,它在捕获事件时,只能拿到当前进程的 pid、线程 tid、指令指针 instruction_pointer(IP)以及虚拟内存地址。

对于 Java 应用,JIT(即时编译器)会在运行期动态生成机器码。内核只知道某个虚拟地址(IP)发生了解析,但无法通过标准的 /proc/PID/maps 找到其对应的 Java 类名和方法名。

解决方案:利用 JVM 的 Perf Map 机制。
通过向 JVM 进程注入 Agent(如 async-profiler 的 helper,或使用 -XX:+PreserveFramePointer 参数),在 /tmp/perf-<pid>.map 中实时生成虚拟地址到 Java 方法名的映射表。eBPF 用户态程序读取该文件即可完成“地址 -> 方法名”的翻译。


三、 实践路径:四步构建监控链

下面我们以 bpftrace 作为快速验证工具,给出具体的实操步骤。

第一步:准备 JVM 环境

为了让 eBPF 能解析出清晰的 Java 堆栈,启动 JVM 时需加上以下参数:

java -XX:+UnlockDiagnosticVMOptions \
     -XX:+DebugNonSafepoints \
     -XX:+PreserveFramePointer \
     -jar your-application.jar
  • -XX:+PreserveFramePointer:保证 CPU 寄存器(RBP)保存栈帧指针,使 eBPF 能够顺着栈帧回溯出完整的用户态调用栈。

第二步:生成 JIT 符号映射表

下载 async-profiler,并针对目标 JVM 进程生成映射文件,这样 bpftrace 就能翻译动态生成的 JIT 代码:

# 假设 JVM 进程 PID 为 12345
./asprof.sh -d 30 -f /tmp/noop.txt 12345

运行后,在 /tmp 目录下会生成 /tmp/perf-12345.map

第三步:编写并运行 bpftrace 脚本

编写一个简易的 eBPF 监控脚本 jvm_pf.bt,用于捕获该 JVM 进程的 Page Fault 频次及对应的方法栈:

#!/usr/bin/env bpftrace

/* 过滤指定 JVM 进程的 page_fault_user 事件 */
tracepoint:exceptions:page_fault_user
/pid == $1/
{
    // 以用户态调用栈为 Key,统计发生 Page Fault 的次数
    @[ustack] = count();
}

interval:s:10
{
    // 每 10 秒打印一次统计结果并退出
    exit();
}

运行该脚本(需要 root 权限,传入 JVM PID 作为参数):

bpftrace jvm_pf.bt 12345

第四步:分析输出结果

bpftrace 会在终端输出类似如下的堆栈火焰图数据(或直接打印栈帧):

@[
    # 发生缺页时的用户态调用栈
    0x7f9a12bc4f1a              # 某个共享库中的地址
    Interpreter                 # JVM 解释器
    java.io.RandomAccessFile.readBytes (offset 12)
    java.io.RandomAccessFile.read (offset 4)
    org.apache.rocketmq.store.logfile.MappedFile.commit (MappedFile.java:312)
    # ... 省略中间调用栈 ...
    Thread::run+0x3d
]: 14208

数据解读
上面这组数据非常清晰地暴露出:在过去的 10 秒内,MappedFile.commit 方法由于读取操作,触发了 14208 次 用户态缺页中断。这说明该 MappedByteBuffer 对应的物理内存并未常驻,内核正在高频地为其分配物理页面。


四、 进阶:如何生产级工程化?

单机调试用 bpftrace 非常方便,但在生产大规模部署时,我们需要更系统化的方案:

  1. 改用 BCC 或 libbpf
    使用 Python/C 编写 eBPF 程序。内核态程序负责将 (tid, address, ip) 写入 BPF_MAP_TYPE_PERF_EVENT_ARRAY(或 Ring Buffer);用户态守护进程(Daemon)消费该 Buffer,并结合实时解析的 /tmp/perf-PID.map 进行符号化。

  2. 区分 Major 与 Minor Page Fault

    • 监控 tracepoint:exceptions:page_fault_user 时,可以通过内核结构体中的错误代码(error_code)来识别是硬缺页(需要 I/O)还是软缺页。
    • 或者通过挂载内核函数 handle_mm_fault,统计其返回状态。若返回包含 VM_FAULT_MAJOR,则判定为硬缺页。
  3. 控制观测开销
    在流量极高、内存申请极度频繁的 JVM 上,Page Fault 事件可能会以每秒数十万次的速度爆发。

    • 生产防护:在 eBPF 内核态做聚合(如使用 Map 记录 [stack_id, count]),不要把每次 Page Fault 事件都往用户态抛发送,避免因 Ring Buffer 爆仓丢包,或者导致守护进程把用户态 CPU 吃满。

五、 常见的排查治理手段

通过 eBPF 确认了 JVM 发生缺页中断的调用栈后,该如何根治?

  • 针对堆扩容引起的缺页
    在 JVM 启动参数中务必加入 -XX:+AlwaysPreTouch。它会在 JVM 启动阶段把所有分配的堆内存每一个字节都写一遍 0,提前强制触发物理内存分配。虽然这会延长服务启动时间(大约每 10GB 堆需要 1~2 秒),但能完美消除运行时由于堆扩容带来的 RT 毛刺。
  • 针对大文件 mmap 引起的缺页
    若应用频繁使用 MappedByteBuffer,可以考虑通过 JNI 调用系统 API mlock(),将该段虚拟内存锁定在物理内存中,防止其被系统 Swap 出去。
  • 针对 Swap 引起的缺页
    调整 Linux 内核参数 vm.swappiness = 1(避免积极使用 Swap),并确保 JVM 的 -Xms-Xmx 设为一致,防止内存动态伸缩与系统内存回收产生冲突。
内核观测者 eBPFJVM 性能调优缺页中断

评论点评