CodeGym /课程 /JAVA 25 SELF /NIO 通道与 ByteBuffer

NIO 通道与 ByteBuffer

JAVA 25 SELF
第 41 级 , 课程 1
可用

1. NIO 通道简介

在传统的 Java IO(java.io)中,基本遵循“一个线程 — 一个文件或资源”的模式。一旦开始读写,线程就会被阻塞并等待操作完成。对于简单场景这很方便,但在高负载系统中会成为瓶颈:当连接有成千上万时,同样数量的线程会被动地等待。

在 NIO(New IO)中方式不同。这里的输入/输出可以是非阻塞的,线程无需空等。当某些数据仍在传输时,它可以切换去处理其他任务。因此,可以用寥寥几个线程同时服务海量连接。

差异在细节上同样明显。传统 IO 围绕流构建:它们读取和写入字节或字符,但在操作期间总会阻塞。NIO 的核心概念是通道(Channels)和缓冲区(Buffers)。它们不仅支持非阻塞 I/O(对服务器尤为重要),还可以采用 zero-copy,使数据直接传输,绕过在 JVM 缓冲区中的多余拷贝。

对比:流(Streams)与通道(Channels)

流(InputStream/OutputStream):

  • 按字节或字节数组读/写。
  • 无法直接控制文件中的位置。
  • 处理超大文件的效率不高。

通道(Channel):

  • 通过缓冲区(Buffer)读/写数据。
  • 可以管理位置(支持随机访问)。
  • 支持异步与非阻塞模式。
  • 可利用 zero-copy 实现极快的复制。

2. FileChannel 与 SeekableByteChannel

使用缓冲区进行读写

FileChannel 是处理文件的主要通道。可以从 FileInputStreamFileOutputStream 获取,或通过 NIO.2 的 Files.newByteChannel(返回 SeekableByteChannel)获取。

示例:通过 FileChannel 和 ByteBuffer 读取文件

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelReadExample {
    public static void main(String[] args) throws Exception {
        try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
             FileChannel channel = file.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(1024); // 1 KB 缓冲区

            int bytesRead = channel.read(buffer); // 读取到缓冲区
            while (bytesRead != -1) {
                buffer.flip(); // 切换缓冲区到读取模式
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get());
                }
                buffer.clear(); // 清空缓冲区以便下一次读取
                bytesRead = channel.read(buffer);
            }
        }
    }
}

写入文件:

try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
     FileChannel channel = file.getChannel()) {

    ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
    channel.write(buffer);
}

NIO.2:通过 Files.newByteChannel 打开通道

import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;

Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
    ByteBuffer buf = ByteBuffer.allocate(256);
    ch.read(buf);
}

定位(position())与修改大小(truncate()

  • position() —— 用于获取或设置文件中的当前位置(类似“光标”)。
  • truncate(long size) —— 将文件截断为指定大小。
channel.position(100);   // 移动到第 100 个字节
channel.truncate(1024);  // 将文件截断为 1 KB

直接访问与位置式访问文件

  • 直接访问:可在文件任意位置读/写,而不仅是顺序方式。
  • 位置式访问:可在指定位置读/写数据,而不改变通道的当前位置。
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128);   // 从位置 128 读取 4 个字节,不改变 channel.position()

3. ByteBuffer:工作原理

核心参数:capacitylimitpositionmark

  • capacity —— 缓冲区的最大容量(创建时指定)。
  • limit —— 可读/写的上界(默认等于 capacity)。
  • position —— 当前指针(写入位置/读取起点)。
  • mark —— 书签,可设置后在需要时恢复到该位置。

缓冲区的典型生命周期:

  1. 向缓冲区写入数据(例如通过通道 read() 读取)。
  2. flip() —— 将缓冲区切换为读取模式(position = 0limit = 当前 position)。
  3. 从缓冲区读取数据(get())。
  4. clear() —— 清空缓冲区以便下一次写入(position = 0limit = capacity)。

示例:

ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip();                // 现在可以读取了
byte value = buffer.get();    // 42
buffer.clear();               // 准备好进行新的写入

创建缓冲区:allocate()allocateDirect()

ByteBuffer 有两种主要的创建方式,它们在实际使用中差异明显。方法 allocate() 在 JVM 堆上分配缓冲区:创建速度快,适合大多数场景,但在本机 I/O 中可能存在在堆与操作系统内存之间的额外拷贝。

方法 allocateDirect() 会在 JVM 堆外(“native memory”)分配内存。此类缓冲区创建成本更高、管理更复杂,但在读取/写入大文件或进行网络操作时,往往因为避免多余拷贝而更快。

思路很简单:如果你关注大规模数据的吞吐——请使用“直接”缓冲区。对于体量小且频繁的操作,其创建开销可能会抵消收益。

ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);

4. 高性能操作:transferTo() 与 transferFrom()

方法 transferTo()transferFrom()

FileChannel 提供了两个实现“零拷贝”的方法——transferTo()transferFrom()。其核心思想是让数据在文件通道之间,或例如在文件与网络之间直接传输。JVM 参与很少:操作由操作系统完成,Java 侧的缓冲区基本不会被触碰。

因此,复制大文件会明显更快:更少的数据拷贝、更少的用户态/内核态切换、CPU 负载更低。

示例:通过 zero-copy 复制文件

import java.nio.channels.FileChannel;
import java.nio.file.*;

public class ZeroCopyExample {
    public static void main(String[] args) throws Exception {
        try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
             FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

            long size = src.size();
            long transferred = src.transferTo(0, size, dst);
            System.out.println("复制的字节数:" + transferred);
        }
    }
}

零拷贝在哪些情况下真正生效?

  • 在同一磁盘上的文件之间复制。
  • 通过网络发送文件(例如经由 SocketChannel)。
  • 当操作系统支持 zero-copy(Linux、macOS、Windows 均支持)。

优势:

  • 拷贝最少:数据不经 JVM 缓冲区。
  • 速度更高:切换更少、CPU 负载更低。
  • 更省内存:无需大型用户态缓冲区。

示例:“一行”完成文件复制

Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// 如果可能,内部可能使用 zero-copy

5. 常见错误

错误 1:在从缓冲区读取之前忘记调用 flip() 向缓冲区写入数据后必须调用 flip(),否则读取不会按预期工作:position/limit 仍停留在“写入模式”。

错误 2:在小型操作中使用 allocateDirect() Direct 缓冲区适合大体量操作,但对小请求而言其创建成本往往得不偿失。默认优先选择 allocate()

错误 3:未关闭通道。 始终为通道和流使用 try-with-resources,以避免句柄泄漏。

错误 4:混淆 position/limit/capacity 读/写之前先确认缓冲区所处的模式:写入后需要 flip(),读取完要进行新的写入则使用 clear()compact()

错误 5:以为 zero-copy “总是可用”。 在某些配置(不同设备/不同文件系统/特殊标志)下,zero-copy 可能不可用——此时会回退为普通复制,性能也会不同。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION