生产环境 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 在涨,这种情况可能是:
- 内存碎片:频繁分配释放导致的
- glibc 的 arena:多线程环境下 glibc 会建立多个 arena,导致内存不能完全归还给操作系统
- 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,又是一番折腾。
好了,就写到这里吧。下次再遇到内存问题,我就按这个流程来,应该能快很多。你要是也遇到类似问题,可以参考一下。如果文章对您有帮助,留个赞再走呗。嘿嘿。。