Skip to content

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() 时,数据经历了两次拷贝:

  1. DMA 拷贝: 磁盘 → OS 内核缓冲区(硬件直接完成,不占用 CPU)
  2. 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 指针

java
// 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 的堆外内存回收是一个精巧而脆弱的机制:

java
// 清理器 (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

手动回收(推荐):

java
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 操作)
大小限制-XmxOS 物理内存 / -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: 性能对比

java
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");
        }
    }
}
bash
javac DirectMemoryBenchmark.java && java DirectMemoryBenchmark
# 典型结果 (不同系统有差异):
# Heap ByteBuffer: 1523ms
# DirectByteBuffer: 987ms
# Direct 快了约 35%, 主要原因就是少了一次 CPU 拷贝

验证 2: 直接内存 OOM

java
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
        }
    }
}
bash
# 设置直接内存上限为 500MB
java -XX:MaxDirectMemorySize=500m DirectOOMDemo
# 在分配约 5 次 100MB 后抛出 OOM

排查直接内存泄漏:

bash
# 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 默认值

bash
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
  • ← 返回 **运行时数据区索引**

Knowledge4J — Java 知识库