[转帖]Native Memory Tracker

济南小老虎 / 2024-06-03 / 原文

https://zhuanlan.zhihu.com/p/669599050

 

Native Memory Tracking 概念
Native Memory Tracking 的开启
Native Memory Tracking 的使用
分析NMT summary 信息组成
1. Total: reserved=12491MB, committed=10954MB
2. Java Heap
3. Metaspace
4. 符号 Symbol
5. 线程 Thread
6. Code Cache
7. Arena
8. Native Memory Tracking
9. Serviceability
10. GC
11. Internal、Other
12. String Deduplication
13. Tracing
14. Logging
15. Arguments
16. Safepoint
17. Synchronization
监控时间段
smaps_rollup
监控时间段
问题排查手段:虚拟机退出时获取 NMT 数据

背景

今天遇到了线上环境的容器服务宕机现象,怀疑是内存泄漏导致被docker kill。借用Native Memory Tracker工具查看内存使用情况。

Native Memory Tracking 概念

JVM中的本地内存追踪NMT: Native Memory Tracking。

这个是官方提供的一个查看 JVM 内存占用的工具引入。不过要注意的一点是,这个只能监控 JVM 原生申请的内存大小,如果是通过 JDK 封装的系统 API 申请的内存,是统计不到的,例如 Java JDK 中的 DirectBuffer 以及 MappedByteBuffer 这两个。以及封装 JNI 调用系统调用去申请内存,都是 Native Memory Tracking 无法涵盖的。

Native Memory Tracking 的开启

Native Memory Tracking 默认是不开启的,并且无法动态开启(因为NMT的实现方式是埋点采集统计,如果可以动态开启那么没开启的时候的内存分配没有记录无法知晓,所以无法动态开启),目前只能通过在启动 JVM 的时候通过启动参数开启。即通过 -XX:NativeMemoryTracking 开启:

-XX:NativeMemoryTracking=off|summary|detail
注意:启用NMT会导致5% -10%的性能开销。

 

  • -XX:NativeMemoryTracking=off:这是默认值,即关闭 Native Memory Tracking
  • -XX:NativeMemoryTracking=summary: 开启 Native Memory Tracking,但是仅仅按照各个 JVM 子系统去统计内存占用情况
  • -XX:NativeMemoryTracking=detail:开启 Native Memory Tracking,从每次 JVM 中申请内存的不同调用路径的维度去统计内存占用情况。注意,开启 detail 比开启 summary 的消耗要大不少,因为 detail 每次都要解析 CallSite 分辨调用位置。

 

Native Memory Tracking 的使用

开启之后,可以通过 jcmd 命令去查看 Native Memory Tracking 的即时快照信息,即jcmd <pid> VM.native_memory

jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff | shutdown] [scale= KB | MB | GB]
  • jcmd <pid> VM.native_memory或者jcmd <pid> VM.native_memory summary:两者是等价的,即查看 Native Memory Tracking 的 summary 信息。默认单位是 KB,可以指定单位为其他,例如 jcmd <pid> VM.native_memory summary scale=MB
  • jcmd <pid> VM.native_memory detail:查看 Native Memory Tracking 的 detail 信息,包括 summary 信息,以及按照虚拟内存映射分组的内存使用信息,还有按照不同 CallSite 调用分组的内存使用情况。默认单位是 KB,可以指定单位为其他,例如 jcmd <pid> VM.native_memory detail scale=MB

假设应用的 VMOption 配置如下:

-XX:NativeMemoryTracking=summary -Xms10240m -Xmx10240m -XX:+UseG1GC

如何获取 pid?

为了找到一个JVM应用程序的PID,我们使用jps命令: jps -l

 

分析NMT summary 信息组成

$ jcmd 19544 VM.native_memory summary scale=MB
19544:

Native Memory Tracking:

(Omitting categories weighting less than 1MB)

Total: reserved=12491MB, committed=10954MB
       malloc: 121MB #730024
       mmap:   reserved=12370MB, committed=10833MB

-                 Java Heap (reserved=10240MB, committed=10240MB)
                            (mmap: reserved=10240MB, committed=10240MB) 
 
-                     Class (reserved=1026MB, committed=15MB)
                            (classes #19552)
                            (  instance classes #18434, array classes #1118)
                            (malloc=2MB #49338) 
                            (mmap: reserved=1024MB, committed=12MB) 
                            (  Metadata:   )
                            (    reserved=96MB, committed=89MB)
                            (    used=88MB)
                            (    waste=1MB =0.88%)
                            (  Class space:)
                            (    reserved=1024MB, committed=12MB)
                            (    used=12MB)
                            (    waste=1MB =5.72%)
 
-                    Thread (reserved=341MB, committed=34MB)
                            (thread #341)
                            (stack: reserved=340MB, committed=33MB)
                            (malloc=1MB #2048) 
 
-                      Code (reserved=245MB, committed=37MB)
                            (malloc=3MB #12204) 
                            (mmap: reserved=242MB, committed=34MB) 
 
-                        GC (reserved=451MB, committed=451MB)
                            (malloc=38MB #14413) 
                            (mmap: reserved=412MB, committed=412MB) 
 
-                  Compiler (reserved=1MB, committed=1MB)
                            (malloc=1MB #1126) 
 
-                  Internal (reserved=3MB, committed=3MB)
                            (malloc=3MB #53489) 
 
-                     Other (reserved=34MB, committed=34MB)
                            (malloc=34MB #170) 
 
-                    Symbol (reserved=21MB, committed=21MB)
                            (malloc=19MB #557798) 
                            (arena=2MB #1)
 
-    Native Memory Tracking (reserved=11MB, committed=11MB)
                            (tracking overhead=11MB)
 
-        Shared class space (reserved=16MB, committed=12MB)
                            (mmap: reserved=16MB, committed=12MB) 
 
-               Arena Chunk (reserved=3MB, committed=3MB)
                            (malloc=3MB) 
 
-                   Tracing (reserved=32KB, committed=32KB)
                            (arena=32KB #1)
  
-                   Logging (reserved=5KB, committed=5KB)
                            (malloc=5KB #216) 
 
-                 Arguments (reserved=31KB, committed=31KB)
                            (malloc=31KB #90) 

-                    Module (reserved=1690KB, committed=1690KB)
                            (malloc=1690KB #6811) 
 
-                 Safepoint (reserved=8KB, committed=8KB)
                            (mmap: reserved=8KB, committed=8KB) 
 
-           Synchronization (reserved=500KB, committed=500KB)
                            (malloc=500KB #4576) 
 
-            Serviceability (reserved=2MB, committed=2MB)
                            (malloc=2MB #31062) 
 
-                 Metaspace (reserved=97MB, committed=90MB)
                            (malloc=1MB #517) 
                            (mmap: reserved=96MB, committed=89MB)

-      String Deduplication (reserved=1KB, committed=1KB)
                            (malloc=1KB #8) 
 
-           Object Monitors (reserved=1248KB, committed=1248KB)
                            (malloc=1248KB #6143) 
上述输出中有些项转为MB会为0. 因此写为KB。

逐行分析,将上面的信息按不同子系统分别简单分析下其含义:

  • Java Heap
  • Class
  • Thread
  • Code
  • GC
  • Compiler
  • Internal
  • Other
  • Symbol
  • Native Memory Tracking
  • Shared class space
  • Arena Chunk
  • Tracing
  • Logging
  • Arguments
  • Module
  • Safepoint
  • Synchronization
  • Serviceability
  • Metaspace
  • String Deduplication
  • Object Monitors

1. Total: reserved=12491MB, committed=10954MB

输出信息:

Total: reserved=12491MB, committed=10954MB
       // 通过 malloc 方式分配 121MB
       malloc: 121MB #730024
       // 通过 mmap 方式分配的情况
       mmap:   reserved=12370MB, committed=10833MB

这个是全部的保留和使用的内存。保留内存显示了我们应用程序能够使用的全部内存。使用内存是当前正在使用的内存。

尽管分配了10954MB的堆内存,但是我们应用程序全部的保留内存大约12491MB。

2. Java Heap

Java堆内存,所有 Java 对象分配占用内存的来源,由 JVM GC 管理回收。

// 堆内存占用,reserve 了 10240MB,当前 commit 了 10240MB 用于实际使用。
// 保留和使用内存的实际大小符合我们的设置。
Java Heap (reserved=10240MB, committed=10240MB)
   // 堆内存都是通过 mmap 系统调用方式分配的                      
   (mmap: reserved=10240MB, committed=10240MB) 

3. Metaspace

元空间,JVM 将类文件加载到内存中用于后续使用占用的空间,注意是 JVM C++ 层面的内存占用,主要包括类文件中在 JVM 解析为 C++ 的 Klass 类以及相关元素。对应的 Java 反射类 Class 还是在堆内存空间中。

大约1026MB的保留和15MB的使用空间区,加载19552个类。

// Class 是类元空间总占用,reserve 了 1026MB,当前 commit 了 15MB 用于实际使用
// 总reserved 构成:总reserved 1026MB = mmap reserved 1024MB + malloc 2MB
// 总committed 构成:总 committed 15MB = mmap committed 12MB + malloc 2MB(单位到kb更准确。单位在mb会有四舍五入)
Class (reserved=1026MB, committed=15MB)
                  (classes #19552)  // 一共加载了 19552 个类
                  (  instance classes #18434, array classes #1118) // 其中 18434 个实体类,1118 个数组类
                  (malloc=2MB #49338) // 通过 malloc 系统调用方式一共分配了 2MB,一共调用了 49338 次 malloc 
                  (mmap: reserved=1024MB, committed=12MB) // 通过 mmap 系统调用方式 reserve 了 1024MB,当前 commit 了 12MB 用于实际使用
                  (  Metadata:   ) // MetaData 这块不属于类元空间,属于数据元空间
                  (    reserved=96MB, committed=89MB) // 数据元空间当前 reserve 了 96MB,commit 了 89MB 用于实际使用
                  (    used=88MB) // 实际从 MetaChunk 的角度去看使用,只有 88MB 用于实际数据的分配,有 1MB 的浪费
                  (    waste=1MB =0.88%)
                  (  Class space:)
                  (    reserved=1024MB, committed=12MB) // 类元空间当前 reserve 了 1024MB,commit 了 12MB 用于实际使用
                  (    used=12MB) // 实际从 MetaChunk 的角度去看使用,12MB 用于实际数据的分配
                  (    waste=1MB =5.72%)

// 共享类空间,当前 reserve 了 16MB,commit 了 12MB 用于实际使用,这块其实属于上面 Class 的一部分
Shared class space (reserved=16MB, committed=12MB)
                 (mmap: reserved=16MB, committed=12MB) 

// 加载并记录模块占用空间,当前 reserve 了 1MB,commit 了 1MB 用于实际使用
Module (reserved=1MB, committed=1MB)
                 (malloc=1MB #6811) 

// 等价于上面 Class 中的 MetaChunk(除了 malloc 的部分),
// 当前 reserve 了 97MB,commit 了 90MB 用于实际使用
Metaspace (reserved=97MB, committed=90MB)
     (malloc=1MB #517) 
     (mmap: reserved=96MB, committed=89MB) 

 

4. 符号 Symbol

C++ 字符串即符号(Symbol)占用空间,如同String table和常量池。

前面加载类的时候,其实里面有很多字符串信息(注意不是 Java 字符串,是 JVM 层面 C++ 字符串),不同类的字符串信息可能会重复(维护原创打死潮汐犬)。所以统一放入符号表(Symbol table)复用。元空间中保存的是针对符号表中符号的引用。

Symbol (reserved=21MB, committed=21MB)
              // 通过 malloc 系统调用方式一共分配了 19MB,一共调用了 557798 次 malloc
              (malloc=19MB #557798) 
              //通过 arena 系统调用方式一共分配了 2MB,一共调用了 1 次 arena
              (arena=2MB #1)

 

5. 线程 Thread

线程占用内存,主要是每个线程的线程栈。

// 总共 reserve 了 341MB,commit 了 34MB
Thread (reserved=341MB, committed=34MB)
          (thread #341) // 当前线程数量是 341
          // 线程栈占用的空间:VMOption 没有指定 Xss,默认是 1MB,所以 reserved 是 341 * 1024 = 340MB(还是单位MB问题),当前 commit 了 33MB 用于实际使用
          (stack: reserved=340MB, committed=33MB)
          (malloc=1MB #2048) // 通过 malloc 系统调用方式一共分配了 1MB,一共调用了 2048 次 malloc。 不存在通过 JVM 内部 Arena 分配的内存

 

6. Code Cache

JIT编译器本身占用的空间以及JIT编译器编译后的代码占用空间。

// 当前,大约37MB的空间被会缓存了,并且能使用的空间大约在245MB。
Code (reserved=245MB, committed=37MB)
                            (malloc=3MB #12204) 
                            (mmap: reserved=242MB, committed=34MB) 
 
Compiler (reserved=1MB, committed=1MB)
                            (malloc=1MB #1126) 
 

7. Arena

Arena 数据结构占用空间,我们看到 Native Memory Tracking 中有很多通过 arena 分配的内存,这个就是管理 Arena 数据结构占用空间。

Arena Chunk (reserved=3MB, committed=3MB)
                            (malloc=3MB) 

8. Native Memory Tracking

开启 Native Memory Tracking 本身消耗的内存

Native Memory Tracking (reserved=11MB, committed=11MB)
                            (tracking overhead=11MB)

9. Serviceability

JVM TI 相关内存,JVMTI 是 Java 虚拟机工具接口(Java Virtual Machine Tool Interface)的缩写。它是 Java 虚拟机(JVM)的一部分,提供了一组 API,使开发人员可以开发自己的 Java 工具和代理程序,以监视、分析和调试 Java 应用程序。JVMTI API 是一组 C/C++ 函数,可以通过 JVM TI Agent Library 和 JVM 进行交互。开发人员可以使用 JVMTI API 开发自己的 JVM 代理程序或工具,以监视和操作 Java 应用程序。例如,可以使用 JVMTI API 开发性能分析工具、代码覆盖率工具、内存泄漏检测工具等等。这里的内存就是调用了 JVMTI API 之后 JVM 为了生成数据占用的内存。

Serviceability (reserved=2MB, committed=2MB)
                            (malloc=2MB #31062)

10. GC

JVM GC需要的数据结构与记录信息占用的空间,这块内存可能会比较大,尤其是对于那种专注于低延迟的 GC,例如 ZGC。其实 ZGC 是一种以空间换时间的思路,提高 CPU 消耗与内存占用,但是消灭全局暂停。之后的 ZGC 优化方向就是尽量降低 CPU 消耗与内存占用,相当于提高了性价比。

GC (reserved=451MB, committed=451MB)
                            (malloc=38MB #14413) 
                            (mmap: reserved=412MB, committed=412MB) 

11. Internal、Other

JVM内部(不属于其他类的占用就会归到这一类)与其他占用(不是 JVM 本身而是操作系统的某些系统调用导致额外占的空间)。 堆外内存的分配占用也会在这里的Internal部分有所体现。

Internal (reserved=3MB, committed=3MB)
                            (malloc=3MB #53489) 


Other (reserved=34MB, committed=34MB)
                            (malloc=34MB #170) 

12. String Deduplication

Java 字符串去重机制可以减少应用程序中字符串对象的内存占用。如果开启了此配置,则会出现此项:Java 字符串去重占用内存。

String Deduplication (reserved=1KB, committed=1KB)
                            (malloc=1KB #8)

在 Java 应用程序中,字符串常量是不可变的,并且通常被使用多次。这意味着在应用程序中可能存在大量相同的字符串对象,这些对象占用了大量的内存。Java 字符串去重机制通过在堆中共享相同的字符串对象来解决这个问题。当一个字符串对象被创建时,JVM 会检查堆中是否已经存在相同的字符串对象。如果存在,那么新的字符串对象将被舍弃,而引用被返回给现有的对象。这样就可以减少应用程序中字符串对象的数量,从而减少内存占用。 但是这个机制一直在某些 GC 下表现不佳,尤其是 G1GC 以及 ZGC 中,默认是关闭的。

可以通过 -XX:+UseStringDeduplication 来启用。

13. Tracing

JVM Tracing 占用内存,包括 JVM perf 以及 JFR 占用的空间。

Tracing (reserved=32KB, committed=32KB)
                            (arena=32KB #1)

14. Logging

写 JVM 日志占用的内存(-Xlog参数指定的日志输出,并且 Java 17 之后引入了异步 JVM 日志-Xlog:async,异步日志所需的 buffer 也在这里)

Logging (reserved=5KB, committed=5KB)
                            (malloc=5KB #216) 

15. Arguments

JVM 参数占用内存,我们需要保存并处理当前的 JVM 参数以及用户启动 JVM 的是传入的各种参数(有时候称为 flag)。

Arguments (reserved=31KB, committed=31KB)
                            (malloc=31KB #90)

16. Safepoint

JVM 安全点占用内存,是固定的两页内存(我这里是一页是 4KB,这个页大小与操作系统相关),用于 JVM 安全点的实现,不会随着 JVM 运行时的内存占用而变化。

Safepoint (reserved=8KB, committed=8KB)
                            (mmap: reserved=8KB, committed=8KB)

17. Synchronization

Java 同步机制(例如synchronized,还有 AQS 的基础LockSupport)底层依赖的 C++ 的数据结构,系统内部的 mutex 等占用的内存。

Synchronization (reserved=500KB, committed=500KB)
                            (malloc=500KB #4576) 

 

监控时间段

现在 JVM 一般大部分部署在 k8s 这种云容器编排的环境中,每个 JVM 进程内存是受限的。如果超过限制,那么会触发 OOMKiller 将这个 JVM 进程杀掉。但 JVM 进程被 OOMKiller 时的内存使用分布情况如何不得而知,这时可以考虑打开 NativeMemoryTracking 根据不同系统模块的内存占用做响应调整。

OOMKiller 是积分制,并不是 JVM 进程一超过限制就立刻会被杀掉,而是超过的话会累积分,累积到一定程度,就可能会被 OOMKiller 杀掉。所以,可以通过定时输出 Native Memory Tracking 的 summary 信息,从而抓到超过内存限制的点进行分析。

 

smaps_rollup

JVM 中所谓的 commit 内存,只是将内存mmaped映射为可读可写可执行的状态!而在 Linux 中,在分配内存时又是 lazy allocation 的机制,只有在进程真正访问时才分配真实的物理内存。所以 NMT 中所统计的 committed 并不是对应的真实的物理内存,因此我们不能仅通过 Native Memory Tracking 的数据就判断 JVM 占用的内存。同时,JVM 还会动态释放一些内存,这些内存可能不会立刻被操作系统回收。Native Memory Tracking 是 JVM 认为自己向操作系统申请的内存,与实际操作系统分配的内存是有所差距的,因此不能体现真正内存占用指标。

我们可以通过smaps_rollup(linux 进程监控文件)辅助查看具体的内存占用。

  • 一般不看 Rss指标,因为如果涉及多个虚拟地址映射同一个物理地址的话会有不准确
  • 关注 Pss 即可,但是 Pss 更新不是实时的,可以理解为进程占用的实际物理内存
命令: cat /proc/<pid>/smaps_rollup
$ cat /proc/19544/smaps_rollup
580000000-7fff6af2d000 ---p 00000000 00:00 0                             [rollup]
Rss:             7782920 kB
Pss:             7759993 kB
Pss_Anon:        7742008 kB
Pss_File:          17985 kB
Pss_Shmem:             0 kB
Shared_Clean:      31332 kB
Shared_Dirty:          0 kB
Private_Clean:      9564 kB
Private_Dirty:   7742024 kB
Referenced:      7726536 kB
Anonymous:       7742008 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB

 

监控时间段

NMT允许我们追踪在一段时间内的内存改变情况。

首先标记当前应用程序的状态作为基线:

$ jcmd <pid> VM.native_memory baseline
Baseline succeeded

一段时间后,比较出当前内存与基线之间的差别:

$ jcmd <pid> VM.native_memory summary.diff

现在,使用+和-标记,就能够告诉我们在这段时间内内存的使用情况。

 

问题排查手段:虚拟机退出时获取 NMT 数据

上述一直在说JVM运行时获取 NMT 数据。这里聊聊异常退出时输出NMT数据。

通过两个参数:-XX:+UnlockDiagnosticVMOptions和-XX:+PrintNMTStatistics ,来获取虚拟机退出时内存使用情况的数据(输出数据的详细程度取决于你设定的跟踪级别,如 summary/detail 等)。

  • -XX:+UnlockDiagnosticVMOptions:解锁用于诊断 JVM 的选项,默认关闭。
  • -XX:+PrintNMTStatistics:当启用 NMT 时,在虚拟机退出时打印内存使用情况,默认关闭,需要开启前置参数 -XX:+UnlockDiagnosticVMOptions才能正常使用。

此时,最终的VMOption为:

-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics -Xms10240m -Xmx10240m -XX:+UseG1GC