Java 中常见的 I/O 模型
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间。
1 BIO (Blocking I/O)
BIO 属于同步阻塞 IO 模型 。
BIO是一种同步阻塞I/O操作方式,即当线程执行输入或输出操作时,线程被阻塞,一直等待输入或输出完成并返回结果。在这种模型中,一个请求对应于一个线程,因此支持的并发量比较有限。
在Java中,有几种阻塞式I/O的实现方式,也称为BIO。以下是其中几种常见的BIO:
java.net.ServerSocket:ServerSocket类是用于创建服务器端的Socket对象。它侦听指定的端口,等待客户端的连接请求。当有新的连接请求到达时,accept()方法会阻塞当前线程,直到有客户端连接成功。详见案例1。java.net.Socket:Java标准库中的Socket类提供了阻塞式的网络I/O操作。它允许建立客户端和服务器之间的连接,并通过InputStream和OutputStream进行数据读写。当进行数据读取或写入时,Socket会阻塞当前线程直到操作完成或发生异常。详见案例2。java.io.InputStream和java.io.OutputStream:这些是Java I/O流的基础类。它们提供了阻塞式的读取和写入操作。当读取或写入数据时,这些流会阻塞当前线程,直到数据可用或操作完成。详见案例2.
这些阻塞式I/O实现方式在处理多个并发连接时存在性能瓶颈。每个连接都需要独立的线程来处理I/O操作,当连接数增加时,线程数量也会增加,可能导致资源消耗过高。为了解决这个问题引入了 NIO 和 AIO。
案例1:
使用 ServerSocket 实现 BIO 模型。例如,在服务器端,可以使用 ServerSocket 来监听指定端口,等待客户端的连接请求。当有客户端连接时,可以使用 Socket 对象来与客户端进行通信。在客户端,可以使用 Socket 来连接服务器,然后使用该 Socket 与服务器进行通信。
以下是一个简单的示例代码,用于在服务器上创建一个 ServerSocket,并监听指定端口,等待客户端的连接:
ServerSocket serverSocket = new ServerSocket(9999);
while (true) {
Socket clientSocket = serverSocket.accept(); // 如果没有连接到来,那么本线程就会一直阻塞在accept()这里
// 处理客户端请求,并创建新的线程
Thread t1 = new Thread(new MySocketHandler(clientSocket));
t1.start();
}
案例2:
以下是一个简单的使用BIO模型的服务端程序,它采用了阻塞操作的方式来处理客户端连接并接收数据:
public class ClassicBIOServer {
public static void main(String[] args) {
try {
ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
// 【serverSocket.accept()阻塞线程】
Socket socket = serverSocket.accept();
// 为每一个连接创建一个新的线程去处理
Thread thread = new Thread(() -> {
try {
InputStream is = socket.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length = 0;
// 【阻塞读操作,线程会一直阻塞在is.read(buffer)这里,等待客户端发送数据】
while ((length=is.read(buffer))!=-1) {
baos.write(buffer, 0, length);
System.out.println(new String(buffer, 0, length));
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
});
thread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
为什么BIO在并发情况下每个连接都需要一个线程来处理?
- 进行阻塞式I/O操作时,每个I/O操作都会阻塞当前线程,直到操作完成或发生异常。这意味着如果有多个并发连接需要处理,每个连接都需要独立的线程进行阻塞式I/O操作。如果将多个BIO操作交由一个线程来处理,会导致严重的线程阻塞问题。
- 多个线程处理多个BIO操作的缺点:每个线程都需要占用一定的内存和CPU资源,线程切换的开销也会增加。极大影响性能和浪费资源。
2 NIO (Non-blocking/New I/O)
非阻塞式I/O是一种I/O操作模型,无论操作是否完成,它都不会阻塞当前线程(立即返回),即当线程执行输入或输出操作时,如果暂时没有数据可读或可写,则线程不会阻塞,而是去执行其他任务。
-
NIO 可以使用单个线程处理多个连接,从而提高并发处理能力。
-
NIO支持面向缓冲的、基于通道的 I/O 操作方法。
在Java中,有几种实现了非阻塞式I/O的方式,也称为NIO。以下是其中几种常见的NIO:
-
java.nio.channels.Channel:Channel是 NIO 中的核心概念,它代表了一个打开的连接,可以进行读取和写入操作。与阻塞式I/O不同,Channel是非阻塞的,它不会阻塞线程,而是立即返回。 -
java.nio.channels.Selector:Selector是 NIO 中多路复用的关键组件,可以监视多个Channel状态的对象。它可以检测Channel是否已经准备好进行读取或写入操作。通过Selector,可以使用单个线程处理多个Channel的I/O操作。 -
java.nio.ByteBuffer:ByteBuffer是 NIO 中用于数据读取和写入的缓冲区,使用ByteBuffer可以高效地进行数据传输。1、核心组件:
Selector、Channel和Buffer被称为 NIO 的三大核心。在NIO中,数据的读写通常是通过将数据从Channel读取到Buffer,或从Buffer写入到Channel中:
Selector提供了高效的事件驱动机制,可以在单个线程中处理多个Channel的I/O操作,避免了为每个Channel创建独立线程的开销。2、使用方式:使用
Selector时,可以注册一个或多个Channel到Selector上,并指定感兴趣的事件类型,例如读就绪、写就绪等。Selector会不断地轮询这些Channel的状态,当有Channel准备就绪时,Selector会通知相应的线程进行处理。3、总结:这三个核心组件共同构成了NIO的基础,通过
Channel进行数据的读写操作,使用Buffer作为数据的中转存储区域,通过Selector实现多路复用和事件驱动。这种非阻塞的I/O模型可以提高系统的并发处理能力和性能。 -
java.nio.channels.ServerSocketChannel:ServerSocketChannel是用于创建服务器端的Channel对象。它可以监听指定的端口,并等待客户端的连接请求。与阻塞式的ServerSocket不同,ServerSocketChannel是非阻塞的。 -
java.nio.channels.SocketChannel:SocketChannel是用于创建客户端连接的Channel对象。它可以连接到服务器端的SocketChannel,并进行读写操作。
案例:下面是一个使用 Selector、Channel 和 Buffer 的简单案例,用于实现基于 NIO 的简单聊天服务器和客户端:
服务器端(Server):
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
public class NIOServer{
public static void main(String[] args) throws IOException {
// 创建 Selector
Selector selector = Selector.open();
// 创建 ServerSocketChannel,并注册到 Selector 上
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false); // 设置为非阻塞模式
serverChannel.bind(new InetSocketAddress("localhost", 8888), 1024); //绑定IP和端口,接受连接的请求队列的最大长度为1024。
serverChannel.register(selector, SelectionKey.OP_ACCEPT); // 将serverChannel注册到selector上,并指定感兴趣的事件类型OP_ACCEPT
System.out.println("Server started...");
while (true) {
selector.select();
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// 接受客户端连接
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = server.accept(); // 接收客户端连接
clientChannel.configureBlocking(false);
clientChannel.register(selector, SelectionKey.OP_READ); // 将channel注册到selector上
System.out.println("Client connected: " + clientChannel.getRemoteAddress());
} else if (key.isReadable()) {
// 读取客户端消息
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 读取客户端数据到buffer中,并返回读取到的字节数。注意上面获取连接时设置了clientChannel为非阻塞模式,所以下面的read方法并不会阻塞读取,而如果没有读取到数据就直接返回
int bytesRead = clientChannel.read(buffer);
if (bytesRead == -1) { // 返回字节数为 -1,表示客户端断开了连接
// 客户端断开连接,取消对应的 SelectionKey 并关闭客户端的连接通道。
key.cancel();
clientChannel.close();
} else { // 如果读取的字节数不为 -1,说明读取到了客户端发送的数据
String message = new String(buffer.array()).trim();
System.out.println("Received message: " + message);
}
}
keyIterator.remove(); // 从 Selector 中移除已处理的 SelectionKey,继续处理其他就绪key
}
}
}
}
1、解释
serverChannel.register(selector, SelectionKey.OP_ACCEPT);:
register方法用于将一个Channel注册到一个Selector对象中,并指定感兴趣的事件类型。register方法的第一个参数是要注册的Selector对象,第二个参数是感兴趣的事件类型,使用SelectionKey.OP_ACCEPT表示对ACCEPT事件感兴趣,即监听新的连接请求。- 具体来说,
selector会监控serverChannel的状态,当有新的连接请求到达时,会触发OP_ACCEPT事件(serverChannel的SelectionKey的OP_ACCEPT标志位置为真),服务器端就可以接受新的连接并进行相应的处理。
2、解释上述 while 循环里面的流程:
- 调用
select()方法用于轮询与通道关联的I/O事件,以检查是否有任何事件就绪。该方法会阻塞当前线程,直到至少一个通道就绪(如果不希望阻塞可以使用selectNow()方法)。一旦有通道就绪,select()方法会返回就绪通道的数量。- 通过调用
selectedKeys()方法获取到一个Set对象,其中包含了所有就绪的SelectionKey。- 遍历
Set中的SelectionKey,可以通过isAcceptable()、isConnectable()、isReadable()和isWritable()方法判断具体是哪种就绪事件。- 根据就绪事件类型进行相应的处理。
客户端(Client):
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;
public class NIOClient {
public static void main(String[] args) throws IOException {
// 创建 SocketChannel
SocketChannel clientChannel = SocketChannel.open();
clientChannel.configureBlocking(false);
clientChannel.connect(new InetSocketAddress("localhost", 8888));
while (!clientChannel.finishConnect()) {
// 等待连接完成
}
System.out.println("Connected to server.");
// 从控制台读取输入,并发送给服务器
Scanner scanner = new Scanner(System.in);
while (true) {
String message = scanner.nextLine();
if (message.equalsIgnoreCase("exit")) {
break;
}
// 创建了一个新的 ByteBuffer 对象,用于包装字符串 message 的字节数组。
ByteBuffer buffer = ByteBuffer.wrap(message.getBytes());
// 将 ByteBuffer 中的数据写入到客户端的连接通道 clientChannel 中。
clientChannel.write(buffer);
}
clientChannel.close();
scanner.close();
}
}
测试,先启动服务端,再启动客户端:



顺便提一嘴 netty(如果只需要搞懂NIO,这部分不需要掌握):
Netty是一个基于Java的高性能、异步事件驱动的网络应用框架。它提供了简单易用的API,能够帮助开发者快速构建可扩展的网络应用程序。Netty内部封装了NIO相关的组件,提供了更高级的抽象和功能,使得网络编程更加简洁和高效。
基本使用案例如下:
-
添加依赖:在项目的构建文件(如Maven的pom.xml)中添加Netty的依赖。
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>版本号</version> </dependency> -
创建服务端:
EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new YourChannelHandler()); } }); ChannelFuture future = serverBootstrap.bind(8080).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }在上述代码中,首先创建了两个
EventLoopGroup,分别用于处理客户端的连接请求(bossGroup)和处理网络IO事件(workerGroup)。然后创建了ServerBootstrap对象,并设置相关的参数,包括所使用的通道类型(NioServerSocketChannel)和自定义的ChannelInitializer,用于初始化ChannelPipeline,添加自定义的ChannelHandler。 -
创建客户端:
EventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap clientBootstrap = new Bootstrap(); clientBootstrap.group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new YourChannelHandler()); } }); ChannelFuture future = clientBootstrap.connect("localhost", 8080).sync(); future.channel().closeFuture().sync(); } finally { group.shutdownGracefully(); }类似于服务端,客户端也需要创建一个
EventLoopGroup,并设置相关参数。然后创建Bootstrap对象,并设置通道类型(NioSocketChannel)和自定义的ChannelInitializer。使用connect()方法连接到服务器,并通过sync()方法进行同步等待,直到连接完成。 -
自定义
ChannelHandler:在上述代码中的
ChannelInitializer中,可以添加自定义的ChannelHandler来处理网络IO事件。可以根据具体需求来实现自己的ChannelHandler,处理数据的读写、协议解析、业务逻辑等。
这只是Netty的基本使用示例,实际使用中可能还涉及到更多的配置和功能。Netty提供了丰富的API和组件,可以进行更高级的网络编程,如编解码、心跳检测、SSL加密等。可以参考Netty的官方文档和示例代码来深入学习和了解Netty的更多用法和特性。
9.3 AIO(Asynchronous I/O)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
NIO和AIO最大的不同在于编程模型和底层实现方式。
- 编程模型:
- NIO使用事件驱动模型,应用程序需要显式地检查通道的事件状态并进行相应的操作(显示检查key)。
- AIO使用回调模型,应用程序通过回调方法处理I/O操作完成事件。它在发起异步操作后立即返回,并在操作完成时通知应用程序调用相应的回调方法。
- 底层实现:
- NIO使用了选择器和缓冲区等API,通过操作系统的非阻塞I/O机制实现。它利用操作系统提供的I/O复用机制来监听多个通道的事件。
- AIO使用了异步操作和CompletionHandler接口等API,通过操作系统的异步I/O机制实现。它利用操作系统提供的异步I/O机制来实现非阻塞的I/O操作。
总的来说,NIO和AIO都是Java中的异步I/O模型,能够高效地处理多个并发的I/O操作。它们的最大不同在于编程模型和底层实现方式。NIO使用事件驱动模型,需要显式地检查事件状态;AIO使用回调模型,在操作完成后通知应用程序进行处理。此外,NIO利用操作系统的非阻塞I/O机制,而AIO利用操作系统的异步I/O机制。选择使用NIO还是AIO应该根据具体的需求和应用场景来决定。
Java中的I/O模型:BIO,NIO和AIO详解
文章详细介绍了Java中的三种I/O模型:BIO(阻塞I/O)、NIO(非阻塞I/O)和AIO(异步I/O)。BIO在处理并发连接时需要为每个连接创建单独的线程,导致资源消耗高。NIO引入了非阻塞I/O和Selector,通过单线程处理多个连接,提高了并发性能。AIO进一步使用异步回调机制,提供更高效的I/O操作。文章通过案例展示了BIO和NIO的使用,并对比了它们的区别和应用场景。
3007

被折叠的 条评论
为什么被折叠?



