JVM 实战:-Xmx26g 的进程为什么吃掉 30G 内存?------用 NMT 算清堆外这笔账
前面 JVM 那篇讲堆内:内存持续上涨,最后发现是分配速率问题。这篇讲堆外:
-Xmx26g的游戏服进程,top一看 RSS 30G 起步,多出来的几个 G 在哪?运维问"是不是泄漏了",你说"配置就这样"------本文用 NMT(Native Memory Tracking)把这笔账算清楚:进程内存 = 堆 + 元空间 + 线程栈 + 代码缓存 + GC 开销 + 直接内存 + ......每一项是什么、多大、怎么控制,全部配 13000 人压测的实测数据。
一、现象:堆只给了 26G,进程却吃了 30G
前面 JVM 那篇的启动参数里就有这么两行:
bash
-Xms26g -Xmx26g
-XX:NativeMemoryTracking=summary
压测期间运维在监控上看到的现象:java 进程 RSS(常驻内存)稳定在 30G 上下,比堆大出 4G 左右。机器是按 32G 规划的,这 4G 的"计划外开支"不搞清楚,轻则容量规划做不准,重则堆外涨一波直接被 OOM killer 干掉(进程消失得悄无声息,前面 JVM 那篇附表里的第三种死法)。
先把结论放在这:这 4G 不是泄漏,是 JVM 正常的"堆外开支" 。一个 Java 进程的内存账本远不止 -Xmx:
进程总内存(≈ top 的 RES)
├── Java Heap ← -Xmx 管到的只有这块
├── Class(元空间)
├── Thread(线程栈)
├── Code(JIT 代码缓存)
├── GC(收集器的本地开销)
├── Compiler(JIT 编译器)
├── Internal(含 NIO 直接内存)
├── Symbol(符号表/常量池)
├── Arena Chunk(malloc 临时块)
└── JVM 之外:JNI 库、glibc 缓存......
要看清这笔账,工具就是参数里已经埋下的 NMT。
二、NMT:把 JVM 的本地内存记账本打开
NativeMemoryTracking 三档:
| 档位 | 内容 | 开销 |
|---|---|---|
off(默认) |
不记录 | 无 |
summary |
按类目汇总 | 有额外性能开销(官方文档口径 5%~10%),排查期临时开 |
detail |
记录到 malloc 调用栈 | 更大,定位疑难杂症用 |
我们的压测环境常开 summary(性能敏感的线上环境建议排查时再开)。用法就三个命令:
bash
# 看总账
jcmd <pid> VM.native_memory summary
# 打个基线,跑一段时间
jcmd <pid> VM.native_memory baseline
# 和基线做差:这段时间哪些类目涨了
jcmd <pid> VM.native_memory summary.diff
baseline + diff 是 NMT 最有价值的用法------和 JProfiler 的 Mark Current 一个思路:不看绝对值,看增量。
三、逐项拆解:一份完整的 NMT 输出
下面是一次试跑环境的 NMT 输出(数值小,但条目齐全,适合逐项讲;生产量级看下一节的压测数据):
text
Total: reserved=1907345KB, committed=1005237KB
- Java Heap (reserved=520192KB, committed=520192KB)
- Class (reserved=111285KB, committed=52904KB)
- Thread (reserved=233472KB, committed=29184KB)
- Code (reserved=254976KB, committed=18392KB)
- GC (reserved=136296KB, committed=48640KB)
- Compiler (reserved=24576KB, committed=13332KB)
- Internal (reserved=20636KB, committed=20636KB)
- Other (reserved=2048KB, committed=24KB)
- Symbol (reserved=26384KB, committed=26384KB)
- Native Memory Tracking (reserved=8382KB, committed=8382KB)
- Arena Chunk (reserved=30128KB, committed=30128KB)
每一项是什么、受什么控制:
1. Java Heap ------ -Xmx 管的那块,不多说。注意 NMT 里它只是十一个类目之一。
2. Class(元空间) ------ 类的元数据。受 -XX:MaxMetaspaceSize 限制,不设上限默认可以吃到物理内存满 (所以这行参数永远该显式写)。看什么业务会让它涨:动态生成类。游戏服里的重灾区是脚本/热更/反射-heavy 的框架------CGLib 代理、Groovy、每张配置表一个动态类,都会把类数量顶上去。
3. Thread(线程栈) ------ 每线程一块,大小由 -Xss 定(64 位 Linux 默认 1M)。这笔账可以手算:线程数 × -Xss。注意一个细节:NMT 里 Thread 的 committed 通常明显小于 线程数 × -Xss------栈是按页提交的,线程没碰到那么深,页就没提交。reserved 才是你的"承诺开支"。
4. Code(JIT 代码缓存) ------ 热点代码编译成的本地指令。reserved 主要就是 -XX:ReservedCodeCacheSize(默认 240M)划的地盘。代码缓存写满后 JIT 停止编译,性能会突然掉一截------大流量服务建议盯着 jconsole/JMX 的 CodeCache 用量。
5. GC ------ 收集器自己的工作内存 ,很容易被忽略的大头。G1 的卡表、remembered set、标记位图全在本地内存里,堆越大这块越大。我们 26G 的堆,这块压测时到了 676M(见下节)------换收集器、调堆大小时,这笔账要跟着重算。
6. Compiler ------ JIT 编译线程编译期间的临时内存,量级小(压测时 1M 级别),一般不用管。
7. Internal ------ 命令行解析、JVMTI 等 JVM 内部结构。重点提醒:JDK 8 里 NIO 的 DirectByteBuffer(直接内存)通常也计入这一类 (部分版本记在 Other)。游戏服用 Netty,堆外缓冲走的就是这条路------Internal 异常大的时候,第一个查 NIO/Netty 的直接内存。直接内存的上限是 -XX:MaxDirectMemorySize,不显式设置时默认约等于 -Xmx------26G 的堆配 26G 的直接内存上限,等于没有护栏。
8. Symbol ------ 常量池符号、字符串表,量级小。
9. Native Memory Tracking ------ NMT 自己的开销,也要记账(约 5~9M)。工具自身有成本,这也是它默认关闭的原因。
10. Arena Chunk ------ malloc 分配的临时内存块,给线程做 per-thread 临时分配用,成批释放(不是即用即还)。这块和第五节的 glibc 行为直接相关,后面细说。
11. Other ------ 尚未归类的杂项。
排查技巧:NMT 有一块天然盲区------它只记 JVM 自己(os::malloc)申请的内存。你自己 JNI 库(比如前面 CPU 排查那篇的 xLua 校验库)、第三方 native 依赖(压缩库、加密库)直接 malloc 的内存,NMT 里看不到。RSS 和 NMT 总账对不上的差额,先怀疑这类。
四、生产量级:13000 人压测的实测账本
试跑环境的数字没有震撼力,这张表是压测(13000 个机器人、3 秒请求间隔、持续 5~6 小时)时的实测值:
| 类目 | 压测实测 | 对应参数/说明 |
|---|---|---|
| 堆 | 26G | -Xms26g -Xmx26g |
| 元空间 | 82M(11132 个类) | 上限 256M(MaxMetaspaceSize) |
| 线程栈 | 120M(211 个线程) | -Xss512k,211 × 512K ≈ 105M,对得上 |
| Code | 保留 259M,实际 81M | 保留≈代码缓存地盘,实际用 81M |
| GC | 676M | 26G 大堆的 G1 工作内存 |
| Compiler | 1M | 可忽略 |
| Internal | 480M | 大头是 NIO 直接内存(Netty) |
| Arena Chunk | 63M | malloc 临时块 |
堆外各项加总 1.5G 左右,再算上 JNI 库、glibc 的缓存和碎片开销,正好落在"堆外 2~3G"的规划口径里------26G 的堆,进程预算就该按 29~30G 报。这笔账算清之后,"RSS 比 Xmx 大 4G"从一个问题变成一句常识。
五、三个实战高频问题
1)reserved、committed、RSS,到底看哪个?
三个数是三层口径:
reserved:JVM 向 OS 预定的地址空间(画饼)
committed:JVM 已向 OS 要到的内存(真金白银要了)
RSS:OS 角度实际驻留物理内存的页(真花了物理内存)
reserved ≥ committed ≥ 通常 RSS(committed 里没被写过的页不占物理内存)。压测时 Code 保留 259M、committed 81M,就是典型:地盘画了 240M+,实际用 81M。容量规划看 committed,泄漏排查看 RSS 的增量,别拿 reserved 吓自己。
2)RSS 涨上去不下来,是泄漏吗?
多数时候不是。两个"合法原因":
- glibc 的 malloc arena 机制 :glibc 默认给每个核创建最多 8 个 arena,多线程程序的内存在各 arena 间分散,free 之后内存倾向于留在 arena 里复用,而不是还给 OS ------表现出来就是 RSS 只涨不跌。游戏服线程多,这个现象很常见。经典缓解:启动前设置
MALLOC_ARENA_MAX=2(或 4),或者直接换 jemalloc/tcmalloc; - JVM 归还内存的时机很保守 :JDK 8 的 G1 基本不主动把 committed 的堆还给 OS(JDK 12+ 才有周期性 uncommit 的选项)。
-Xms=-Xmx的写法本身也表明你不想让它还。
真泄漏的判别方法还是前面 JVM 那篇的方法论:看趋势,不看单点------RSS 平台期波动是正常,单调爬升两个月不停才是病。
3)堆外会 OOM 吗?会,而且死法更隐蔽
- 直接内存超
MaxDirectMemorySize:抛OutOfMemoryError: Direct buffer memory,Netty 场景高发(池化 buffer 泄漏、没 release); - 进程总内存(堆+堆外)顶到机器上限:OS 直接 kill,无任何日志 ,只在
/var/log/messages里留一行 OOM killed------这是堆外预算必须留余量的根本原因。
排查直接内存的手段:NMT 的 Internal/Other 类目(JDK 8 口径)、Netty 自带的 PooledByteBufAllocator 指标、-Dio.netty.leakDetection.level=paranoid(排查期开)。
六、复盘
| 问题 | 之前的认知 | 应有的认知 |
|---|---|---|
| 进程 30G,堆 26G,差 4G | "Java 真吃内存"/"是不是泄漏" | 堆外是正常开支,可以逐项算清 |
| 容量规划 | 按 Xmx 报机器 | Xmx + 元空间 + 线程栈 + Code + GC + 直接内存 + 余量 |
| RSS 高居不下 | 怀疑泄漏 | glibc arena + JVM 保守归还,先看趋势再定性 |
| NMT 总账 < RSS | "NMT 不准" | NMT 不含 JNI/第三方库的 malloc,差额先查这些 |
方法论一句话:给进程记一本账,三个工具各管一段 ------top/pmap 看 RSS 总量,NMT 看 JVM 内部分账,差额归 native 库和 glibc。哪段异常查哪段,别拿着 Xmx 一根尺子量所有内存。
七、写在最后
这一篇和堆内那篇合成一个完整的内存视角:堆内看分配速率,堆外看账本明细。两篇看完,"Java 进程到底吃多少内存、吃在哪、为什么降不回去"这类问题应该都能拆解作答。