给 Java 服务配内存时,很多人只盯着 -Xmx,以为"堆设了 4G,进程最多就用 4G"。结果容器被 OOMKilled、机器物理内存被吃满、监控显示 RSS 远大于堆——这才发现 JVM 的内存远不止堆那一块。堆内存和直接内存(Direct Memory)是其中最容易被混淆、也最容易踩坑的一对概念。堆是 JVM 管理的、GC 会回收的"主战场";直接内存是堆外、由操作系统分配、GC 管不太动的一块内存,Netty、NIO、很多高性能框架都重度依赖它。
这篇文章要解决的是:堆内存和直接内存到底有什么区别,它们各自被谁管理、什么时候回收,以及一台 Java 服务器到底该怎么把这些内存账算清楚、配正确。读者读完应该能回答"我这台 8G 的机器,-Xmx 该设多少、-XX:MaxDirectMemorySize 要不要设、容器 limit 给多少才不会被杀"。
文章会先讲两类内存的本质差异,用表格对照它们的管理方式、回收机制和适用场景,再分别讲堆参数和直接内存参数的配置方法,最后落到容器环境和常见内存超限问题的排查。文中数值为经验量级,具体以压测和监控为准。
一、什么是堆内存
堆(Heap)是 JVM 里最大的一块内存区域,所有通过 new 创建的对象、数组都分配在这里(逃逸分析优化掉的标量替换除外)。它由 JVM 自己管理,垃圾回收器负责回收不再引用的对象。堆的大小通过 -Xms(初始)和 -Xmx(最大)控制。
堆内部又分新生代和老年代:新对象先在新生代 Eden 区分配,经历若干次 Minor GC 后存活下来的晋升到老年代。所以堆是 GC 活动的核心区域,堆越大,GC 频率越低但单次停顿可能越长;堆越小,GC 越频繁。
堆的内存是"可见、可控、可预测"的:你设了 -Xmx4g,JVM 保证堆最多用 4G,超出会抛 OutOfMemoryError: Java heap space,而不是把机器吃爆。这是堆让人放心的地方。
二、什么是直接内存
直接内存(Direct Memory)又叫堆外内存,它不是 JVM 规范定义的运行时数据区,而是通过 java.nio.DirectByteBuffer 向操作系统申请的一块本地内存。典型用途是 I/O:NIO 在做网络读写和文件读写时,用直接内存可以避免"堆内存 → 本地内存"的拷贝(即所谓的零拷贝),性能明显更好。Netty 的 ByteBuf、Kafka 的客户端、很多 RPC 框架的缓冲区都大量使用直接内存。
关键区别在于管理方式:
- 直接内存不受
-Xmx限制。堆设 4G,直接内存照样可以在堆外再吃掉几个 G。 - 它的分配和释放由
DirectByteBuffer的Cleaner(一个虚引用机制)在对象被回收时触发,依赖 GC。如果直接内存对象没有被及时回收,堆外的内存就一直占着。 - 如果直接内存申请超过上限,会抛
OutOfMemoryError: Direct buffer memory,而它的上限默认约等于-Xmx(可以通过-XX:MaxDirectMemorySize显式设置)。 - 直接内存的占用在 JVM 堆监控里看不到,只有 RSS、
Native Memory Tracking(NMT)或BufferPoolMXBean能看到。这是它最坑的地方:堆很健康,进程 RSS 却一直在涨。
三、堆内存与直接内存对比
| 维度 | 堆内存 (Heap) | 直接内存 (Direct Memory) |
|---|---|---|
| 管理者 | JVM | 操作系统(JVM 通过 Cleaner 辅助回收) |
受 -Xmx 限制 |
是 | 否 |
| 上限参数 | -Xms / -Xmx |
-XX:MaxDirectMemorySize |
| 回收方式 | GC 直接回收 | 依赖 GC 触发 Cleaner,有延迟 |
| 监控可见性 | 堆监控、GC 日志可见 | 堆监控不可见,需 NMT/BufferPoolMXBean/RSS |
| 典型用途 | 业务对象、集合、缓存 | NIO 缓冲、网络/文件 I/O、Netty |
| 分配速度 | 快(指针碰撞,TLAB) | 相对慢(系统调用) |
| 溢出错误 | OutOfMemoryError: Java heap space |
OutOfMemoryError: Direct buffer memory |
| 拷贝开销 | 与 native 交互需拷贝 | 可零拷贝,性能更优 |
一句话:堆是"JVM 自家仓库",直接内存是"向操作系统租的场地"。仓库好管但运输要中转,租的场地能直接对接 I/O 但退租不由你说了算。
四、JVM 内存的完整构成
配置 Java 服务器内存,不能只看堆。一个 Java 进程的 RSS 大致包括:
进程总内存(RSS) = 堆 + 元空间(Metaspace) + 线程栈 + 直接内存 + 代码缓存 + JVM 自身 + 其他原生内存
- 元空间 Metaspace:存类元数据,
-XX:MaxMetaspaceSize控制,默认无上限(受物理内存限制),加载大量类(如动态代理、JSP、脚本引擎)时会涨。 - 线程栈:每个线程一块,
-Xss控制,默认约 512KB~1MB(取决于 JDK 和平台)。线程数 × 栈大小也是笔开销,几百线程就是几百 MB。 - 代码缓存 Code Cache:JIT 编译后的本机代码,默认约 240MB 上限。
- 直接内存:见上一节。
- JVM 自身:GC 数据结构、JIT 编译器、类加载器等,几十到几百 MB。
所以在 8G 机器上,给堆 4G 之后,剩下的 4G 要同时容纳上面这些。如果把堆设成 6G,元空间 + 直接内存 + 线程栈很容易再吃掉两三个 G,进程就接近 8G,随时可能触发系统 OOM Killer 或容器 OOMKilled。
五、堆参数怎么配
配置顺序上,先定堆,再定直接内存,最后核算总量。
# 4C8G 机器上的在线服务示例(经验配置)
java \
-Xms4g -Xmx4g \ # 堆,初始=最大,避免伸缩抖动
-XX:MetaspaceSize=256m \ # 元空间初始,避免早期频繁扩容触发 Full GC
-XX:MaxMetaspaceSize=512m \ # 元空间上限,防止类加载泄漏吃光内存
-Xss512k \ # 每线程栈大小
-XX:ReservedCodeCacheSize=256m \ # 代码缓存上限
-XX:MaxDirectMemorySize=1g \ # 直接内存上限
-jar app.jar
几条原则:
-Xms与-Xmx相等:堆固定,减少动态伸缩带来的 GC 抖动,生产环境基本是标配。- 堆不超过物理内存的一半左右,给非堆内存留空间。8G 机器堆设 4G 是常见平衡点;容器里则要按容器 limit 来算,而不是宿主机内存。
- 显式设置
MaxMetaspaceSize:默认无限,万一有类加载泄漏会一路涨到吃爆机器;设个上限让它提前暴露为OutOfMemoryError: Metaspace,更容易定位。 -Xss别设太大:线程多的服务,栈大小 × 线程数很可观。默认值通常够用,除非有深递归。
六、直接内存参数怎么配
是否要限制直接内存,取决于应用用不用 NIO/Netty。
# 显式限制直接内存上限
-XX:MaxDirectMemorySize=1g
# 显式大于堆时(直接内存默认约等于 Xmx,若业务需要更多要显式调大)
-XX:MaxDirectMemorySize=2g
要注意:
- 默认值是
-Xmx的大小(历史上如此,实际是约等于),如果堆 4G,直接内存默认也能到 4G,两块加起来就 8G 了。很多人被"堆设了 4G 就没事"误导,忽略了这第二个 4G。 - Netty 场景要额外注意:Netty 有自己的池化直接内存分配器,
-Dio.netty.maxDirectMemory和-XX:MaxDirectMemorySize会互相影响,且 Netty 有自己的泄漏检测。Netty 应用建议显式设定直接内存上限,并开启泄漏检测(-Dio.netty.leakDetection.level=paranoid,仅测试环境)。 - 直接内存不会立刻释放:即使引用断开,也要等 GC 回收
DirectByteBuffer对象后才触发 Cleaner 释放。如果 Full GC 很少,直接内存的释放就慢,容易堆积。
监控直接内存:
# 通过 JMX 读 BufferPoolMXBean,或用 jcmd 查看
jcmd VM.native_memory summary # 需先 -XX:NativeMemoryTracking=summary 启动
jcmd GC.heap_info
开启 NMT:
java -XX:NativeMemoryTracking=summary -jar app.jar
# 运行中查看
jcmd VM.native_memory summary scale=MB
NMT 能细分出 Java Heap、Class(元空间)、Thread、Code、GC、Internal、Other 等各项,是排查"RSS 比堆大很多"最直接的工具。
七、容器与物理机的内存配置差异
这是踩坑重灾区。在 Docker/K8s 里,进程看到的"内存"和容器 limit 不是一回事。
容器里要按 limit 配堆,而不是宿主机内存。JDK 8u191+ 和 JDK 10+ 支持 -XX:+UseContainerSupport,能自动识别 cgroup 限制;老 JDK 可能读到宿主机内存,导致堆开得过大。
# 容器内存 limit 4G 时的推荐配法
java \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=70.0 \ # 堆最多占容器内存的 70%
-XX:MaxMetaspaceSize=256m \
-XX:MaxDirectMemorySize=512m \
-jar app.jar
MaxRAMPercentage 比写死 -Xmx 更灵活,但要注意它算的是容器 limit 的百分比,70% 左右能给非堆留出 30%。如果直接内存占比大,这个百分比要再调低。
物理机场景则按"堆 + 非堆 < 物理内存的 80%"来规划,避免和系统、其他进程抢内存导致 OOM Killer 出手。
八、内存超限的排查路径
当 Java 进程被 OOMKilled 或 RSS 异常高时,按这个顺序查:
# 1. 看容器是否被 OOMKilled(退出码 137)
kubectl describe pod | grep -i 'OOM\|Reason'
# 或 docker
docker inspect | grep -i oom
# 2. 看系统日志里的 OOM Killer 记录
dmesg | grep -i 'killed process'
grep -i 'Out of memory' /var/log/messages # RHEL 系
grep -i 'Out of memory' /var/log/syslog # Debian/Ubuntu
# 3. 看 JVM 各块内存明细(需启动时开 NMT)
jcmd VM.native_memory summary
# 4. 看直接内存池
jcmd VM.native_memory summary | grep -i 'other\|internal'
常见结论对照:
| 现象 | 可能原因 | 方向 |
|---|---|---|
| 堆接近 Xmx,Full GC 频繁 | 堆不够或有泄漏 | 调堆或查泄漏 |
| 堆正常但 RSS 高,容器被杀 | 直接内存/元空间/线程栈过大 | 限直接内存、限元空间 |
| Metaspace 持续涨 | 动态类加载泄漏 | 查代理/脚本/反射 |
| 线程数暴涨导致内存高 | 线程池无界或泄漏 | 查线程池配置 |
Direct buffer memory 报错 |
直接内存超上限 | 调大或查 Netty 泄漏 |
常见问题
Q:-Xmx4g 是不是意味着进程最多用 4G 内存?
不是。-Xmx 只管堆。进程 RSS 还包括元空间、直接内存、线程栈、代码缓存和 JVM 自身。堆 4G 的进程用 6~7G 很常见。
Q:直接内存到底什么时候释放?
在持有它的 DirectByteBuffer 对象被 GC 回收时,通过 Cleaner 释放。所以释放时机取决于 GC,不是断开引用就立即回。这也是为什么直接内存容易"看着没引用却还占着"。
Q:要不要设置 -XX:MaxDirectMemorySize?
用 Netty/NIO 的服务建议设,避免直接内存无限制上涨;不用 NIO 的传统服务可以不设(默认约等于 Xmx)。设的时候要算进容器/物理机的总账。
Q:容器里 JDK 8 老版本识别不到 limit 怎么办?
老版本可能读到宿主机内存,导致堆设得过大。升级到 8u191+ 并显式加 -XX:+UseContainerSupport,或干脆手工写死匹配 limit 的 -Xmx。
Q:堆和直接内存哪个性能更好?
取决于用途。普通对象放堆;涉及 I/O 的数据缓冲放直接内存能省一次拷贝,性能更好。但直接内存分配和释放成本高、监控困难,不要为了"性能"滥用。
总结
堆内存受 -Xmx 管理、GC 直接回收、监控可见;直接内存是堆外的本地内存,不受 -Xmx 限制、依赖 GC 触发 Cleaner 回收、堆监控看不到,两者本质上是"JVM 仓库"和"向操作系统租的场地"的区别。
Java 进程的总内存远不止堆,还包括元空间、线程栈、代码缓存、直接内存和 JVM 自身。配置时必须按"堆 + 非堆 < 可用内存"来算账,而不是只盯 -Xmx。
容器环境要按容器 limit 配堆(用 MaxRAMPercentage 或匹配 limit 的 -Xmx),并确认 JDK 能识别 cgroup 限制,否则会按宿主机内存错误地开大堆。
-Xms 与 -Xmx 相等、显式设置 MaxMetaspaceSize 和 MaxDirectMemorySize、必要时限制线程栈,是减少 OOM 风险的几条标配。
排查"RSS 比堆大很多"时,优先开 NMT(-XX:NativeMemoryTracking=summary)并用 jcmd 看各项明细,而不是靠猜。
企业QQ咨询




