JVM堆内存和直接内存有什么区别?Java服务器内存配置详解

给 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 看各项明细,而不是靠猜。