先打个比方
想象你住在小区 A,朋友住在小区 B,你要给他送一份文件。
普通方式(堆内存):
你把文件交给小区快递站 → 快递站发快递 → 文件送到朋友手上
你要给他送东西,得先交给快递站,快递站再转给朋友。快递站就是"中转站",多了一步。
直接内存方式:
你俩后来住进了同一个小区,文件直接递过去就行
东西直接递过去就行,不用走快递站,中间省了一步
直接内存就是 JVM绕过堆内存,直接向操作系统申请的一块内存区域,让 JVM 和操作系统共用同一块地盘。
具体发生了什么
没有直接内存时
磁盘/网卡 → 操作系统内核缓冲区 → 【复制一份】→ JVM 堆内存 byte[] → Java 程序读取 ↑ ↑ 系统管的区域 Java 管的区域每次做 I/O 操作(读文件、收网络数据),数据从磁盘出来后要先存在系统内核缓冲区,然后再复制一份到 JVM 堆内存的 byte[] 里,你的 Java 程序才能用。
同一份数据存了两份,复制这一步既浪费时间,又浪费内存。
有了直接内存后
磁盘/网卡 → 直接内存 → Java 程序读取 ↑ ↑ 系统能访问 JVM也能访问 同一块内存,不用复制数据从磁盘出来后,直接进入一块两边都能访问的内存区域。Java 堆里的 byte[] 缓冲区不再需要,中间那次复制被彻底干掉了。
代码怎么用
Java NIO 提供了ByteBuffer,就一个方法的区别:
// 普通的堆内存缓冲区 ByteBuffer heapBuffer = ByteBuffer.allocate(1024); // 直接内存缓冲区 —— 关键就在这里 ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);allocateDirect()就是向操作系统申请直接内存。用起来 API 一模一样,区别全在底层。
一个实际例子——文件零拷贝:
FileChannel in = FileChannel.open(Paths.get("大文件.dat")); FileChannel out = FileChannel.open(Paths.get("副本.dat")); // 底层就是直接内存,没有经过 Java 堆 in.transferTo(0, in.size(), out);你没手动分配 ByteBuffer,但transferTo内部就是靠直接内存实现的。很多你天天用的 API 底层都是这套机制。
堆内存 vs 直接内存,一张表看清
| 对比项 | 堆内存 | 直接内存 |
|---|---|---|
| 分配位置 | JVM 堆内 | 堆外,操作系统管理 |
| 分配速度 | 快 | 相对慢(要跟操作系统打交道) |
| 读写速度 | 慢(需要额外复制到系统缓冲区) | 快(直接访问,不需要复制) |
| GC 管理 | 自动回收 | 不归 GC 管,需要自己管理 |
| 适合场景 | 普通 Java 对象 | 大量 I/O 操作(网络、文件) |
核心优势:少了一次内存复制,频繁 I/O 场景下性能提升明显。
三个坑,用之前得知道
坑一:分配慢
堆上 new 一个数组很快,但allocateDirect每次都要跟操作系统申请,开销不小。不是 I/O 密集的场景,没必要用。
坑二:不归 GC 管
GC 不会主动回收直接内存。短时间内分配了大量直接内存又不用,可能会报:
java.lang.OutOfMemoryError: Direct buffer memory建议启动时显式设置上限:
-XX:MaxDirectMemorySize=256m坑三:出了问题难查
直接内存不在堆里,jmap、jstat这些工具看不到它的全貌,排查成本比堆内存高不少。
谁在用
Netty(网络框架)—— ByteBuf 默认用直接内存,这是它高性能的核心原因之一
NIO FileChannel——
FileChannel.map()返回的就是直接内存Hadoop / Spark—— 大块缓冲区放堆外,减轻 GC 压力
各种 RPC 框架—— 涉及大量网络 I/O 的基本都在用
不是偏门知识,是 Java 高性能 I/O 的基础设施。
一句话总结
直接内存 = JVM 向操作系统借一块内存(JVM 和操作系统共用同一块地盘),跳过中间的复制环节,让 I/O 更快。代价是分配慢、不归 GC 管、排查困难。高频 I/O 场景值得用