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 是处理文件的主要通道。可以从 FileInputStream、FileOutputStream 获取,或通过 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:工作原理
核心参数:capacity、limit、position、mark
- capacity —— 缓冲区的最大容量(创建时指定)。
- limit —— 可读/写的上界(默认等于 capacity)。
- position —— 当前指针(写入位置/读取起点)。
- mark —— 书签,可设置后在需要时恢复到该位置。
缓冲区的典型生命周期:
- 向缓冲区写入数据(例如通过通道 read() 读取)。
- flip() —— 将缓冲区切换为读取模式(position = 0,limit = 当前 position)。
- 从缓冲区读取数据(get())。
- clear() —— 清空缓冲区以便下一次写入(position = 0,limit = 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 可能不可用——此时会回退为普通复制,性能也会不同。
GO TO FULL VERSION