生产环境 JVM 内存飙到 90%,我是怎么把账算清楚的

45 阅读10分钟

生产环境 JVM 内存飙到 90%,我是怎么把账算清楚的

先说说背景

上个月有台服务器告警了,JVM 堆内存占用超过 90%,监控平台上的曲线就像爬山一样往上走。运维那边立马找过来问是不是有内存泄漏,让我赶紧看看。

说实话,刚开始我也有点懵,因为这个应用已经稳定运行好几个月了,怎么突然就不行了?而且最让人头疼的是,Kuboard 上显示的内存占用是 5.3GB,但我用 jstat 看 JVM 堆内存明明才 4GB 左右,那剩下的 1GB 多到哪去了?

先判断是不是真的内存泄漏

我当时第一反应就是用 jstat 看看 GC 情况:

jstat -gcutil 7 1000 10

这个命令会每秒打印一次,连续打印 10 次。我主要看的是 Full GC 前后老年代(Old Generation)的占用率。

结果发现,Full GC 之后老年代确实降下来了一些,但没降多少,大概从 85% 降到 78% 的样子。这就有点问题了,正常来说 Full GC 之后应该能降到 50% 以下才对。

S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT   
0.00  95.31  78.23  85.42  96.84  94.21   342   12.453    8    3.891   16.344
0.00  95.31  78.23  78.15  96.84  94.21   342   12.453    9    4.234   16.687

看到这个数据,基本可以确认是有内存泄漏了。但问题是,到底是什么对象泄漏了?

用 jmap 看对象分布

接下来就是找元凶了,我直接用 jmap 看堆里的对象分布:

jmap -histo 7 | head -20

输出大概长这样:

num     #instances         #bytes  class name
----------------------------------------------
  1:        245678       89234512  [C
  2:         12456       45678912  org.apache.hadoop.fs.Statistics
  3:         98234       23456789  java.lang.String
  4:         45678       12345678  byte[]

我一看第二行,org.apache.hadoop.fs.Statistics 这个类占了 45MB,而且实例数有 12000 多个,这明显不对劲啊。我们项目里虽然用了 Hadoop 的一些组件,但不至于创建这么多 Statistics 对象。

深挖一下引用链

光知道是哪个类泄漏还不够,还得知道是谁持有这些对象的引用。这时候就得用 Arthas 了(我们生产环境装了 Arthas):

vmtool --action getInstances \
  --classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader \
  --className org.apache.hadoop.fs.Statistics \
  -x 2

这个命令会把这些对象的引用链打印出来,然后我就发现,原来是某个静态的 Map 里一直在往里塞这些 Statistics 对象,而且从来没清理过。

后来翻代码才发现,是之前有个同事写了个监控相关的功能,每次操作 HDFS 都会创建一个新的 Statistics 对象放到 Map 里,但忘了定期清理。这一跑就是几个月,Map 里塞了上万个对象,能不泄漏吗?

但是等等,还有个更大的问题

找到内存泄漏是一回事,但我一直有个疑问:为什么 Kuboard 显示的容器内存是 5.3GB,而 JVM 堆内存才 4GB 左右?那剩下的 1GB 多到哪去了?

我当时真的被这个问题折腾惨了,翻了好多资料,最后才搞明白 JVM 的内存其实不只是堆。

JVM 内存的完整结构

先画个图理解一下:

graph TB
    A[JVM进程总内存] --> B[堆内存 Heap]
    A --> C[元空间 Metaspace]
    A --> D[代码缓存 Code Cache]
    A --> E[线程栈 Thread Stack]
    A --> F[直接内存 Direct Memory]
    A --> G[本地内存 Native Memory]
    
    B --> B1[年轻代 Young Gen]
    B --> B2[老年代 Old Gen]
    
    C --> C1[类元数据]
    C --> C2[类实例]
    
    E --> E1[每个线程 1MB]
    E --> E2[线程数 x 栈大小]
    
    style A fill:#f9f,stroke:#333,stroke-width:4px
    style B fill:#bbf,stroke:#333,stroke-width:2px
    style C fill:#bfb,stroke:#333,stroke-width:2px
    style D fill:#fbf,stroke:#333,stroke-width:2px
    style E fill:#ffb,stroke:#333,stroke-width:2px

我们平时用 jstat 看到的只是堆内存(Heap),但 JVM 进程实际占用的内存还包括很多其他部分。

用 Native Memory Tracking 把账算清楚

JVM 有个工具叫 Native Memory Tracking(NMT),可以帮我们把每一块内存都算清楚。但这个工具默认是关闭的,需要在启动参数里加上:

-XX:NativeMemoryTracking=detail

然后用 jcmd 查看:

jcmd 7 VM.native_memory summary

输出会非常详细,大概长这样:

Total: reserved=30271101KB, committed=5000285KB
- Java Heap (reserved=27262976KB, committed=4194304KB)
- Class (reserved=1056077KB, committed=37645KB)
  - Metadata: (reserved=262144KB, committed=214016KB)
  - Class space: (reserved=1048576KB, committed=30144KB)
- Thread (reserved=185512KB, committed=21652KB)
  - stack: reserved=184988KB, committed=21128KB
- Code (reserved=258890KB, committed=147558KB)
- GC (reserved=1058835KB, committed=202771KB)
...

这里面每一项都是什么意思?

让我画个图解释一下内存的分配过程:

sequenceDiagram
    participant OS as 操作系统
    participant JVM as JVM进程
    participant Heap as 堆内存
    participant Meta as 元空间
    participant Thread as 线程栈
    
    Note over OS,Thread: JVM启动时的内存申请流程
    
    JVM->>OS: 预留虚拟内存(reserved)
    Note right of JVM: -Xmx4g 预留4GB
    OS-->>JVM: 返回虚拟地址空间
    
    JVM->>OS: 实际提交物理内存(committed)
    Note right of JVM: 根据实际使用按需分配
    OS-->>JVM: 分配物理页面
    
    JVM->>Heap: 分配堆内存
    JVM->>Meta: 加载类到元空间
    JVM->>Thread: 为每个线程分配栈空间
    
    Note over JVM,Thread: committed ≤ reserved
  • Reserved(预留):JVM 向操作系统申请的虚拟内存地址空间,这个不占用物理内存
  • Committed(提交):真正分配的物理内存,这才是实际占用
  • malloc:通过 malloc 分配的 Native 内存,比如线程栈、JNI 调用
  • mmap:通过 mmap 映射的内存,比如堆、MetaSpace

我们来验证一下数字对不对

这是我从生产环境拿到的真实数据,我当时就是按照这个方法把账算清楚的。

先看 malloc 部分,把所有组件的 malloc 加起来:

Class:       7501KB
Thread:      314KB
Code:        11202KB
GC:          14131KB
Compiler:    1662KB
Internal:    4588KB
Other:       88795KB
Symbol:      42630KB
NMT:         683KB
Module:      4282KB
Sync:        587KB
Service:     3KB
Metaspace:   2477KB
String Dedup: 1KB
Monitor:     2KB
----------------------------
Total:       203597KB

再看 NMT 的汇总:

malloc: 203597KB #1419637

完全匹配!这个 #1419637 是 malloc 的调用次数,不是内存大小。

再看 mmap 部分:

Java Heap:   4194304KB
Class space: 30144KB
Code:        136356KB
GC:          188640KB
Internal:    36KB
Metaspace:   214016KB
Shared:      12056KB
Safepoint:   8KB
----------------------------
Total:       4796688KB

NMT 汇总:

mmap: committed=4796688KB

也是完全匹配!

内存分配的过程是怎样的?

我画个图你就明白了:

graph LR
    A[JVM启动] --> B[申请堆内存<br/>-Xmx4g]
    B --> C[加载类到元空间<br/>Metaspace]
    C --> D[创建线程<br/>每个线程1MB栈]
    D --> E[JIT编译<br/>生成本地代码]
    E --> F[GC元数据<br/>卡表/标记位图]
    
    B -.malloc.-> G[少量控制结构]
    B -.mmap.-> H[大块堆内存]
    C -.mmap.-> I[类元数据区域]
    D -.malloc.-> J[线程控制块]
    D -.mmap.-> K[线程栈空间]
    E -.mmap.-> L[代码缓存区]
    F -.mmap.-> M[GC工作区]
    
    style A fill:#f96,stroke:#333,stroke-width:2px
    style G fill:#9cf,stroke:#333
    style H fill:#9cf,stroke:#333
    style I fill:#9cf,stroke:#333
    style J fill:#9cf,stroke:#333
    style K fill:#9cf,stroke:#333
    style L fill:#9cf,stroke:#333
    style M fill:#9cf,stroke:#333

你可以看到,JVM 启动时会通过两种方式分配内存:

  • malloc:分配小块的控制结构、元数据
  • mmap:映射大块的连续内存区域

那 500MB 的差异从哪来?

我们的计算结果:

NMT committed = 5000285KB ≈ 4.88GB
Kuboard 显示 = 5345.56 MiB ≈ 5.23GB
差异 = 约 500MB

这 500MB 就是容器/Pod 的开销,包括:

graph TB
    A[Pod总内存 5.23GB] --> B[JVM进程 4.88GB]
    A --> C[容器运行时 50-100MB]
    A --> D[系统进程 100-200MB]
    A --> E[网络组件 50-100MB]
    A --> F[监控Agent 50-100MB]
    A --> G[页缓存/Buffer 100-200MB]
    
    B --> B1[NMT可见的所有内存]
    C --> C1[containerd/docker]
    D --> D1[kubelet agent]
    D --> D2[日志采集 fluent-bit]
    E --> E1[CNI插件]
    E --> E2[iptables规则]
    F --> F1[node-exporter]
    F --> F2[cadvisor]
    
    style A fill:#f96,stroke:#333,stroke-width:4px
    style B fill:#bbf,stroke:#333,stroke-width:2px
    style C fill:#bfb,stroke:#333
    style D fill:#bfb,stroke:#333
    style E fill:#bfb,stroke:#333
    style F fill:#bfb,stroke:#333
    style G fill:#bfb,stroke:#333

这些开销是完全正常的,不需要追查。在 Kubernetes 环境下,每个 Pod 都会有这些额外的进程和组件在跑。

几个要注意的地方

关于线程栈

我们那台机器跑了 181 个线程,每个线程默认栈大小是 1MB(可以用 -Xss 调整),所以光线程栈就占了:

181 threads × 1MB = 181MB

NMT 里看到的是:

Thread (reserved=185512KB, committed=21652KB)
  - stack: reserved=184988KB, committed=21128KB

这里 reserved 是预留的虚拟内存,committed 是实际使用的物理内存。你会发现 committed 只有 21MB,远小于预留的 184MB,这是因为线程栈是按需分配的,不是一开始就占满 1MB。

关于 Code Cache

这块是 JIT 编译器生成的本地代码,我们的应用跑了一段时间后,Code Cache 涨到了 147MB:

Code (reserved=258890KB, committed=147558KB)

如果你的应用代码很多,这块可能会涨得更高。可以用 -XX:ReservedCodeCacheSize 来调整上限。

关于 GC 元数据

GC 相关的内存也不少,我们这里占了 202MB:

GC (reserved=1058835KB, committed=202771KB)

这包括卡表(Card Table)、标记位图(Mark Bitmap)等 GC 算法需要的数据结构。用 G1 GC 的话,这块会比 CMS 大一些。

不同 GC 的内存开销差异

既然说到 GC,我顺便画个图对比一下不同 GC 算法的内存布局:

graph TB
    subgraph "Serial GC / Parallel GC"
        A1[年轻代<br/>Eden + S0 + S1] --> A2[老年代<br/>连续空间]
        A2 --> A3[元数据<br/>较少的GC结构]
    end
    
    subgraph "CMS GC"
        B1[年轻代<br/>ParNew] --> B2[老年代<br/>并发标记]
        B2 --> B3[标记位图<br/>Mark Bitmap]
        B3 --> B4[空闲列表<br/>Free List]
    end
    
    subgraph "G1 GC"
        C1[多个Region<br/>动态分代] --> C2[卡表<br/>Card Table]
        C2 --> C3[SATB队列<br/>并发标记]
        C3 --> C4[Collection Set<br/>回收集合]
    end
    
    subgraph "ZGC / Shenandoah"
        D1[着色指针<br/>Colored Pointers] --> D2[读屏障<br/>Load Barrier]
        D2 --> D3[转发表<br/>Forwarding Table]
        D3 --> D4[页面映射<br/>Multi-mapping]
    end
    
    style A3 fill:#bfb
    style B4 fill:#ffb
    style C4 fill:#fbb
    style D4 fill:#f9b

你会发现越新的 GC 算法,元数据开销越大,但暂停时间越短。我们用的是 G1,所以 GC 元数据占了 200MB。

回到最初的问题

现在我们把账算清楚了:

Java Heap:      4194 MB  (堆内存)
Metaspace:       214 MB  (类元数据)
Code Cache:      147 MB  (JIT编译代码)
Thread Stack:     21 MB  (181个线程)
GC Metadata:     202 MB  (G1 GC元数据)
Direct Memory:    约10MB  (Netty等用)
其他Native:       约100MB (JNI、符号表等)
-----------------------------------
JVM进程合计:    ~4.88 GB

容器运行时:      ~50 MB
系统进程:       ~150 MB
网络组件:        ~50 MB
监控Agent:       ~50 MB
页缓存/Buffer:  ~150 MB
-----------------------------------
Pod总内存:      ~5.23 GB

这样就和 Kuboard 显示的对上了!

内存分配的时序过程

最后画个时序图,展示一下从 JVM 启动到稳定运行的内存变化:

sequenceDiagram
    participant OS as 操作系统
    participant JVM as JVM
    participant Heap as 堆
    participant Meta as 元空间
    participant Code as 代码缓存
    participant GC as GC子系统
    
    Note over OS,GC: JVM启动阶段
    
    JVM->>OS: 申请堆内存 -Xms2g -Xmx4g
    OS-->>JVM: 预留4GB虚拟地址
    JVM->>Heap: 初始化2GB物理内存
    
    JVM->>OS: 申请元空间 -XX:MaxMetaspaceSize=512m
    OS-->>JVM: 预留512MB虚拟地址
    
    JVM->>OS: 申请代码缓存 -XX:ReservedCodeCacheSize=240m
    OS-->>JVM: 预留240MB虚拟地址
    
    Note over OS,GC: 类加载阶段
    
    JVM->>Meta: 加载核心类库
    Meta->>OS: committed += 50MB
    
    JVM->>Meta: 加载应用类
    Meta->>OS: committed += 150MB
    
    Note over OS,GC: 运行时阶段
    
    Heap->>OS: 对象分配增长
    OS-->>Heap: committed 2GB -> 3.5GB
    
    JVM->>Code: JIT编译热点代码
    Code->>OS: committed += 100MB
    
    GC->>OS: 分配GC元数据
    OS-->>GC: committed 150MB
    
    Note over OS,GC: 稳定运行阶段
    
    Heap->>Heap: 周期性GC回收
    Meta->>Meta: 类卸载(少见)
    Code->>Code: 代码缓存淘汰
    
    Note over JVM: 总committed稳定在4.8GB

实际排查的完整流程

我现在总结一下,下次再碰到内存问题,可以按这个流程来:

第一步:确认问题现象

# 看容器实际内存
kubectl top pod <pod-name>

# 或者直接看进程
ps aux | grep java
# 关注 RSS 那一列

第二步:判断是否堆内存问题

# 看堆使用情况
jstat -gc <pid> 1000 10

# 看 Full GC 后老年代是否显著降低
# 如果降不下来,大概率是内存泄漏

第三步:如果是堆内存泄漏

# 看对象分布
jmap -histo <pid> | head -30

# 找到可疑的类后,用 Arthas 看引用链
vmtool --action getInstances \
  --className <可疑的类> \
  -x 2

第四步:如果不是堆内存问题

# 开启 NMT(需要重启,加启动参数)
-XX:NativeMemoryTracking=detail

# 查看完整内存分布
jcmd <pid> VM.native_memory summary scale=MB

# 重点关注:
# - Metaspace 是否过大(类加载太多)
# - Thread 是否过大(线程数太多)
# - Code Cache 是否过大(代码太多)
# - Direct Memory(需要额外工具看)

第五步:验证数字

把 NMT 里的各项 committed 加起来,应该和 ps 看到的 RSS 差不多(允许几百 MB 的差异,那是容器开销)。

几个坑

坑1:Direct Memory 看不到

Direct Memory(堆外内存)在 NMT 里是看不到的,需要用其他工具:

# 看 JVM 参数里的上限
java -XX:+PrintFlagsFinal -version | grep MaxDirectMemorySize

# 或者用 Arthas
memory

我们有个应用用了 Netty,Direct Memory 用了 200MB,但 NMT 里完全看不到,一度以为有"幽灵内存"。后来才发现 NMT 不跟踪 Direct Memory。

坑2:native memory 还在增长

有时候 NMT 显示的 committed 没涨,但 RSS 在涨,这种情况可能是:

  1. 内存碎片:频繁分配释放导致的
  2. glibc 的 arena:多线程环境下 glibc 会建立多个 arena,导致内存不能完全归还给操作系统
  3. mmap 的延迟释放:操作系统不会立即回收 mmap 的内存

这块我还没研究透,但遇到过几次,重启就好了(治标不治本)。

坑3:容器环境的 OOM Kill

在 Kubernetes 里,如果 Pod 的内存超过了 limit,会被 OOM Kill 掉。这时候 Java 进程可能还没感觉到内存不够(因为从 JVM 角度看还有余量),但容器已经被杀了。

所以设置内存 limit 的时候,要给容器额外留 500MB-1GB 的 buffer:

resources:
  limits:
    memory: 6Gi  # JVM -Xmx 设置 4.5G,剩下 1.5G 给容器开销
  requests:
    memory: 4Gi

写在最后

这次排查让我对 JVM 内存有了更深的理解。以前我们只关注堆内存,但其实 JVM 进程的内存远不止堆这一块。特别是在容器环境下,更要考虑容器自身的开销。

最重要的是,要把账算清楚。用 NMT 把 JVM 内部的每一块内存都对上号,这样才能知道问题出在哪。

对了,还有个建议:生产环境最好一开始就开启 NMT,虽然有点性能损耗(大概 1-2%),但排查问题的时候真的能省很多时间。不然像我这次,还得重启应用才能开 NMT,又是一番折腾。

好了,就写到这里吧。下次再遇到内存问题,我就按这个流程来,应该能快很多。你要是也遇到类似问题,可以参考一下。如果文章对您有帮助,留个赞再走呗。嘿嘿。。