NIO 性能调优:零拷贝、直接内存与线上问题排查全解析

1 ⚡ 问题切入:文件服务器 CPU 100%,网络带宽却没用满

一个典型的文件下载服务,使用传统 Java I/O 实现:

// 传统文件传输:将磁盘文件发送给客户端
public static void sendFile(Socket socket, String filepath) throws IOException {
    FileInputStream fis = new FileInputStream(filepath);
    BufferedInputStream bis = new BufferedInputStream(fis);
    OutputStream os = socket.getOutputStream();

    byte[] buf = new byte[8192];
    int len;
    while ((len = bis.read(buf)) != -1) {
        os.write(buf, 0, len);  // 每次循环:内核→用户→内核→网卡
    }
    bis.close();
    fis.close();
}

这段代码能工作,但投入生产后出现异常现象:4 核 CPU 全部 100%,但千兆网卡只用了 600Mbps。理论上这台机器完全可以跑满千兆,为什么 CPU 先成了瓶颈?

答案在于 数据拷贝次数。每一次 read() + write() 循环,数据经历了 4 次跨总线拷贝和 4 次上下文切换,其中 2 次拷贝由 CPU 执行——CPU 在"搬运数据"而非"处理业务"。当文件传输量增大时,CPU 的所有时间都耗在 memcpy 上,根本没有余力处理其他请求。

这个问题在生产环境中极其常见。Kafka 在早期版本中频繁遇到,Netty 的文件传输功能就是为解决这个问题而优化的。核心解决方案是 零拷贝 (Zero-Copy,让数据从磁盘到网卡的过程中,CPU 不参与数据搬运)。

2 🚀 零拷贝

2.1 ❓ 什么是零拷贝——从硬件数据流理解

“零拷贝"这个词容易引起误解。它不是说"完全没有拷贝”,而是"没有 CPU 参与的拷贝"。DMA(Direct Memory Access,直接内存访问)拷贝仍然存在,但 DMA 由硬件控制器(DMAC)完成,不消耗 CPU 周期。

在深入理解之前,先明确三个概念:

术语含义谁执行消耗 CPU?
DMA Copy(DMA 拷贝)硬件 DMA 控制器在设备与内存之间搬运数据主板上的 DMAC 芯片,CPU 可并行执行其他指令
CPU Copy(CPU 拷贝)CPU 执行 memcpy 类指令在内存区域间搬运数据CPU 核心,占用 ALU 和总线带宽
DMA Gather Copy(DMA 聚集拷贝)NIC 的 DMA 引擎从多个不连续的物理内存页直接收集数据并发送网卡上的 DMA 引擎,且省去了一次额外的内核拷贝

下面这张图完整展示了传统 I/O 与零拷贝在硬件层面的数据流向差异:

传统 I/O vs 零拷贝 硬件数据流对比

2.2 🔴 传统 I/O 的数据搬运路径(4 拷贝 + 4 切换)

以文件下载为例,Java 调用 read() 然后 write() 的过程如下:

sequenceDiagram
    participant App as 应用进程(User)
    participant Kern as 内核态(Kernel)
    participant Disk as 磁盘控制器(DMA)
    participant NIC as 网卡控制器(DMA)

    Note over App: ① read() 系统调用
    App->>Kern: 上下文切换: User→Kernel
    Disk->>Kern: DMA Copy 1: 磁盘→Kernel Read Buffer
    Kern->>App: CPU Copy 1: Kernel Buffer→User Buffer
    App->>Kern: 上下文切换: Kernel→User (read返回)
    Note over App: ② write() 系统调用
    App->>Kern: 上下文切换: User→Kernel
    App->>Kern: CPU Copy 2: User Buffer→Socket Buffer
    Kern->>NIC: DMA Copy 2: Socket Buffer→NIC
    App->>Kern: 上下文切换: Kernel→User (write返回)

    Note over App,NIC: 总计: 4次上下文切换 + 4次数据拷贝(2次DMA + 2次CPU)

为什么 CPU Copy 是瓶颈:CPU 拷贝不仅仅是 memcpy 的执行时间,还包括:

  1. 缓存污染:拷贝的数据覆盖了 CPU L1/L2 缓存中的热数据(正在处理的业务数据),导致后续 cache miss 增加
  2. 总线争用:内存总线同时被 CPU 拷贝和 DMA 拷贝争抢,两者互相拖慢
  3. 上下文切换:4 次用户态/内核态切换,每次切换需要保存/恢复寄存器、刷新 TLB(Translation Lookaside Buffer,页表缓存)

2.3 🟢 sendfile 零拷贝(2 拷贝 + 2 切换)

Linux 2.1 引入了 sendfile() 系统调用,将 read() + write() 两步合并为一步:

sequenceDiagram
    participant App as 应用进程(User)
    participant Kern as 内核态(Kernel)
    participant Disk as 磁盘控制器(DMA)
    participant NIC as 网卡控制器(DMA)

    Note over App: sendfile() 系统调用
    App->>Kern: 上下文切换: User→Kernel
    Disk->>Kern: DMA Copy 1: 磁盘→Kernel Read Buffer
    Kern->>Kern: CPU Copy: Kernel Buffer→Socket Buffer
    Kern->>NIC: DMA Copy 2: Socket Buffer→NIC
    App->>Kern: 上下文切换: Kernel→User (sendfile返回)

    Note over App,NIC: 总计: 2次上下文切换 + 3次数据拷贝(2次DMA + 1次CPU)

从 4+4 降到 2+3,但仍然有 1 次 CPU 拷贝。Linux 2.4 引入 DMA Scatter/Gather(DMA 聚集/分散)进一步优化:

sequenceDiagram
    participant App as 应用进程(User)
    participant Kern as 内核态(Kernel)
    participant Disk as 磁盘控制器(DMA)
    participant NIC as 网卡 SG-DMA

    Note over App: sendfile() + DMA Gather
    App->>Kern: 上下文切换: User→Kernel
    Disk->>Kern: DMA Copy 1: 磁盘→Kernel Read Buffer
    Kern->>Kern: Socket Buffer只存描述符(指针+长度)
    Kern->>NIC: DMA Gather: NIC从Page Cache直接读数据+描述符
    App->>Kern: 上下文切换: Kernel→User (sendfile返回)

    Note over App,NIC: 总计: 2次上下文切换 + 2次数据拷贝(1次DMA + 1次DMA Gather)
    Note over App,NIC: CPU完全不解剖数据,只是传递描述符

DMA Gather 的关键:Socket Buffer 中不再存数据本身,而是存一个 描述符{内存页地址, 偏移, 长度} 三元组)。NIC 的 DMA 引擎读取描述符后,直接从 Page Cache(页缓存,内核中用于缓存磁盘数据的页面)的对应位置抓取数据并组装成网络包发出。

三种模式的完整对比:

模式CPU 拷贝DMA 拷贝上下文切换用户缓冲区参与?
传统 read + write224
sendfile(无 gather)122
sendfile + DMA Gather02(含 1 次 gather)2

零拷贝的"零"指的是零次 CPU 拷贝,不是零次 DMA 拷贝。磁盘到内存的数据搬运(DMA Copy)和网卡从内存抓取数据(DMA Gather Copy)都由硬件完成,CPU 全程不触摸数据。

2.4 ☕ Java 实现:FileChannel.transferTo()

Java NIO 通过 FileChannel.transferTo() 封装了操作系统的零拷贝能力。在 Linux 2.4+ 上,底层会调用 sendfile64() 系统调用:

import java.io.FileInputStream;
import java.io.IOException;
import java.net.Socket;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;

/**
 * 使用 FileChannel.transferTo() 实现零拷贝文件传输。
 *
 * 测试方法:
 *   Linux: strace -f -e trace=sendfile java ZeroCopyFileTransfer
 *   看到 sendfile64(...) = xxx 即证明使用了零拷贝
 */
public class ZeroCopyFileTransfer {
    public static void sendFile(Socket socket, String filepath) throws IOException {
        SocketChannel socketChannel = socket.getChannel();
        try (FileInputStream fis = new FileInputStream(filepath);
             FileChannel fileChannel = fis.getChannel()) {

            long position = 0;
            long size = fileChannel.size();

            /*
             * transferTo(position, count, target):
             *   position: 从文件的哪个位置开始传输
             *   count:    传输多少字节
             *   target:   目标 Channel(这里是 SocketChannel)
             *
             * 在 Linux 2.4+ 上,底层调用 sendfile64(fd, socket_fd, offset, count)
             * 在 Windows 上,底层调用 TransmitFile()
             * 在 macOS 上,底层调用 sendfile()
             */
            long bytesTransferred;
            while (position < size) {
                bytesTransferred = fileChannel.transferTo(
                    position,
                    size - position,
                    socketChannel
                );
                if (bytesTransferred <= 0) break;
                position += bytesTransferred;
            }
        }
    }
}

strace 验证底层确实调用了 sendfile

# 启动 Java 程序后,找到进程 PID 并 trace
$ strace -e trace=sendfile -p <PID>

# 当有文件传输时,输出类似:
sendfile64(12, 10, NULL, 16777216) = 4194304
sendfile64(12, 10, NULL, 12582912) = 4194304
# fd=12 是文件描述符, fd=10 是 socket, 每次传输约4MB

transferTo 的限制

限制说明解决办法
单次传输上限sendfile 单次最多传输 Integer.MAX_VALUE 字节(约 2GB)循环调用直到全部传完
无法修改数据数据直接从 Page Cache 到网卡,应用层无法添加 header/修改内容FileRegion(Netty)在数据前添加 header
仅限文件到 SockettransferTo 的源必须是文件,目标是 Socket 或文件其他场景使用 DirectBuffer 减少拷贝

2.5 🌐 Netty 中的零拷贝实现

Netty 在多个层面使用了零拷贝思想:

Netty 特性对应技术原理
FileRegiontransferTo()将文件内容直接发送到网络,底层调用 sendfile,不经过用户空间
CompositeByteBuf虚拟 buffer 合并将多个 ByteBuf 合并为一个逻辑 ByteBuf,不实际拷贝数据
Unpooled.wrappedBuffer()共享底层数组多个 ByteBuf 共享同一块内存,零拷贝"拆分"
ByteBuf.slice()共享底层数组切片操作不创建新的内存副本
// Netty FileRegion 示例:零拷贝文件传输
import io.netty.channel.*;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.stream.ChunkedFile;

// 在 Handler 中使用 ChunkedFile(内部使用 FileRegion + transferTo)
ctx.write(new ChunkedFile(new java.io.RandomAccessFile("large_file.bin", "r")));
// ChunkedFile 内部会将文件分块,每块通过 FileRegion 零拷贝发送

3 🧠 直接内存

3.1 ❓ 为什么需要直接内存——数据的"过墙"问题

Java 的堆内存(Heap Memory)由 JVM 管理,但操作系统进行 I/O 操作时,数据必须位于 堆外内存 (Off-Heap Memory,JVM 堆之外的内存区域)——因为 GC 可能移动对象,导致内存地址变化,而 I/O 操作需要物理地址稳定。

传统 I/O 使用堆内 ByteBuffer 时,JVM 会在 I/O 操作前临时分配一块堆外内存做"中转":

flowchart TD
    %% ==========================================
    %% 样式定义
    %% ==========================================
classDef heap fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca,font-weight:bold;
classDef direct fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold;
classDef io fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold;
classDef label fill:#1e1e24,stroke:#6b7280,stroke-width:1px,color:#e5e7eb;

    %% ==========================================
    %% 堆内 vs 直接内存 I/O 路径
    %% ==========================================
    subgraph HEAP_PATH ["堆内 ByteBuffer 路径(多一次拷贝)"]
        direction LR
        H1["App writes to\nHeapByteBuffer"] --> H2["JVM allocates\nTemporary DirectBuffer"]
        H2 --> H3["memcpy: Heap→Direct"]
        H3 --> H4["Kernel I/O reads\nfrom Direct Buffer"]
    end

    subgraph DIRECT_PATH ["直接 ByteBuffer 路径(零次额外拷贝)"]
        direction LR
        D1["App writes to\nDirectByteBuffer"] --> D2["Kernel I/O reads\nfrom Direct Buffer"]
    end

    class H1,H2,H3,H4 heap;
    class D1,D2 direct;
对比维度ByteBuffer.allocate(1024)ByteBuffer.allocateDirect(1024)
内存位置JVM 堆内堆外(Native Memory)
I/O 路径堆内 → 临时堆外 → 内核(多一次拷贝)堆外 → 内核(直接)
GC 影响受 GC 管理,可能被移动不受 GC 管理,地址稳定
分配速度(JVM 堆内分配,走 TLAB)(系统调用 malloc
释放机制GC 自动回收Cleaner 虚引用回收,时机不确定
读写效率需要 JNI 边界检查底层可直接操作内存地址

使用原则:直接内存适合 长期使用、频繁 I/O 的大缓冲区(如 Netty 的读写缓冲区),因为分配虽然慢但避免了每次 I/O 的临时拷贝;堆内内存适合 短期使用的小缓冲区

3.2 ⚙️ 直接内存的配置

# JVM 启动参数
-XX:MaxDirectMemorySize=512m    # 限制直接内存的最大值,默认等于 -Xmx
-XX:+DisableExplicitGC          # 禁止 System.gc() 触发 Full GC
                                # (会让 Netty 的 Cleaner 回收变慢)

直接内存的默认上限等于 -Xmx。如果超过这个限制,抛出 OutOfMemoryError: Direct buffer memory。可以用 JMX 监控直接内存使用量:

// 监控直接内存使用情况
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;

List<BufferPoolMXBean> pools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
for (BufferPoolMXBean pool : pools) {
    System.out.println(pool.getName()
        + " count=" + pool.getCount()        // 当前分配的 Buffer 数量
        + " used=" + pool.getMemoryUsed()    // 已使用的字节数
        + " capacity=" + pool.getTotalCapacity()); // 总分配容量
}

3.3 📦 Netty 中的直接内存管理

Netty 默认使用 PooledByteBufAllocator,内部维护了直接内存的池化分配器,避免频繁的 malloc / free

// Netty 内存分配器选择
// 方式一:使用池化直接内存(默认,推荐)
Bootstrap b = new Bootstrap();
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);

// 方式二:使用非池化堆内存(调试时用)
b.option(ChannelOption.ALLOCATOR, UnpooledByteBufAllocator.DEFAULT);

// 方式三:查看 Netty 内存泄漏检测(开发/测试环境)
// -Dio.netty.leakDetection.level=PARANOID
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);

PooledByteBufAllocator 的内存分配层次:

层次说明
Arena与线程绑定,减少锁竞争。数量 = CPU 核数 × 2
ChunkList管理 Chunk 的列表,按使用率分级(qInit/q000/q025/q050/q075/q100)
Chunk16MB 的连续内存块(默认),是向操作系统申请的最小单位
Page8KB(默认),Chunk 内的分配单位
SubPage小于 Page 的分配单位,通过位图管理

4 🔧 常见问题排查

4.1 🔴 句柄泄露(Too many open files)

现象:服务运行几天后,突然所有连接被拒绝,日志中出现 java.io.IOException: Too many open files

原因:每个 Socket 连接、每个打开的文件都占用一个文件描述符(File Descriptor,操作系统分配给进程的整数句柄)。如果连接关闭了但没有释放 fd,就会逐渐耗尽进程的 fd 配额。

排查

# 1. 查看进程打开了多少文件描述符
lsof -p <PID> | wc -l

# 2. 按类型统计 fd
lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn

# 3. 查看系统限制
ulimit -n    # 软限制(默认 1024)
ulimit -n 65535  # 临时增大

# 4. 查看哪个文件被打开最多次(从中推断泄漏源)
lsof -p <PID> | awk '{print $9}' | sort | uniq -c | sort -rn | head -20

修复模式

// 原始代码(句柄泄露)
ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket client = server.accept();
    new Thread(() -> {
        InputStream in = client.getInputStream();
        // ... 处理中如果抛异常,client 和 in 永远不会关闭
    }).start();
}

// 修复后(确保关闭)
ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket client = server.accept();
    new Thread(() -> {
        try (InputStream in = client.getInputStream();
             client) {  // try-with-resources 保证关闭
            // ... 处理
        } catch (IOException e) {
            // 日志记录
        }
    }).start();
}

Netty 的保护机制:Netty 的 SimpleChannelInboundHandler 自动释放消息(channelRead0 返回后自动调用 ReferenceCountUtil.release())。但如果继承 ChannelInboundHandlerAdapter,必须手动释放:

// SimpleChannelInboundHandler: 自动释放(推荐)
class SafeHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
        // msg 在方法结束后自动被 release(),无需手动处理
        ctx.writeAndFlush(msg.retain());
    }
}

// ChannelInboundHandlerAdapter: 必须手动释放
class UnsafeHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        try {
            ByteBuf buf = (ByteBuf) msg;
            ctx.writeAndFlush(buf.retain());
        } finally {
            ReferenceCountUtil.release(msg);  // ← 必须手动释放!
        }
    }
}

4.2 🌀 NIO 空轮询(Selector Spinning Bug)

现象:线上服务 CPU 使用率突然飙到 100%,jstack 显示主线程一直在执行 selector.select(),但没有任何实际 I/O 处理。

原因:JDK NIO 在某些 Linux 内核版本上存在 Bug——epoll_wait 正常返回 0(超时,无就绪 fd),但 Java 的 Selector.select() 在内部计数错误下认为有事件发生,直接返回并进入下一次循环,形成 无限空转

排查

# 1. 查看 CPU 使用
top -H -p <PID>    # 找到 CPU 100% 的线程

# 2. jstack 看线程栈
jstack <PID> | grep -A 20 "CPU-consuming-thread-name"

# 典型堆栈:
"nioEventLoopGroup-2-1" #13 prio=10 ...
  at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
  at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:93)
  at io.netty.channel.nio.NioEventLoop.select(NioEventLoop.java:813)
  at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:460)
  # 反复出现在 select() → processSelectedKeys() → select() 循环中
  # 但 processSelectedKeys() 没有实际处理任何事件

Netty 的修复:Netty 在 NioEventLoop 中内建了空轮询检测与自动恢复机制:

// Netty 空轮询检测简化版 (NioEventLoop.java)
long currentTimeNanos = System.nanoTime();
for (;;) {
    long timeoutMillis = ...;
    int selectedKeys = selector.select(timeoutMillis);
    selectCnt++;

    if (selectedKeys != 0) {
        break; // 正常:有 Channel 就绪
    }

    long time = System.nanoTime();
    if (time - currentTimeNanos >= timeoutMillis) {
        selectCnt = 1;  // 正常超时,重置计数
    } else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
               selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
        // 空轮询次数累计达到阈值(默认512),触发重建 Selector
        rebuildSelector();  // ① 创建新 Selector ② 迁移所有 Channel ③ 关闭旧 Selector
        selectCnt = 1;
        break;
    }
}

产生条件:该 Bug 在 JDK 6u4 到 JDK 8 的特定内核版本上都会出现,尤其是在 epoll_wait 超时时间非常短(接近 0)时触发概率更高。升级 JDK 11+ 可以缓解,但 Netty 的防御机制更加可靠。

4.3 💥 直接内存溢出(Direct Buffer OOM)

现象:JVM 进程 -Xmx 只配了 2GB,堆内存才用了 500MB,却突然 OOM 进程崩溃。日志中出现:

java.lang.OutOfMemoryError: Direct buffer memory

或者(在 Netty 中更常见):

io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s)
of direct memory (used: 1073741824, max: 1073741824)

原因:直接内存(Direct Memory)不受 -Xmx 限制,默认上限等于 -Xmx。Netty 的读写缓冲区默认使用直接内存,高并发下大量 ByteBuf 未正确释放,导致直接内存耗尽。

排查

# 1. 开启 Native Memory Tracking(会有 5%~10% 的性能开销,仅排查时使用)
java -XX:NativeMemoryTracking=detail -jar myapp.jar

# 2. 查看 Native Memory 使用详情
jcmd <PID> VM.native_memory summary

# 输出中关注 "Internal" 区域(包含 Direct Buffer):
# Internal (reserved=1248MB, committed=1248MB)
#                   (malloc=1248MB #18382)
# 如果 Internal 远大于预期,说明 Direct Buffer 泄漏

# 3. Netty 自带泄漏检测(开发/测试环境)
-Dio.netty.leakDetection.level=PARANOID
# 日志会输出泄漏的 ByteBuf 的创建堆栈,精确定位泄漏位置

Netty 泄漏检测输出示例

LEAK: ByteBuf.release() was not called before it's garbage-collected.
Recent access records:
#1:  io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(...)
#2:  io.netty.handler.codec.ByteToMessageDecoder.channelRead(...)
#3:  com.example.MyHandler.channelRead(...MyHandler.java:42)
     ^--- 这里分配了 ByteBuf 但忘记 release

预防措施

措施说明
配置 -XX:MaxDirectMemorySize显式限制直接内存上限,避免无上限增长
使用 SimpleChannelInboundHandler自动释放消息,避免手动管理引用计数
开启泄漏检测测试环境 PARANOID 级别,生产环境 SIMPLE 级别
配置池化分配器PooledByteBufAllocator 复用 ByteBuf,减少 allocate/free 频率
监控 BufferPoolMXBean定期打印直接内存使用量,建立告警

5 🎯 总结

flowchart TD
    %% ==========================================
    %% 样式定义
    %% ==========================================
classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold;
classDef branch fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#fde68a,font-weight:bold;
classDef leaf fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef highlight fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;

    %% ==========================================
    %% NIO 性能调优知识体系
    %% ==========================================
    ROOT["NIO 性能调优三大支柱"]

    ROOT --> ZERO["零拷贝"]
    ROOT --> DIRECT["直接内存"]
    ROOT --> TROUBLESHOOT["问题排查"]

    ZERO --> Z1["传统 I/O: 4次拷贝+4次切换"]
    ZERO --> Z2["sendfile: 2次拷贝+2次切换"]
    ZERO --> Z3["sendfile+DMA Gather: 0次CPU拷贝"]
    ZERO --> Z4["Java实现: transferTo()"]
    ZERO --> Z5["Netty: FileRegion"]

    DIRECT --> D1["HeapBuffer: I/O需要中转拷贝"]
    DIRECT --> D2["DirectBuffer: 直接I/O"]
    DIRECT --> D3["-XX:MaxDirectMemorySize"]
    DIRECT --> D4["Netty PooledByteBufAllocator"]

    TROUBLESHOOT --> T1["句柄泄露: lsof排查"]
    TROUBLESHOOT --> T2["空轮询: jstack+rebuildSelector"]
    TROUBLESHOOT --> T3["直接内存溢出: NMT+泄漏检测"]

    class ROOT root;
    class ZERO,DIRECT,TROUBLESHOOT branch;
    class Z1,Z2,Z3,Z4,Z5,D1,D2,D3,D4,T1,T2,T3 leaf;
    class Z3 highlight;
层级核心要点一句话
问题驱动文件传输 CPU 100%,网络带宽用不满CPU 忙于搬运数据,而非处理业务
零拷贝sendfile + DMA Gather,CPU 不参与数据搬运2 次上下文中换 + 2 次 DMA 拷贝,0 次 CPU 拷贝
transferTo()Java 封装 sendfile64 系统调用strace -e sendfile 可验证
直接内存allocateDirect() 分配堆外内存,避免中转分配慢但 I/O 快,适合长期重用的 I/O 缓冲区
句柄泄露fd 未关闭,lsof 排查用 try-with-resources 或 Netty 的 SimpleChannelInboundHandler
NIO 空轮询JDK Selector Bug,CPU 100%Netty rebuildSelector 检测并自动恢复
直接内存溢出-XX:MaxDirectMemorySize + NMT + 泄漏检测Netty RESOURCE_LEAK_DETECTOR 追踪到代码行