03.7 - 直接内存
定位: JVM 堆外的、由 OS 管理的原生内存——通过 NIO DirectByteBuffer 绕过堆限制直接操作 面试高频度: ⭐⭐⭐ 考查方式: 零拷贝原理、DirectByteBuffer 回收、堆外内存 OOM 排查
一、这是什么?为什么需要它?
直接内存(Direct Memory)不是 JVM 规范中定义的运行时数据区,但它是在 JVM 实践中广泛使用的内存区域——通过 NIO(New I/O)在堆外分配的内存块。
为什么需要直接内存? 答案在传统 I/O 的性能瓶颈中。
传统 I/O 的数据路径(有拷贝):
传统 I/O: FileInputStream.read(byte[] buf)
↓
磁盘 → [OS 内核缓冲区] → [JVM 堆缓冲区] → 应用代码
① DMA 拷贝 ② CPU 拷贝 ③ 可访问
↑
上下文切换 + 拷贝开销当应用代码调用 read() 时,数据经历了两次拷贝:
- DMA 拷贝: 磁盘 → OS 内核缓冲区(硬件直接完成,不占用 CPU)
- CPU 拷贝: OS 内核缓冲区 → JVM 堆缓冲区(CPU 实际参与复制)
第二次拷贝是问题所在:数据从内核态复制到用户态。对于大文件或高并发 I/O,这段时间消耗了大量 CPU 周期和内存带宽。
NIO 直接内存的数据路径(零拷贝):
NIO: DirectByteBuffer
↓
磁盘 → [OS 内核缓冲区] ↔ [直接内存] → 应用代码
① DMA 拷贝 ② 共享映射 ③ 直接访问
↑
没有第二次拷贝!
OS 内核可以直接访问直接内存 (DMA)直接内存位于堆外(off-heap),由 OS 管理。OS 的内核态可以直接通过 DMA 技术向直接内存写入数据,不需要 CPU 参与第二次拷贝。这就是"零拷贝"(Zero-Copy)的本质——不是没有拷贝,而是消除了 CPU 参与的拷贝次数。
传统 I/O: 磁盘 → 内核态 → 用户态(堆) = 2 次拷贝 + 上下文切换
零拷贝: 磁盘 → 内核态 ↔ 直接内存(堆外) = 1 次拷贝 (DMA) + 共享映射二、原理拆解
2.1 DirectByteBuffer 的堆外秘密
java.nio.DirectByteBuffer 是 Java 操作直接内存的门面。但它的实例本身非常小——仅占堆上几十字节。
DirectByteBuffer 对象 (在堆上, 很小):
┌─────────────────────────────────────┐
│ address (long) → 0x7f8a3c000000 │ ← 指向堆外内存地址
│ capacity (int) → 1048576 │ (1MB 的直接内存)
│ cleaner → Cleaner 实例 │ ← 用于回收堆外内存
│ ... │
└─────────────────────────────────────┘
│
▼ (address 字段指向)
┌─────────────────────────────────────┐
│ 堆外内存 (Direct Memory, 1MB) │
│ [0x7f8a3c000000 ~ 0x7f8a3c100000] │
│ OS 管理, 不受 -Xmx 控制 │
└─────────────────────────────────────┘这里的 address 是一个 long 类型的字段,存储的是通过 Unsafe.allocateMemory() 分配的原生内存地址。这本质上是一个伪装成 Java long 的 C 指针。
// DirectByteBuffer 构造简化逻辑
DirectByteBuffer(int cap) {
long base = unsafe.allocateMemory(cap); // C 语言: malloc(cap)
unsafe.setMemory(base, cap, (byte) 0); // C 语言: memset(base, 0, cap)
address = base; // 保存原生地址
cleaner = Cleaner.create(this, new Deallocator(base, cap)); // 回收器
}2.2 回收机制 —— Cleaner 与 PhantomReference
DirectByteBuffer 的堆外内存回收是一个精巧而脆弱的机制:
// 清理器 (Deallocator) 的逻辑
private static class Deallocator implements Runnable {
private long address;
public void run() {
unsafe.freeMemory(address); // C 语言: free(address)
}
}回收流程:
DirectByteBuffer 对象变得不可达
↓
GC 发现 DirectByteBuffer 对象不可达
↓
Cleaner (PhantomReference 子类) 被加入 ReferenceQueue
↓
Reference Handler 线程处理 Cleaner
↓
调用 Deallocator.run() → unsafe.freeMemory(address)
↓
堆外内存被释放关键问题: 堆外内存的回收依赖堆上的 DirectByteBuffer 对象被 GC。如果 DirectByteBuffer 还在堆上活跃(可达),Deallocator 永远不会被触发。
这就导致了一个危险的场景:
场景: 大量分配 DirectByteBuffer, 但不 GC
堆 (Heap) 直接内存 (Direct Memory)
┌─────────────────┐ ┌────────────────────────┐
│ DirectByteBuffer │ │ 1GB 堆外数据 │
│ 对象 (每个 ~24 字节)│ │ (已分配但未使用) │
│ ┌─┐┌─┐┌─┐... │ │ │
│ │a││a││a│ │ │ 堆外可用空间: 0 │
│ └─┘└─┘└─┘ │ └────────────────────────┘
│ 堆可用空间: 800MB │ ↑ 堆外 OOM 但是堆还有很多空间!
└─────────────────┘堆看起来还有很多空间(因为 DirectByteBuffer 对象本身很小),但堆外内存已经被耗尽。此时分配新的 DirectByteBuffer 会抛出 OutOfMemoryError: Direct buffer memory。
手动回收(推荐):
import sun.nio.ch.DirectBuffer;
// 获取 DirectByteBuffer 的 Cleaner 并手动触发回收
((DirectBuffer) buffer).cleaner().clean();
// 或者通过 Unsafe
unsafe.freeMemory(address);2.3 直接内存与堆内存的对比
| 维度 | 堆内存 (Heap) | 直接内存 (Direct Memory) |
|---|---|---|
| 分配/回收 | JVM 自动分配,GC 自动回收 | Unsafe.allocateMemory() / freeMemory() |
| 速度 | 快(TLAB bump) | 慢(系统调用 malloc) |
| 读取/写入速度 | 慢(堆内 → 内核需要额外的 copy) | 快(OS 可直接 DMA 操作) |
| 大小限制 | -Xmx | OS 物理内存 / -XX:MaxDirectMemorySize |
| GC 影响 | 频繁(Young GC 扫描) | 无(堆外,GC 不扫描) |
| 适合场景 | 大多数普通对象 | 大文件 I/O、网络传输、共享内存 |
三、图解全景
┌─────────────────────────────────────────────────────────────────────┐
│ 传统 I/O vs 直接内存 I/O 数据路径对比 │
│ │
│ 传统 I/O (FileInputStream) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ① 应用程序调用 read(buf) │ │
│ │ ↓ (系统调用, 用户态→内核态) │ │
│ │ ② OS 发起 DMA: 磁盘 → 内核缓冲区 │ │
│ │ ↓ │ │
│ │ ③ CPU 拷贝: 内核缓冲区 → JVM 堆 (buf 数组) │ │
│ │ ↓ (系统调用返回, 内核态→用户态) │ │
│ │ ④ 应用程序读取 buf 中的数据 │ │
│ │ │ │
│ │ 总开销: DMA(1) + CPU拷贝(1) + 上下文切换(2) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ NIO (DirectByteBuffer) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ① 应用程序创建 DirectByteBuffer(1MB) │ │
│ │ unsafe.allocateMemory(1MB) → 直接内存地址 addr │ │
│ │ ↓ │ │
│ │ ② 发起 Channel.read(directByteBuffer) │ │
│ │ ↓ (系统调用) │ │
│ │ ③ OS 发起 DMA: 磁盘 → [内核缓冲区] → [直接内存] │ │
│ │ ↑ DMA 可以直接写入直接内存, 因为直接内存的物理地址对 OS 可见 │ │
│ │ ↓ (系统调用返回) │ │
│ │ ④ 应用程序直接从 directByteBuffer 读取数据 │ │
│ │ (地址 addr 已经在用户态地址空间映射好) │ │
│ │ │ │
│ │ 总开销: DMA(1) + 上下文切换(2) │ │
│ │ 优势: 没有 CPU 参与的第二次拷贝! │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘四、实战验证
验证 1: 性能对比
import java.nio.ByteBuffer;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
public class DirectMemoryBenchmark {
private static final int FILE_SIZE = 500 * 1024 * 1024; // 500MB
public static void main(String[] args) throws Exception {
// 创建测试文件
try (RandomAccessFile file = new RandomAccessFile("test.dat", "rw");
FileChannel channel = file.getChannel()) {
file.setLength(FILE_SIZE);
// 测试堆 ByteBuffer 读取
ByteBuffer heapBuf = ByteBuffer.allocate(64 * 1024); // 64KB
long t1 = System.nanoTime();
for (int i = 0; i < FILE_SIZE / (64 * 1024); i++) {
channel.position(i * (long)64 * 1024);
channel.read(heapBuf);
heapBuf.clear();
}
long t2 = System.nanoTime();
System.out.println("Heap ByteBuffer: " + (t2 - t1) / 1_000_000 + "ms");
// 测试 DirectByteBuffer 读取
ByteBuffer directBuf = ByteBuffer.allocateDirect(64 * 1024);
long t3 = System.nanoTime();
for (int i = 0; i < FILE_SIZE / (64 * 1024); i++) {
channel.position(i * (long)64 * 1024);
channel.read(directBuf);
directBuf.clear();
}
long t4 = System.nanoTime();
System.out.println("DirectByteBuffer: " + (t4 - t3) / 1_000_000 + "ms");
}
}
}javac DirectMemoryBenchmark.java && java DirectMemoryBenchmark
# 典型结果 (不同系统有差异):
# Heap ByteBuffer: 1523ms
# DirectByteBuffer: 987ms
# Direct 快了约 35%, 主要原因就是少了一次 CPU 拷贝验证 2: 直接内存 OOM
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class DirectOOMDemo {
public static void main(String[] args) {
List<ByteBuffer> buffers = new ArrayList<>();
try {
while (true) {
buffers.add(ByteBuffer.allocateDirect(100 * 1024 * 1024)); // 100MB
}
} catch (OutOfMemoryError e) {
System.out.println("OOM: " + e.getMessage());
// 输出: OOM: Direct buffer memory
}
}
}# 设置直接内存上限为 500MB
java -XX:MaxDirectMemorySize=500m DirectOOMDemo
# 在分配约 5 次 100MB 后抛出 OOM排查直接内存泄漏:
# 1. 查看进程的 native 内存使用
# Linux:
pmap -x <pid> | grep "anon" | awk '{sum+=$3} END {print sum}'
# 2. 使用 jcmd 查看 native memory 细节 (JDK 8+)
jcmd <pid> VM.native_memory summary
# 3. NMT (Native Memory Tracking) 需要启动时开启
java -XX:NativeMemoryTracking=summary ...
jcmd <pid> VM.native_memory detail验证 3: MaxDirectMemorySize 默认值
java -XX:+PrintFlagsFinal -version | grep MaxDirectMemorySize
# 输出 (JDK 8, 8GB 机器):
# uintx MaxDirectMemorySize = 0 {product}
# 0 表示默认值 = -Xmx 的值 (与最大堆大小相同)也就是说:如果你设置了 -Xmx2g,则 MaxDirectMemorySize 默认也是 2GB。这常常是隐藏的 OOM 来源——应用大量使用 NIO,直接内存消耗了接近 2GB,但堆监控显示才用了 1GB。
五、面试视角
| 追问 | 答案要点 |
|---|---|
| 直接内存和堆内存的区别? | 堆内存受 -Xmx 限制、由 GC 管理、分配快(TLAB bump)。直接内存是堆外原生内存、受 OS 管理、适合 I/O 的大块连续内存、分配慢(malloc)但读写快(零拷贝) |
| 直接内存为什么会 OOM?不是堆外吗? | 会。-XX:MaxDirectMemorySize(默认 = -Xmx)限制了直接内存上限。且回收依赖堆上的 DirectByteBuffer 对象被 GC—堆不 GC 则直接内存也无法释放 |
| DirectByteBuffer 如何回收? | 通过 Cleaner(PhantomReference)机制。DirectByteBuffer 被 GC 时,Cleaner 触发 Deallocator.run() → unsafe.freeMemory() 释放堆外内存。也可以手动调用 ((DirectBuffer)buffer).cleaner().clean() |
| 零拷贝的原理? | 直接内存的物理地址对 OS 内核可见。DMA 操作可以直接将数据从磁盘写入直接内存,不需要 CPU 把数据从内核缓冲区复制到 JVM 堆缓冲区。不是没有拷贝,而是消除了 CPU 参与的拷贝 |
| 直接内存分配为什么比堆慢? | 直接内存分配触发系统调用(malloc/mmap),涉及用户态→内核态切换。堆分配只是指针 bump(TLAB 内),无锁无系统调用 |
| 如何排查直接内存泄漏? | 开启 NMT(-XX:NativeMemoryTracking=summary),使用 jcmd <pid> VM.native_memory;Linux 使用 pmap;监控 DirectByteBuffer 数量的 JMX Bean (java.nio:type=BufferPool,name=direct) |
📚 相关链接
- **Java堆** — 直接内存与堆的对比:生命周期、GC、分配速度
- **对象创建过程** — 堆上对象的分配 vs 直接内存 allocation
- ← 返回 **运行时数据区索引**