Java NIO:非阻塞IO
面向对象程序设计(Java)
理解Buffer、Channel、Selector,掌握构建高性能IO模型的能力传统IO vs NIO
一个请求处理一个线程,当成千上万的连接涌入时,服务器线程数爆炸,CPU在线程切换上疲于奔命——传统BIO(Blocking IO)的瓶颈。
传统IO:面向流、阻塞
- 面向流 (Stream Oriented): 数据像水流一样,单向、连续、顺序读取。你无法跳跃读取。
- 阻塞 (Blocking): 当调用 `read()` 或 `write()` 时,线程会被阻塞,直到数据准备好或写入完成。
- 简单性: 模型简单,易于理解和编程。
NIO:面向缓冲区、非阻塞
- 面向缓冲区 (Buffer Oriented): 数据被读入缓冲区,你可以灵活地在缓冲区中移动和操作数据。
- 非阻塞 (Non-blocking): `read()` 或 `write()` 调用立即返回,不管数据是否准备好。你的程序可以继续做其他事。
- 选择器 (Selectors): 允许单个线程监控多个通道(Channel)的IO事件(如连接、读、写)。
NIO用更少的线程处理更多的连接,是构建高性能网络应用(如Netty, Tomcat)的基石。
核心组件:Buffer(缓冲区)
Buffer是一块内存区域,NIO的读写都通过它进行。理解Buffer的关键在于它的四个核心属性:
- capacity: 容量,Buffer的最大数据量,创建后不可变。
- position: 位置,下一个要被读或写的数据的索引。
- limit: 界限,缓冲区中有效数据的末尾位置。
- mark: 标记,一个备忘位置,可以通过`reset()`恢复到这个位置。
核心操作: `flip()` (切换读写模式), `rewind()` (重新读取), `clear()` (清空), `compact()` (压缩)。
// Buffer 使用示例
ByteBuffer buf = ByteBuffer.allocate(10);
buf.put((byte)'H').put((byte)'e').put((byte)'l').put((byte)'l').put((byte)'o'); // 写模式
// buf state: pos=5, lim=10, cap=10
buf.flip(); // 切换到读模式
// buf state: pos=0, lim=5, cap=10
byte[] dst = new byte[buf.limit()];
buf.get(dst); // 读数据
// buf state: pos=5, lim=5, cap=10
buf.clear(); // 清空缓冲区,准备下次写入
// buf state: pos=0, lim=10, cap=10
核心组件:Channel(通道)
Channel是数据源和目标之间的连接,负责数据的传输。它类似于传统IO的Stream,但有关键区别:
- Channel是双向的,可以同时进行读写,而Stream是单向的。
- Channel必须与Buffer交互。数据总是先读到Buffer,或从Buffer写入。
主要实现:
用于文件IO。可以从文件中的任意位置读写。
// 从文件读取
FileInputStream fis = new FileInputStream("data.txt");
FileChannel fc = fis.getChannel();
ByteBuffer buf = ByteBuffer.allocate(1024);
fc.read(buf);
用于TCP网络IO的客户端。可以配置为非阻塞模式。
// 连接服务器
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("host", 80));
sc.configureBlocking(false);
用于TCP网络IO的服务器端。监听新的客户端连接。
// 监听端口
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
FileChannel是阻塞的,但网络相关的Channel(SocketChannel, ServerSocketChannel, DatagramChannel)都可以配置为非阻塞模式。
核心组件:Selector(选择器)
如何用一个线程管理成百上千个网络连接,而不错过任何一个连接的IO请求?轮询所有连接会浪费大量CPU——Selector就是答案。
Selector允许单个线程监视多个Channel的IO事件。这种模型称为“IO多路复用”。
工作流程:
- 创建一个Selector。
- 将Channel注册到Selector上,并指定你感兴趣的事件(`SelectionKey.OP_READ`, `OP_WRITE`, `OP_CONNECT`, `OP_ACCEPT`)。
- 调用`selector.select()`方法。这个方法会阻塞,直到至少有一个你感兴趣的事件发生。
- 获取所有发生事件的`SelectionKey`集合。
- 遍历`SelectionKey`,根据事件类型进行相应的处理。
Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while(true) {
int readyChannels = selector.select();
if(readyChannels == 0) continue;
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey k = keyIterator.next();
if(k.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (k.isConnectable()) {
// a connection was established with a remote server.
} else if (k.isReadable()) {
// a channel is ready for reading
} else if (k.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove(); // 必须移除,否则会重复处理
}
}
实践项目:NIO Echo服务器
让我们用NIO的组件构建一个简单的非阻塞回显服务器。它能接收客户端消息,并在消息前加上"Echo: "前缀后返回。
public class NioEchoServer {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress("localhost", 8080));
serverSocket.configureBlocking(false);
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(256);
System.out.println("Server started on port 8080");
while (true) {
selector.select(); // Block until events happen
Set selectedKeys = selector.selectedKeys();
Iterator iter = selectedKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
if (key.isAcceptable()) {
SocketChannel client = serverSocket.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
System.out.println("Accepted new connection from " + client.getRemoteAddress());
}
if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
buffer.clear();
int bytesRead = client.read(buffer);
if (bytesRead == -1) {
// Connection closed
key.cancel();
client.close();
System.out.println("Connection closed by " + client.getRemoteAddress());
} else {
buffer.flip();
String received = new String(buffer.array(), 0, bytesRead).trim();
System.out.println("Received: " + received);
// Echo back
ByteBuffer outBuffer = ByteBuffer.wrap(("Echo: " + received + "\n").getBytes());
client.write(outBuffer);
}
}
iter.remove();
}
}
}
}
这个服务器只用一个线程,就能处理所有客户端的连接和读写请求。
NIO.2 (Java 7+):更现代的IO
Java 7对NIO进行了重大升级,引入了NIO.2,主要特性是新的文件系统API。
Path 接口
一个与操作系统无关的路径表示。可以看作是`java.io.File`的升级版。
Path p = Paths.get("C:\\temp\\data.txt");
System.out.println(p.getFileName()); // data.txt
Files 工具类
提供了大量静态方法,用于常见的文件操作,极大简化了代码。
- `Files.exists(path)`
- `Files.createFile(path)`
- `Files.copy(from, to)`
- `Files.move(from, to)`
- `Files.delete(path)`
- `Files.readAllBytes(path)`
- `Files.write(path, bytes)`
异步IO
NIO.2还引入了真正的异步IO操作,通过`Future`或`CompletionHandler`来处理结果。
Path path = Paths.get("file.txt");
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
Future operation = fileChannel.read(buffer, 0);
while(!operation.isDone()); // 等待完成
buffer.flip();
// ... process data ...
buffer.clear();
日常文件操作,优先使用`Path`和`Files`类,代码更简洁、功能更强大。
NIO.2:目录遍历与过滤(Files.walk / Files.find)
Files.walk() 返回一个 Stream<Path>,可以像写 Stream 一样做过滤、映射、收集。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class WalkDemo {
public static void main(String[] args) throws IOException {
Path root = Paths.get("src");
try (var stream = Files.walk(root)) {
stream.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.limit(10)
.forEach(System.out::println);
}
}
}
要点:这是懒加载的遍历,记得用 try-with-resources 关闭 Stream(底层会占用目录句柄)。
NIO.2:递归扫描(Files.walkFileTree + FileVisitor)
当你需要更强控制力(跳过目录、遇错继续、统计信息、收集结果)时,用 walkFileTree。
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
public class WalkTreeDemo {
public static void main(String[] args) throws IOException {
Path root = Path.of(".");
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.toString().endsWith(".log")) {
System.out.println("log: " + file);
}
return FileVisitResult.CONTINUE;
}
});
}
}
经验法则:“只遍历并过滤”选 walk,“需要流程控制/统计/容错”选 walkFileTree。
NIO.2:目录监听(WatchService)
当你要做“热加载/自动编译/文件同步”时,WatchService 可以监听目录变化事件。
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
public class WatchDemo {
public static void main(String[] args) throws IOException, InterruptedException {
Path dir = Path.of("tmp");
WatchService ws = FileSystems.getDefault().newWatchService();
dir.register(ws,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
while (true) {
WatchKey key = ws.take();
key.pollEvents().forEach(e -> System.out.println(e.kind() + " -> " + e.context()));
if (!key.reset()) break;
}
}
}
注意:context() 只给“相对路径”,要和被监听目录拼起来才是完整路径。
NIO.2:匹配与文件系统(PathMatcher / FileSystem)
NIO.2 把“文件系统”抽象成 FileSystem,并提供 PathMatcher 做 glob/regex 匹配。
import java.nio.file.FileSystems;
import java.nio.file.Path;
public class MatchDemo {
public static void main(String[] args) {
var matcher = FileSystems.getDefault().getPathMatcher("glob:**/*.java");
Path p = Path.of("src/main/java/App.java");
System.out.println(matcher.matches(p)); // true
}
}
把 Path 当成“值对象”来用:拼接用 resolve,不要手写字符串路径。
课堂练习与自测
练习1:文件拷贝
使用`FileChannel`和`ByteBuffer`实现一个文件拷贝功能。尝试两种方式:
- 通过`read()`和`write()`循环读写。
- 使用`transferTo()`或`transferFrom()`方法,看看哪种更简洁。
练习2:客户端实现
为上一节的NIO Echo服务器编写一个客户端。客户端应该:
- 连接到服务器。
- 从控制台读取用户输入。
- 将输入发送给服务器。
- 打印服务器返回的"Echo"消息。
自测题目:
- Buffer的`flip()`方法做了什么?为什么在读写切换时必须调用它?
- Selector是如何实现单线程管理多路连接的?`select()`方法为什么会阻塞?
- NIO和传统IO最大的区别是什么?分别适用于哪些场景?
- `Files.readAllLines()`和自己用`FileChannel`循环读取文件有什么优缺点?
总结与延伸阅读
本章重点
- 三大核心组件: Buffer(数据容器)、Channel(数据通道)、Selector(事件派发器)。
- IO模型: NIO是非阻塞、面向缓冲区的,适合高并发、连接数多的场景(如网络服务器)。传统IO是阻塞、面向流的,适合连接数少、顺序读写的场景。
- NIO.2: 提供了现代化的文件操作API(`Path`, `Files`),是`java.io.File`的替代者。
常见问题解答
Q: NIO这么好,为什么还有人用传统IO?
A: 传统IO模型简单,代码直观,对于IO负载不高的应用来说,开发效率更高。NIO编程相对复杂,需要处理更多的状态和细节。
Q: Netty和NIO是什么关系?
A: Netty是一个基于Java NIO构建的高性能网络应用框架。它极大地简化了NIO的编程复杂性,提供了更高级、更易用的API,是开发网络服务的首选。