一、什么是堆外内存?
堆外内存(Off-Heap Memory),顾名思义,就是不在 JVM 堆内的内存。
┌──────────────────────────────────┐
│ 操作系统物理内存 │
│ ┌────────┐ ┌────────────────┐ │
│ │ JVM 堆 │ │ 堆外内存 │ │
│ │ -Xmx │ │ -XX:MaxDirect │ │
│ │ GC │ │ MemorySize │ │
│ │ 管理 │ │ 手动管理 │ │
│ └────────┘ └────────────────┘ │
└──────────────────────────────────┘核心区别:
| 维度 | 堆内内存 | 堆外内存 |
|---|---|---|
| 管理方式 | JVM GC 自动回收 | 程序员手动释放 |
| 数据读写 | 受 GC 停顿影响 | 无停顿 |
| 存储位置 | JVM 进程内 | 直接内存 / 内存映射 |
| IO 传输 | 需要从堆拷贝到直接内存 | 零拷贝 |
| 大小限制 | -Xmx | -XX:MaxDirectMemorySize |
二、为什么需要堆外内存?三大核心场景
2.1 场景一:避免 GC 停顿
这是最朴素的需求。堆内对象随时可能被 GC 移动或回收,对于微秒甚至纳秒级的延迟敏感应用,一次 Full GC 停顿几十毫秒是灾难性的。
// 堆内:可能被 GC 移动
byte[] heapData = new byte[1024 * 1024];
// 一段时间后,这个引用可能指向了被 GC 复制到新位置的数据
// 堆外:地址固定,永不移动
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);
// directBuffer.address() 返回的内存地址永远不变
// 可以安全地传给 C 代码(JNI),不需要先 pin 住对象像 Disruptor 的 RingBuffer、Chronicle Queue 这类追求极低尾延迟的系统,都重度使用堆外内存,保证不会因为 GC 产生毛刺。
2.2 场景二:零拷贝 IO
这是堆外内存最大的性能优势。
堆内数据的 IO 路径:
Java堆内存 → 堆外临时缓冲区 → 内核Socket缓冲区 → 网卡
↑_____________一次拷贝_____________↑堆外数据的 IO 路径:
堆外内存 → 内核Socket缓冲区 → 网卡
↑_______直接映射,零拷贝_______↑实际表现:用 Netty 传输一个文件,如果是堆内 byte[],需要先复制到 DirectByteBuffer 再传给通道;如果是 FileChannel.map() 或 DirectByteBuffer,走的是 DMA(直接内存访问),CPU 几乎不参与拷贝。
2.3 场景三:突破堆大小限制
堆大小由 -Xmx 控制,但应用的“活数据”可能远超这个值。
堆外内存只要物理内存足够,可以分配远超 -Xmx 的量(受 -XX:MaxDirectMemorySize 控制,默认等于 -Xmx,但可以显式放大)。
-Xmx4g -XX:MaxDirectMemorySize=32g典型场景:缓存系统、消息队列、数据库连接池的缓冲区。
三、堆外内存的分配方式
3.1 DirectByteBuffer(最常用)
// 分配 1GB 堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
// 读写与堆内 ByteBuffer 完全一样
buffer.putLong(42);
buffer.flip();
long value = buffer.getLong();内部原理:
// DirectByteBuffer 构造器(简化)
DirectByteBuffer(int cap) {
// 1. 调用 unsafe.allocateMemory 分配堆外内存
long base = unsafe.allocateMemory(cap);
// 2. 初始化所有字节为0(安全考虑)
unsafe.setMemory(base, cap, (byte) 0);
// 3. 保存地址
this.address = base;
// 4. 注册一个 Cleaner,负责后续释放
cleaner = Cleaner.create(this, new Deallocator(base, cap));
}释放机制:
堆外内存的生命周期绑定在 DirectByteBuffer 对象上。当这个对象被 GC 回收时,Cleaner 会在 ReferenceHandler 线程中回调 Deallocator.run(),调用 unsafe.freeMemory(address) 释放内存。
一个巨大的陷阱:
如果 DirectByteBuffer 对象本身晋升到了老年代,但老年代一直没满,它就不会被回收,堆外内存就一直不释放。这就是著名的 直接内存泄露 问题。
补救方法:
// JDK 9+ 可以用这个黑魔法手动释放
((sun.nio.ch.DirectBuffer) buffer).cleaner().clean();或者用 Netty 这类框架,它们自己管理堆外内存的分配和释放。
3.2 Unsafe(完全手动)
// 反射获取 Unsafe 实例(JDK 8 方式)
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
// 分配
long address = unsafe.allocateMemory(1024);
// 写入
unsafe.putLong(address, 42L);
// 读取
long value = unsafe.getLong(address);
// 必须手动释放!!!
unsafe.freeMemory(address);完全控制,但没有安全网。忘了 freeMemory 就永久泄露,相当于 C 的 malloc/free。
3.3 MappedByteBuffer(内存映射文件)
// 将文件的前 1GB 映射到内存
RandomAccessFile file = new RandomAccessFile("data.bin", "rw");
MappedByteBuffer mapped = file.getChannel()
.map(MapMode.READ_WRITE, 0, 1024 * 1024 * 1024);
// 直接读写,操作系统负责刷盘
mapped.putLong(0, 42);
mapped.force(); // 强制刷盘这是 Chronicle Queue 的基础。数据在 OS 页缓存中,进程崩溃数据不丢,性能接近纯内存。
四、堆外内存的回收机制深度剖析
4.1 三阶段回收路径
DirectByteBuffer 对象被 GC 发现不可达
│
▼
┌───────────────────┐
│ 对象进入 finalize │ ← 如果重写了 finalize(),更慢
│ 队列等待处理 │
└────────┬──────────┘
│
▼
┌───────────────────┐
│ Cleaner 被触发 │ ← ReferenceHandler 线程处理
│ Deallocator.run() │
└────────┬──────────┘
│
▼
┌───────────────────┐
│ unsafe.freeMemory │ ← 真正释放
│ (address) │
└───────────────────┘关键隐患:
- ReferenceHandler 是单线程的,如果大量 DirectByteBuffer 同时被回收,释放会排队。
- 如果该线程因为某些原因阻塞,堆外内存释放就会堆积。
- 释放操作本身是 synchronized 的,有锁竞争。
4.2 监控与诊断
# 查看堆外内存使用情况
jcmd <pid> VM.native_memory summary
# 或者用 JMX 监控
java.lang:type=BufferPool,name=direct
# Count: 当前 DirectByteBuffer 对象数
# MemoryUsed: 估计的堆外内存使用量实际案例:如果 Count 很高但 MemoryUsed 接近上限,说明有很多小 Buffer 没来得及释放。
五、框架中的堆外内存实践
5.1 Netty 的 PooledByteBufAllocator
Netty 不直接使用 ByteBuffer.allocateDirect(),而是自己实现了一套池化分配器:
// Netty 的池化堆外内存
PooledByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
ByteBuf buf = allocator.directBuffer(1024);
// 用完释放回池
buf.release();分层设计:
ThreadLocal 缓存 → 不同大小的内存块队列 → 按需向 OS 申请
(极小快) (PoolArena) (PoolChunk)这种设计避免了频繁的系统调用和碎片问题,同时解决了 DirectByteBuffer 的延迟释放问题。
5.2 Chronicle Queue 的持久化堆外
Chronicle Queue 使用 MappedByteBuffer 将消息直接写入映射文件,然后不同进程甚至不同机器可以同时读取。本质上是一个跨进程的堆外内存循环队列。
六、何时该用、何时不该用?
适合用堆外内存
- 网络 IO 框架(Netty):零拷贝传输,性能提升明显。
- 极低延迟系统(金融交易):不能容忍 GC 停顿。
- 超大缓存(缓存 > 32GB):JVM 堆太大 GC 压力会剧增。
- 跨进程通信:内存映射文件。
不适合用堆外内存
- 小对象:分配和释放开销比堆内大。
- 短生命周期:频繁分配释放会造成碎片。
- 复杂对象图:堆外内存需要手动序列化/反序列化,开发成本高。
- 业务逻辑复杂的领域模型:不如让 JVM 自动管理。
七、总结
┌──────────────────────────────────────────┐
│ 堆外内存选择决策树 │
└──────────────────────────────────────────┘
需要极低延迟(<1ms P999)?
├─ 是 → 池化堆外内存(Netty ByteBuf)
└─ 否 → 数据要持久化或跨进程?
├─ 是 → MappedByteBuffer
└─ 否 → 数据量 > 4GB?
├─ 是 → DirectByteBuffer + 手动释放
└─ 否 → 老老实实用堆内存堆外内存不是银弹,它是一个强大的工具,但需要你愿意为性能付出更多的心智成本去管理它的生命周期。如果你的系统对延迟的容忍度在毫秒级以上,让 JVM 的 GC 来处理其实已经足够好了。

