Java应用监控(9)-NMT堆外内存分析1

4,967 阅读6分钟

欢迎大家关注 github.com/hsfxuebao/j… ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

转自:coderbee.net/index.php/j…

一、基础知识

1.1. JVM 感知容器资源

Java 应用部署在 Kubernetes 集群里,每个容器只运行一个进程, JVM 的启动命令是打包在镜像文件里的。

常规的方式是采用 -Xmx4g -Xms2g 这样的参数来指定 JVM 堆的最大、最小尺寸,如果需要调整堆大小就需要重新打包镜像。

为了避免因为修改堆大小而重新打包,从 JDK 8u191 版本开始支持 JVM 感知容器资源限制,这样在调整 JVM 内存分配时就不需要重新打包镜像文件,采用下面的参数来使 JVM 在启动时感知到容器的资源限制,并设定堆的大小:

-XX:+UseCGroupMemoryLimitForHeap
-XX:InitialRAMPercentage=60.00
-XX:MaxRAMPercentage=80.00
-XX:MinRAMPercentage=60.00 

假如分配给容器的内存上限是 4G,那么上述配置,JVM 堆的初始大小和最小尺寸是 4G * 0.6 即 2.4G,最大尺寸是 4G * 0.8 即 3.2G。

1.2. JVM 被 oomkill

上面的配置运行一段时间后发现容器自动重启了,在 linux 下通过 dmesg 命令查看系统日志,可以看到类似下面的日志:

Aug  8 15:32:40 H-LDOCKER-01 kernel: [ pid ]   uid   tgid   total_vm      rss        nr_ptes   nr_pmds   swapents   oom_score_adj  name
Aug  8 15:32:40 H-LDOCKER-01 kernel: [33775]   1001  33775  7624373       2036828    4476      32        0          -998           java
Aug  8 15:32:40 H-LDOCKER-01 kernel: Memory cgroup out of memory: Kill process 33775 (java) score 0 or sacrifice child
Aug  8 15:32:40 H-LDOCKER-01 kernel: Killed process 33775 (java) total-vm:30497492kB, anon-rss:8134056kB, file-rss:13256kB

注意:上面日志 rss 列表示进程占用的内存大小,对应的值是 2036828,单位是 4KB,也即这个 Java 进程占用了 7.77G,容器分配的内存上限是 8G。第3、4行表示 Java 进程被 oom_killer 了。

OOM_killer 是 Linux 的一种自我保护措施,当系统内存不足时为防止出现严重问题,系统唤醒 oom_killer,挑出 /proc/<pid>/oom_score 值最大的进程并 kill。

因为应用也输出了 GC 日志,从进程被 kill 前的那个时间节点的日志来看,JVM 的堆是远远没有 7G 那么大的,多出来的其实是堆外内存。

1.3. JVM 堆外内存

JVM 的堆外内存主要包括:

  • JVM 自身运行占用的空间;
  • 线程栈分配占用的系统内存;
  • DirectByteBuffer 占用的内存;
  • JNI 里分配的内存;
  • Java 8 开始的元数据空间;
  • NIO 缓存
  • Unsafe 调用分配的内存;
  • codecache

冰山对象:冰山对象是指在 JVM 堆里占用的内存很小,但其实引用了一块很大的本地内存。DirectByteBuffer 和 线程都属于这类对象。

堆外内存泄漏一般很难通过 MAT 之类的工具来分析,必须通过操作系统层面的工具来。

二、NMT分析堆外内存

NMT(Native Memory Tracking)是 HotSpot JVM 引入的跟踪 JVM 内部使用的本地内存的一个特性,可以通过 jcmd 工具访问 NMT 数据。NMT 目前不支持跟踪第三方本地代码的内存分配和 JDK 类库。

NMT 不跟踪非 JVM 代码的内存分配,本地代码里的内存泄露需要使用操作系统支持的工具来定位。

2.1 开启 NMT

启用 NMT 会带来 5-10% 的性能损失。NMT 的内存使用率情况需要添加两个机器字 word 到 malloc 内存的 malloc 头里。NMT 内存使用率也被 NMT 跟踪。

启动命令: -XX:NativeMemoryTracking=[off | summary | detail]

off:NMT 默认是关闭的;
summary:只收集子系统的内存使用的总计数据;
detail:收集每个调用点的内存使用数据。

2.2 jcmd 访问 NMT 数据

命令: jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff | shutdown] [scale= KB | MB | GB]

OPTION

DESC

summary

按分类打印汇总数据

detail

按分类打印汇总数据
打印虚拟内存映射
按调用点打印内存使用汇总

baseling

创建内存使用快照用于后续对比

summary.diff

基于最新的基线打印一份汇总报告

detail.diff

基于最新的基线打印一份明细报告

shutdown

关闭 NMT

在 NMT 启用的情况下,可以通过下面的命令行选项在 JVM 退出时输出最后的内存使用数据:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics

2.3 使用 NMT 检测内存泄露

  1. 开启 NMT,用命令: -XX:NativeMemoryTracking=summary|detail
  2. 创建基线,用命令: jcmd <pid> VM.native_memory baseline
  3. 观察内存变化: jcmd <pid> VM.native_memory detail.diff

NMT 数据输出解释:

reserved memory:预订内存,不表示实际使用,最主要的是申请了一批连续的地址空间;(OS 角度)
commited memory:实际使用的。(OS 角度)
对于 64 位的系统,地址空间几乎是无限的,但越来越多的内存 committed,可能会导致 swapping 或本地 OOM 。

以下示例来自 docs.oracle.com/javase/8/do…

-XX:NativeMemoryTracking=summaryjcmd <pid> VM.native_memory summary 输出:

Total:  reserved=664192KB,  committed=253120KB  <--- total memory tracked by Native Memory Tracking

-     Java Heap (reserved=516096KB, committed=204800KB)  <--- Java Heap
                (mmap: reserved=516096KB, committed=204800KB)

-     Class (reserved=6568KB, committed=4140KB)     <--- class metadata
            (classes #665)                          <--- number of loaded classes
            (malloc=424KB, #1000)                   <--- malloc'd memory, #number of malloc
            (mmap: reserved=6144KB, committed=3716KB)

-     Thread (reserved=6868KB, committed=6868KB)
            (thread #15)                            <--- number of threads
            (stack: reserved=6780KB, committed=6780KB) <--- memory used by thread stacks
            (malloc=27KB, #66)
            (arena=61KB, #30)                       <--- resource and handle areas

-     Code (reserved=102414KB, committed=6314KB)
           (malloc=2574KB, #74316)
           (mmap: reserved=99840KB, committed=3740KB)

-     GC (reserved=26154KB, committed=24938KB)
           (malloc=486KB, #110)
           (mmap: reserved=25668KB, committed=24452KB)

-     Compiler (reserved=106KB, committed=106KB)
               (malloc=7KB, #90)
               (arena=99KB, #3)

-     Internal (reserved=586KB, committed=554KB)
               (malloc=554KB, #1677)
               (mmap: reserved=32KB, committed=0KB)

-     Symbol (reserved=906KB, committed=906KB)
             (malloc=514KB, #2736)
             (arena=392KB, #1)

-     Memory Tracking (reserved=3184KB, committed=3184KB)
                      (malloc=3184KB, #300)

-     Pooled Free Chunks (reserved=1276KB, committed=1276KB)
                         (malloc=1276KB)

-     Unknown (reserved=33KB, committed=33KB)
              (arena=33KB, #1)

-XX:NativeMemoryTracking=detailjcmd <pid> VM.native_memory detail 组合的输出示例:

三、 系统层面的分析思路

内存泄漏一般都不是突然猛增到极限,而是一个慢慢增长的过程,这样我们可以选取两个时间的内存来进行对比,看新增的内存里到底存的是什么内容。

3.0 gdb 方式

gdb 导出指定地址范围的内存块的内容 :

sudo gdb --batch --pid 2754 -ex "dump memory a.dump 0x7f1023ff6000 0x7f1023ff6000+268435456"

然后用 hexdump -C /tmp/memory.binstrings /tmp/memory.bin |less 查看内存块里的内容。

如果内存块里存的是文本信息,这样是可以看出存的是什么内容的,如果是二进制的内存,就没法看了。

3.1 jstack/jmap + core dump

先生成 core dump,然后从 core dump 里提取线程栈、JVM 堆 dump,JDK 8 下提取成功:

# 使用 gcore 命令生成 core dump,
gcore 1791

# 使用 jstack 从 core dump 文件提取线程信息
~/zuul-jdk/zulu8.40.0.25-ca-jdk8.0.222-linux_x64/bin/jstack ~/zuul-jdk/zulu8.40.0.25-ca-jdk8.0.222-linux_x64/bin/java core.1791

# 使用 jmap 从 core dump 文件提取 JVM 堆 dump
~/zuul-jdk/zulu8.40.0.25-ca-jdk8.0.222-linux_x64/bin/jmap -dump:format=b,file=zuul.jmap.hprof ~/zuul-jdk/zulu8.40.0.25-ca-jdk8.0.222-linux_x64/bin/java core.1791

# jstack、jmap 从 core dump 里提取信息的方式,exec 一般是指向可执行命令 java 的路径
jstack exec core-file
jmap <options> exec core-file

3.2 jhsdb

jhsdb: hsdb 是 HotSpot debugger 的简称,是 JDK9 开始引入的一个调试工具。

$ jhsdb
    clhsdb              command line debugger
    hsdb                ui debugger
    debugd --help       to get more information
    jstack --help       to get more information
    jmap   --help       to get more information
    jinfo  --help       to get more information
    jsnap  --help       to get more information

在 openJDK 11 提取实操失败了,生成堆 dump 时会出现一些内存地址读取失败。

用 jstack 从 core dump 提取信息:

sudo jstack -J-d64 /usr/bin/java core.2316

jhsdb jstack --exe /usr/bin/java --core core.2316

-d64 表示64位的系统,这两个也是网上找的,没有实际成功。

四、 参考资料