什么是堆外内存(直接内存)?DirectByteBuffer 是怎么管理它的?
简化版
**堆外内存(Direct Memory / 直接内存)**是「不受 JVM 堆管理、直接向操作系统申请的内存」——通过 ByteBuffer.allocateDirect() 或 Unsafe 分配,NIO、Netty 大量使用。为什么要用堆外内存:① 零拷贝——IO 时数据不用在「JVM 堆 ↔ 内核缓冲区」之间多复制一次(堆内内存做 IO 要先复制到堆外,因为 GC 会移动对象,堆内地址不稳定);② 减少 GC 压力——堆外内存不在堆里,GC 不扫描它,大缓冲区放堆外能减轻 GC 负担。DirectByteBuffer 怎么管理它:它是堆内的一个「小对象」,但持有一块「堆外大内存」的地址;堆外内存的释放靠 Cleaner(虚引用机制)——当 DirectByteBuffer 对象被 GC 回收时,Cleaner 被触发,调用 freeMemory 释放对应的堆外内存。坑:堆外内存不受堆大小(-Xmx)限制,容易被忽视而泄漏;且它的释放依赖 DirectByteBuffer 被 GC,如果堆一直不 GC,堆外内存迟迟不释放,可能堆外 OOM。
详细版
堆内内存 vs 堆外内存:
| 维度 | 堆内存(Heap) | 堆外内存(Direct Memory) |
|---|---|---|
| 归谁管 | JVM,GC 自动回收 | 操作系统,需(间接)手动释放 |
| 受 -Xmx 限制 | 是 | 否(受 -XX:MaxDirectMemorySize) |
| 分配方式 | new 对象 | ByteBuffer.allocateDirect / Unsafe |
| IO 性能 | 要多一次复制(堆→堆外) | 零拷贝,快 |
| GC 影响 | 被 GC 扫描/移动 | 不被 GC 扫描 |
| 分配/释放开销 | 快 | 慢(要系统调用) |
// 分配堆外内存
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 1KB 堆外
ByteBuffer heap = ByteBuffer.allocate(1024); // 1KB 堆内
// DirectByteBuffer 内部(简化):
// - 堆内有个 DirectByteBuffer 小对象,记着堆外内存的地址 address
// - 构造时用 unsafe.allocateMemory 申请堆外内存
// - 注册一个 Cleaner(虚引用),关联释放逻辑
// - 当 DirectByteBuffer 被 GC,Cleaner 触发 → unsafe.freeMemory(address)
⚠️ 堆外内存的「隐藏」和「延迟释放」是两大坑——① 隐藏:堆外内存不算在 -Xmx 里,用
jmap、堆 dump 也看不到它的大小,容易被忽视;一个用了大量堆外内存的应用,堆看起来很健康,但进程总内存(RSS)远超 -Xmx,甚至被 OS 的 OOM Killer 杀掉。② 延迟释放:堆外内存靠 DirectByteBuffer 被 GC 才释放;如果这些 DirectByteBuffer 都在老年代、而老年代很久不 Full GC,堆外内存就迟迟不释放,可能先触发「Direct buffer memory」OOM。所以用堆外内存要显式设-XX:MaxDirectMemorySize并监控。
完整版教学
一、堆外内存是什么
堆外内存是「绕过 JVM 堆、直接向 OS 申请的内存」:
Java 进程的内存 = 堆内存 + 堆外内存 + 其他(栈、元空间、代码缓存...)
堆内存(Heap):
- JVM 管理,new 出来的对象都在这
- GC 自动回收,受 -Xmx 限制
堆外内存(Direct Memory / Off-Heap):
- 不在 JVM 堆里,直接向操作系统申请(malloc)
- GC 不直接管理它
- 通过 ByteBuffer.allocateDirect 或 Unsafe.allocateMemory 分配
- 受 -XX:MaxDirectMemorySize 限制(不设默认约等于 -Xmx)
堆外内存的本质是「JVM 进程里、但不归 JVM 堆管的内存」——它直接向 OS 申请,GC 不扫描它。它是 NIO(DirectByteBuffer)、Netty 高性能网络的基础。理解「堆外内存 = 绕过 JVM 堆直接向 OS 申请、GC 不直接管、受 MaxDirectMemorySize 限制、NIO/Netty 用它」,就理解了它的定位。
二、为什么要堆外内存:零拷贝
用堆外内存的第一个理由是「零拷贝,IO 更快」:
为什么堆内内存做 IO 要多一次复制?
- GC 会移动对象(如标记-整理、复制算法都会挪动对象位置)
- 如果直接把堆内内存地址传给 OS 做 IO(write/read)
IO 过程中 GC 移动了这块内存 → 地址失效 → 数据错乱!
- 所以 JVM 的做法:先把堆内数据复制到一块"堆外临时内存"(地址稳定)
再用这块堆外内存做 IO
→ 堆内 IO = 多一次"堆→堆外"的复制
用堆外内存做 IO:
数据本来就在堆外(地址稳定,GC 不动它)
→ 直接做 IO,省掉那次复制 → 零拷贝,更快
零拷贝是堆外内存的核心优势——堆内内存做 IO 必须先复制到堆外(因为 GC 会移动对象,堆内地址不稳定,不能直接传给 OS 做 IO)。而堆外内存地址稳定(GC 不动它),可以直接做 IO,省掉那次复制。对高频 IO(网络、文件),这次复制的节省很可观。理解「GC 移动对象导致堆内地址不稳、堆内 IO 要先复制到堆外、堆外内存地址稳定可直接 IO 零拷贝」,就理解了堆外内存的性能优势。
三、为什么要堆外内存:减少 GC 压力
第二个理由是「减少 GC 压力」:
堆里的大对象/大缓冲区对 GC 的负担:
- GC 要扫描、可能要移动它们(大对象移动开销大)
- 大缓冲区如果长期存活,占着老年代,增加 Full GC 压力
放到堆外:
- GC 完全不扫描堆外内存(它不在堆里)
- 大缓冲区放堆外 → 堆变小 → GC 扫描的对象少 → GC 更快、更少
典型场景:
- Netty 的网络缓冲区(大量、大块)放堆外,避免堆膨胀和 GC 抖动
- 缓存框架(如 Ehcache 的堆外缓存、OHC)把大量缓存放堆外
→ 堆里对象少,GC 平稳;缓存量大也不影响 GC
减少 GC 压力是堆外内存的第二个价值——大缓冲区/大缓存放堆外,GC 不扫描它们,堆变小,GC 更快更少。Netty 把网络缓冲区放堆外(避免堆膨胀和 GC 抖动)、堆外缓存框架(OHC、Ehcache 堆外)把海量缓存放堆外(缓存量再大也不拖累 GC)。代价是要自己管理内存(分配/释放)。理解「大缓冲区放堆外→GC 不扫描→堆变小→GC 更快、Netty/堆外缓存用它减轻 GC」,就理解了堆外内存的第二个价值。
四、DirectByteBuffer 怎么管理堆外内存
DirectByteBuffer 是 Java 操作堆外内存的核心类,它的管理机制很巧妙:
DirectByteBuffer 的结构(关键:它是"堆内小对象 + 堆外大内存"):
- DirectByteBuffer 对象本身在堆内(很小,就记几个字段)
- 它有个 address 字段,记着一块堆外内存的起始地址
- 构造时:unsafe.allocateMemory(size) 申请堆外内存,记下 address
读写:通过 address 直接读写堆外内存(unsafe.getXxx/putXxx)
释放:这是关键!堆外内存怎么释放?
- 不能靠 GC 直接回收(GC 只管堆内的 DirectByteBuffer 小对象)
- 用 Cleaner(一种虚引用):
创建 DirectByteBuffer 时,注册一个 Cleaner,关联"释放堆外内存"的逻辑
当 DirectByteBuffer 小对象被 GC 回收时,Cleaner 被触发
→ 执行 unsafe.freeMemory(address) 释放堆外内存
DirectByteBuffer 是「堆内小对象 + 堆外大内存」的组合——堆内的小对象记着堆外内存的地址。释放机制是关键:堆外内存靠 Cleaner(虚引用)释放——当堆内的 DirectByteBuffer 对象被 GC 回收时,关联的 Cleaner 被触发,调 freeMemory 释放堆外内存。所以「堆外内存的释放,间接依赖 DirectByteBuffer 被 GC」。理解「DirectByteBuffer 是堆内小对象记堆外地址、堆外内存靠 Cleaner(虚引用)在对象被 GC 时释放」,就理解了堆外内存的管理机制。
五、Cleaner 机制与虚引用
深入理解 Cleaner(虚引用)如何实现「对象死后释放堆外内存」:
Cleaner 基于虚引用(PhantomReference):
1. DirectByteBuffer 创建时,new 一个 Cleaner(虚引用),
引用这个 DirectByteBuffer,并关联一个"释放堆外内存"的 Runnable
2. 虚引用的特点:对象被回收时,虚引用会被加入"引用队列"
3. 有个后台机制(ReferenceHandler 线程 / Reference 处理)
发现 Cleaner 进了队列 → 执行它关联的 Runnable(freeMemory)
→ 达成"DirectByteBuffer 被 GC 后,自动释放它的堆外内存"
为什么用虚引用而不是 finalize():
- finalize() 有性能问题、时机不确定、可能"复活"对象,已废弃
- Cleaner(虚引用)更可靠、更轻量
(JDK 9 后有官方的 java.lang.ref.Cleaner,替代 sun.misc.Cleaner)
Cleaner 是「用虚引用感知对象死亡、然后执行清理」的机制——虚引用的特点是「对象被回收时会被加入引用队列」,后台线程发现后执行清理逻辑(freeMemory)。它比 finalize() 更可靠(finalize 有性能问题、时机不确定、能复活对象,已废弃)。JDK 9 后有官方的 java.lang.ref.Cleaner。理解「Cleaner 基于虚引用、对象被 GC 后进引用队列触发清理、比 finalize 可靠」,就理解了堆外内存自动释放的底层机制。
六、堆外内存的坑与监控
堆外内存有几个必须知道的坑,以及怎么监控:
坑一:隐藏(看不见)
- 堆外内存不算在 -Xmx 里,jmap/堆 dump 看不到
- 现象:堆看起来健康,但进程 RSS 远超 -Xmx
→ 甚至被 OS 的 OOM Killer 杀掉(容器里尤其常见)
监控:Native Memory Tracking (-XX:NativeMemoryTracking)、
或 BufferPoolMXBean 看 direct buffer 用量
坑二:延迟释放
- 堆外内存靠 DirectByteBuffer 被 GC 才释放
- 如果 DirectByteBuffer 进了老年代、老年代很久不 Full GC
→ 堆外内存迟迟不释放 → 先触发"Direct buffer memory" OOM
缓解:设 -XX:MaxDirectMemorySize(到达阈值会触发 GC 尝试释放)
坑三:泄漏
- 如果 DirectByteBuffer 一直被强引用(如放进静态集合)
→ 它和它的堆外内存都释放不了 → 堆外泄漏
排查:Netty 的 ResourceLeakDetector、NMT
最佳实践:
- 显式设 -XX:MaxDirectMemorySize,别用默认
- 监控堆外内存用量
- Netty 等框架用引用计数(ReferenceCounted)手动管理,及时 release
堆外内存的三大坑:① 隐藏(不算在 -Xmx,RSS 超标可能被 OOM Killer 杀,容器里尤其危险);② 延迟释放(依赖 DirectByteBuffer 被 GC,老年代不 GC 就迟迟不释放,可能先堆外 OOM);③ 泄漏(被强引用就释放不了)。最佳实践:显式设 -XX:MaxDirectMemorySize + 监控(NMT/BufferPoolMXBean)+ 框架层手动管理(Netty 的引用计数及时 release)。理解「堆外内存隐藏(RSS 超标被杀)、延迟释放(依赖 GC)、泄漏(被强引用),要设 MaxDirectMemorySize 并监控」,就掌握了堆外内存的实战注意点。
记忆钩子:「堆外内存(Direct Memory)= 绕过 JVM 堆直接向 OS 申请、GC 不管、受 MaxDirectMemorySize 限制;两大好处:① 零拷贝(堆内 IO 要先复制到堆外因为 GC 会移动对象、堆外地址稳定可直接 IO)② 减少 GC 压力(大缓冲区放堆外 GC 不扫描);DirectByteBuffer = 堆内小对象记堆外地址,堆外内存靠 Cleaner(虚引用)在对象被 GC 时 freeMemory 释放;三大坑:隐藏(不算 -Xmx,RSS 超标被 OOM Killer 杀)、延迟释放(依赖 GC,老年代不 GC 就不放,可能先堆外 OOM)、泄漏(被强引用);要显式设 MaxDirectMemorySize 并监控」。
七、常见误区与追问
- 误区:堆外内存受 -Xmx 限制。 不受——堆外内存独立于堆,-Xmx 只管堆;堆外受 -XX:MaxDirectMemorySize 限制(不设则默认约等于 -Xmx)。这也是它「隐藏」的原因:堆看着健康,进程 RSS 却超 -Xmx。
- 误区:堆外内存会被 GC 自动回收。 GC 只回收堆内的 DirectByteBuffer 小对象;堆外那块大内存是当 DirectByteBuffer 被 GC 时,通过 Cleaner(虚引用)触发 freeMemory 间接释放的——所以释放依赖 DirectByteBuffer 被 GC。
- 误区:用堆外内存一定更快。 分配/释放堆外内存要系统调用,比 new 对象慢;它的优势在「大缓冲区 + 频繁 IO」(零拷贝 + 减 GC);小对象、短生命周期用堆外反而更慢,所以 DirectByteBuffer 适合复用(池化)。
- 误区:设了 -Xmx 就控制了进程内存。 进程总内存 = 堆 + 堆外 + 元空间 + 线程栈 + 代码缓存等;只设 -Xmx 控制不住堆外内存,容器里要把堆外也算上(否则 RSS 超 cgroup 限制被 OOM Killer 杀)。
- 追问:为什么堆内内存做 IO 要多一次复制? GC 会移动对象(复制/整理算法会挪动对象位置),若直接把堆内地址传给 OS 做 IO,IO 期间 GC 移动了这块内存会导致地址失效、数据错乱;所以 JVM 先把堆内数据复制到地址稳定的堆外临时内存再做 IO——堆外内存本身地址稳定,省掉这次复制。
- 追问:DirectByteBuffer 的堆外内存什么时候释放? 当 DirectByteBuffer 对象被 GC 回收时,它注册的 Cleaner(虚引用)被加入引用队列,后台线程执行 freeMemory 释放堆外内存;也可以主动调用(反射拿 Cleaner 手动 clean)提前释放。到达 MaxDirectMemorySize 时也会触发 GC 尝试回收。
- 追问:为什么用 Cleaner(虚引用)而不是 finalize? finalize() 有性能差、执行时机不确定、可能让对象「复活」等问题,已被废弃;Cleaner 基于虚引用,更轻量可靠,只做清理不复活对象;JDK 9 提供了官方的 java.lang.ref.Cleaner。
八、加强记忆
堆外内存(Direct Memory / 直接内存)是「不受 JVM 堆管理、直接向 OS 申请的内存」——通过 ByteBuffer.allocateDirect() 或 Unsafe 分配,受 -XX:MaxDirectMemorySize 限制(不受 -Xmx),NIO/Netty 大量使用。两大好处:① 零拷贝——堆内内存做 IO 要先复制到堆外(因为 GC 会移动对象、堆内地址不稳定,不能直接传 OS 做 IO),堆外内存地址稳定可直接 IO 省掉这次复制;② 减少 GC 压力——大缓冲区放堆外,GC 不扫描,堆变小 GC 更快。DirectByteBuffer 的管理:它是「堆内小对象(记堆外地址)+ 堆外大内存」;堆外内存靠 Cleaner(虚引用) 释放——当 DirectByteBuffer 被 GC 回收时,Cleaner 进引用队列、后台线程调 freeMemory 释放(比 finalize 可靠)。三大坑:① 隐藏(不算 -Xmx、jmap 看不到,RSS 超标可能被 OOM Killer 杀,容器里危险);② 延迟释放(依赖 DirectByteBuffer 被 GC,老年代久不 Full GC 就迟迟不放,可能先「Direct buffer memory」OOM);③ 泄漏(被强引用释放不了)。最佳实践:显式设 -XX:MaxDirectMemorySize + 监控(NMT/BufferPoolMXBean)+ 框架层手动管理(Netty 引用计数及时 release)。一句话「堆外内存 = 绕过堆向 OS 申请,零拷贝(堆外地址稳定省一次复制)+ 减 GC 压力;DirectByteBuffer 堆内小对象记堆外地址、靠 Cleaner 虚引用在对象被 GC 时 freeMemory;坑是隐藏(RSS 超标被杀)/延迟释放(依赖 GC)/泄漏,要设 MaxDirectMemorySize 并监控」。