本节摘要:直接内存(Direct Memory)不属于规范定义的运行时数据区,而是 JVM 通过本地库在堆外申请的本地内存,NIO 的 DirectByteBuffer 是它最典型的住客。本节讲清它为什么能加速 IO、回收为何依赖 Cleaner 与 GC 的间接配合,以及它如何成为容器内存事故的隐形推手。
第二章到这里还剩一块"编外"切片。说它编外,是因为 JVMS 描述运行时数据区时根本没有列它;说它重要,是因为 Netty、Kafka 客户端、各类 RPC 框架与数据库驱动都在重度使用它。监控面板上堆占用四成、进程内存却逼近容器上限的怪事,多半就是这块隐秘组织在暗中膨胀。看完本节,第二章的解剖就完整了:堆内外都摸清,第五章谈 GC 时你才知道"堆"只是回收的一半战场。
直接内存是 JVM 进程通过本地库(malloc 一类的系统调用)在堆外申请的内存,Java 侧用一个指向它的地址来操作。Java 对象本来就能装数据,为什么要绕到堆外?答案藏在 IO 路径里。
用堆内字节数组做网络或文件 IO 时,数据必须先从堆复制到一块本地内存(JVM 无法把堆内对象地址直接交给操作系统做 DMA,堆内对象会被 GC 移动,地址不稳定),再由操作系统写出——一来一回多了一次拷贝。用直接内存则省掉这次中转:数据从操作系统缓冲区直达堆外内存,Java 代码直接读写这块固定地址。对于高频大流量的 IO 场景(网络框架、消息中间件),少一次全量拷贝就是实打实的吞吐。
NIO 里它对应的类型是 DirectByteBuffer,通过 ByteBuffer.allocateDirect 分配:
import java.nio.ByteBuffer; public class DirectMemPeek { public static void main(String[] args) { ByteBuffer heapBuf = ByteBuffer.allocate(1024); // 堆内:背后是 byte 数组 ByteBuffer directBuf = ByteBuffer.allocateDirect(1024); // 堆外:背后是本地内存 System.out.println("heap isDirect: " + heapBuf.isDirect()); System.out.println("direct isDirect: " + directBuf.isDirect()); // 写入再读出,接口完全一致,底层路径不同 directBuf.putLong(42L); directBuf.flip(); System.out.println("value: " + directBuf.getLong()); } }
$ java DirectMemPeek heap isDirect: false direct isDirect: true value: 42
isDirect 的差异背后就是两条 IO 路径的差异。上限方面,-XX:MaxDirectMemorySize 显式封顶;不设时默认与堆上限对齐(此默认值是实现行为,并非规范要求)。超限分配会抛 OutOfMemoryError: Direct buffer memory。
直接内存最反直觉的地方在于回收。DirectByteBuffer 对象本身很小,住在堆里,它内部记着堆外那块大内存的地址。堆外内存的释放不是你调 free,而是由 Cleaner 机制接管:DirectByteBuffer 关联一个 Cleaner,当这个 Java 对象本身被垃圾回收时,Cleaner 被触发,进而调用本地方法释放堆外内存。换句话说,堆外内存的归还搭了堆内对象回收的便车。
这张流程图解释了一个经典的内存曲线:堆内 DirectByteBuffer 对象还年轻、未触发 GC 时,对应的堆外内存即便已经"没用了"也无法归还。于是进程总内存(堆外为主)远高于堆使用率,监控若只看堆,会完全漏掉这笔账。主动清理可以调用 system.gc 搭桥(很多框架在分配失败时会这么做),或者升级到 JDK 后期提供的显式 API—— JDK 21 起由 Arena 与外部函数接口接管这类生命周期管理,分配与释放都变成显式动作,不再依赖 GC 顺手帮忙,本册第四章末尾会再提到这条新路线。
下面的实验故意让堆外内存膨胀而堆内几乎不动,再用本机命令观察进程实际内存(Linux 用 top 或 ps 的 RSS 列;下面的会话演示思路,数字因环境而异):
import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; public class DirectGrowth { public static void main(String[] args) throws Exception { List<ByteBuffer> keep = new ArrayList<>(); for (int i = 0; i < 200; i++) { keep.add(ByteBuffer.allocateDirect(1024 * 1024)); // 每轮堆外加一 MB Thread.sleep(100); } System.out.println("direct allocated: " + keep.size() + " MB, now watch process RSS"); Thread.sleep(60000); } }
$ java -Xmx64m -XX:MaxDirectMemorySize=256m DirectGrowth & $ top -p $(jps | awk '/DirectGrowth/{print $1}') PID USER VIRT RES SHR S %CPU %MEM 28164 zcode 4.9g 328m 16m S 0.3 2.1
堆上限只给了六十四 MB,进程常驻内存却涨到三百多 MB——多出来的部分就是堆外直接内存加上元数据与线程栈。这正是"按堆大小划容器配额必翻车"的实证素材,第六章容器化调优一节会引用这笔账目做完整推演。
⚠️ 常见坑一:以为堆外内存不用管回收。Cleaner 的触发依赖堆内对象被 GC,若持有一批 DirectByteBuffer 的集合本身长生不死,堆外内存就永不归还。堆外对象同样要按"用完即清"管理,框架场景优先使用对象池与引用计数(Netty 的 ByteBuf 体系就是这么做的)。
⚠️ 常见坑二:直接内存与 MMAP 混为一谈。mmap 文件映射属于另一类堆外占用(由操作系统页缓存支撑),不计入 MaxDirectMemorySize 的账本。进程内存盘点要把直接内存、mmap、Metaspace、线程栈分开列项,笼统说"堆外"会漏算。
💡 关键直觉:直接内存是"用 GC 的规则管理非 GC 的资源"。它享受 IO 免拷贝的红利,就要付出生命周期间接化的代价——谁持有引用谁负责释放,这根弦在写框架代码时永远要绷着。
第二章的解剖到此收工:堆、方法区、栈、程序计数器、直接内存,五块切片全部上台。下一章顺着方法区的线索向上追——类是怎么被装进标本柜的?装载、链接、初始化的流水线上,验证工序如何拦截恶意字节码,双亲委派的秩序又靠什么维持。