在Java中,处理I/O(输入/输出)操作的方式经历了从BIO(Blocking I/O,阻塞式I/O)到NIO(New I/O 或 Non-blocking I/O,新I/O或非阻塞式I/O)的演变。这两种模型在设计和使用上有显著的区别,它们分别适用于不同的应用场景。本文将深入探讨这两种I/O模型的原理、区别以及各自的使用场景。
一、Java BIO(Blocking I/O)
Java BIO是一种同步阻塞的I/O模型,它是Java最早提供的I/O模型。在进行读写操作的时候,若使用BIO进行通信,则操作不再受到操作系统的控制,而是由应用程序自己控制。在BIO中,数据的读取写入必须阻塞在一个线程内等待其完成。
-
BIO的工作原理相对简单:当一个连接建立后,服务端会为每个连接创建一个新的线程进行处理,直到连接关闭。这种方式在并发连接数较少时表现良好,但当并发连接数增加时,由于每个连接都需要一个独立的线程,系统的资源消耗会急剧增加,导致性能下降。
-
BIO的另一个特点是它是面向流的,即一次只能处理一个输入或输出请求,且这些请求是单向的。这种处理方式在某些场景下可能不够灵活。
二、Java NIO(New I/O 或 Non-blocking I/O)
Java NIO是一种同步非阻塞的I/O模型,它引入了多路复用器和缓冲区的概念,使得一个线程可以处理多个连接,提高了系统的吞吐量和性能。
- NIO的核心组件包括Channel(通道)、Buffer(缓冲区)和Selector(选择器)。Channel类似于BIO中的流,但它可以进行双向操作,并且支持异步读写。Buffer是NIO中的数据容器,它用于在Channel中进行数据的读写操作。Selector则是NIO中的多路复用器,它可以同时监听多个Channel的状态,当Channel有数据可读或可写时,Selector会通知相应的线程进行处理。
JAVA中Buffer和Channel实现:
- NIO的工作原理是基于Reactor模式(反应堆模式)的。当一个连接建立后,服务端会将该连接注册到Selector上,并指定感兴趣的事件(如读事件、写事件等)。然后,服务端会启动一个或多个线程不断轮询Selector,检查是否有感兴趣的事件发生。如果有事件发生,线程会处理相应的事件,然后继续轮询。这种方式使得一个线程可以处理多个连接,降低了系统的资源消耗。
三、BIO与NIO的区别
-
同步与异步:BIO是同步的,读写操作必须等待数据准备好后才能进行;而NIO是同步非阻塞的,读写操作不再受到数据准备状态的限制,可以进行读写操作,但可能需要等待数据真正写入或读取完成。
-
阻塞与非阻塞:BIO是阻塞的,在进行读写操作的时候,若使用BIO进行通信,则操作必须阻塞在一个线程内等待其完成;而NIO是非阻塞的,在进行读写操作的时候,若使用NIO进行通信,则操作不再受到阻塞的限制,可以进行其他操作。
-
面向流与面向缓冲:BIO是面向流的,一次只能处理一个输入或输出请求;而NIO是面向缓冲区的,一次可以处理多个输入或输出请求。
-
选择器(Selector):NIO有选择器,而BIO没有。选择器能够检测多个注册的通道上是否有事件发生,如果有事件发生便获取事件然后针对每个事件进行相应的响应处理,这样就可以只用一个单线程去管理多个通道,也就是管理多个连接。
四、为什么说BIO是同步阻塞,而NIO是同步非阻塞
1、BIO(Blocking I/O)为什么是同步阻塞的?
- 同步:在BIO模型中,当应用程序发起一个I/O请求(例如读取或写入数据)时,它必须等待操作系统内核完成这个请求。在此期间,应用程序的线程是阻塞的,不能做其他事情,直到I/O操作完成。这种等待内核响应的行为被称为同步。
- 阻塞:由于应用程序的线程在等待I/O操作完成期间不能做其他工作,因此我们说这个线程是被阻塞的。阻塞意味着线程不能继续执行后续的代码或处理其他任务,直到当前的I/O操作完成。
2、NIO(New I/O 或 Non-blocking I/O)为什么是同步非阻塞的?
- 同步:在NIO模型中,虽然引入了新的机制和概念(如选择器、通道和缓冲区),但I/O操作仍然是同步的。这意味着当应用程序发起一个I/O请求时,它仍然需要等待操作系统内核处理这个请求。NIO的同步性体现在它并没有完全消除等待,但改变了等待的方式和效率。
- 非阻塞:与BIO不同,NIO模型通过使用选择器和通道,允许单个线程处理多个I/O通道。当一个通道上的I/O操作不再受到阻塞的限制时(例如,可以立即返回而不是等待操作完成),我们说它是非阻塞的。这并不意味着I/O操作本身不需要时间,而是说在等待操作完成的过程中,线程可以转而处理其他通道的I/O操作或执行其他任务。
BIO模型因其简单的编程模型和直观的控制流程而易于理解和使用,但在处理大量并发连接时可能会因为每个连接都需要一个线程而变得效率低下。
NIO模型通过引入选择器和通道,使得单个线程可以处理多个连接,从而提高了系统的吞吐量和可伸缩性。虽然NIO的编程模型相对复杂,但它为处理高并发和大数据量的场景提供了更有效的解决方案。
五、BIO实现文件复制
使用BIO(Blocking I/O,阻塞式I/O)模型实现文件复制涉及到使用FileInputStream和FileOutputStream类。以下例展示了如何使用BIO复制文件:
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException; public class FileCopyBIO { public static void main(String[] args) { String sourceFilePath = "path/to/source/file.txt"; String targetFilePath = "path/to/target/file.txt"; try (FileInputStream inputStream = new FileInputStream(sourceFilePath); FileOutputStream outputStream = new FileOutputStream(targetFilePath)) { byte[] buffer = new byte[1024]; // 缓冲区,用于临时存储读取的数据 int bytesRead; // 读取源文件,并写入目标文件 while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } System.out.println("File copied successfully!"); } catch (IOException e) { e.printStackTrace(); } }
}
在这个例子中,我们创建了一个FileInputStream对象来读取源文件,和一个FileOutputStream对象来写入目标文件。我们使用一个字节数组buffer作为缓冲区,来临时存储从源文件读取的数据。while循环会持续读取数据,直到没有更多数据可读(即read方法返回-1)。
每次调用inputStream.read(buffer)时,它会阻塞直到有一些数据可以读取,或者到达文件末尾。同样地,outputStream.write(buffer, 0, bytesRead)也会阻塞直到所有数据都被写入。
这个简单的例子演示了BIO的基本工作方式:它会阻塞等待I/O操作的完成。在高并发或大数据量的场景下,这种阻塞行为可能会成为性能瓶颈,这时可能需要考虑使用NIO(Non-blocking I/O)或其他更高效的I/O模型。
六、NIO实现文件复制
Java NIO实现文件复制,使用FileChannel和ByteBuffer来以流的方式处理文件,适合处理大文件,因为它不会一次性将整个文件加载到内存中。
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption; public class FileCopyWithNIO { public static void main(String[] args) { // 源文件路径 Path sourcePath = Paths.get("source.txt"); // 目标文件路径 Path destinationPath = Paths.get("destination.txt"); try { // 打开源文件以进行读取,并获取其FileChannel FileChannel sourceChannel = FileChannel.open(sourcePath, StandardOpenOption.READ); // 打开(或创建)目标文件以进行写入,并获取其FileChannel // 注意:使用TRY_WITH_RESOURCES需要确保sourceChannel和destinationChannel都实现了AutoCloseable接口 // 这里我们手动关闭它们,所以没有使用TRY_WITH_RESOURCES FileChannel destinationChannel = FileChannel.open(destinationPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE); // 分配一个ByteBuffer来存储从源文件中读取的数据 ByteBuffer buffer = ByteBuffer.allocate(1024); // 缓冲区大小可以根据需要调整 // 读取并复制文件内容 while (sourceChannel.read(buffer) != -1) { // 切换ByteBuffer为读模式,准备从buffer中读取数据 buffer.flip(); // 将数据写入目标文件 destinationChannel.write(buffer); // 清空buffer,准备下一次读取 buffer.clear(); } // 关闭文件通道 sourceChannel.close(); destinationChannel.close(); System.out.println("File copied successfully."); } catch (IOException e) { System.err.println("Error occurred while copying file: " + e.getMessage()); } }
}
- 我们首先指定了源文件和目标文件的路径。
- 使用FileChannel.open()方法打开源文件进行读取,并打开(或创建)目标文件进行写入。
- 分配一个ByteBuffer来作为数据传输的中介。这个缓冲区的大小可以根据需要进行调整;在这里,我们选择了一个1024字节的缓冲区。
- 使用一个while循环来持续从源文件中读取数据,直到文件末尾。在每次循环中:
- 使用sourceChannel.read(buffer)方法读取数据到缓冲区。
- 调用buffer.flip()来准备从缓冲区中读取刚刚写入的数据。这一步是必需的,因为buffer在写模式后需要切换到读模式。
- 使用destinationChannel.write(buffer)方法将缓冲区中的数据写入目标文件。
- 调用buffer.clear()来清空缓冲区,准备下一次读取操作。这一步不会真正清除缓冲区中的数据,而是将缓冲区的位置设置为0,并将限制设置为容量,以便下一次写入操作。
- 关闭源文件和目标文件的通道。
这个例子展示了如何使用Java NIO的FileChannel和ByteBuffer以高效的方式复制文件,特别适用于处理大文件,因为它不需要一次性加载整个文件到内存中。
七、BIO实现socket通信
使用BIO(Blocking I/O,阻塞式I/O)实现socket通信涉及到ServerSocket和Socket类。下面是一个简单的例子,展示如何使用BIO实现一个基本的服务器-客户端socket通信。
服务器端(Server)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket; public class BIOServer { public static void main(String[] args) { int port = 8080; try (ServerSocket serverSocket = new ServerSocket(port)) { System.out.println("Server is listening on port " + port); while (true) { Socket clientSocket = serverSocket.accept(); // 阻塞等待客户端连接 System.out.println("Client connected from " + clientSocket.getInetAddress()); // 启动一个新线程来处理客户端请求 new Thread(() -> handleClient(clientSocket)).start(); } } catch (IOException e) { System.err.println("Could not listen on port " + port); e.printStackTrace(); } } private static void handleClient(Socket clientSocket) { try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)) { String clientMessage; while ((clientMessage = in.readLine()) != null) { // 阻塞等待客户端消息 System.out.println("Received message from client: " + clientMessage); out.println("Echo from server: " + clientMessage); // 发送响应给客户端 } } catch (IOException e) { System.err.println("Error handling client: " + e.getMessage()); } finally { try { clientSocket.close(); } catch (IOException e) { e.printStackTrace(); } } }
}
客户端(Client)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket; public class BIOClient { public static void main(String[] args) { String host = "localhost"; int port = 8080; try (Socket socket = new Socket(host, port)) { System.out.println("Connected to server at " + host + ":" + port); // 发送消息给服务器 PrintWriter out = new PrintWriter(socket.getOutputStream(), true); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in)); String userInput; while ((userInput = stdIn.readLine()) != null) { out.println(userInput); String serverResponse = in.readLine(); // 阻塞等待服务器响应 System.out.println("Server response: " + serverResponse); if (userInput.equalsIgnoreCase("bye")) { break; } } } catch (IOException e) { System.err.println("Couldn't connect to server at " + host + ":" + port); e.printStackTrace(); } }
}
在这个例子中,服务器使用ServerSocket监听8080端口。当客户端连接时,serverSocket.accept()方法会阻塞,直到有客户端连接上。一旦连接建立,服务器会为新连接的客户端启动一个新线程来处理通信。
客户端使用Socket类连接到服务器。客户端和服务器都使用BufferedReader和PrintWriter来读写数据。注意,在读取和写入数据时,这些操作都是阻塞的。
这个例子展示了BIO的基本工作原理:读写操作受到操作系统的控制,并且在操作完成之前,执行这些操作的线程会被阻塞。在高并发的场景下,这种模型可能会导致资源利用率低下,因为每个连接都需要一个线程来处理。
八、NIO实现socket通信
使用Selector来实现非阻塞式I/O操作,我们创建一个简单的非阻塞式服务器,它能够同时处理多个客户端连接。
服务端:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set; public class NonBlockingServer { public static void main(String[] args) throws IOException { // 创建一个Selector Selector selector = Selector.open(); // 打开一个ServerSocketChannel并设置为非阻塞模式 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); // 绑定ServerSocketChannel到一个地址和端口 serverSocketChannel.bind(new InetSocketAddress(8080)); // 将ServerSocketChannel注册到Selector,关心ACCEPT事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { // 阻塞等待需要处理的事件 int readyChannels = selector.select(); if (readyChannels == 0) continue; // 获取可用通道集合 Set<SelectionKey> selectedKeys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = selectedKeys.iterator(); // 遍历SelectionKey while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); // 检查是否有新的连接,如果有,则接受该连接 if (key.isAcceptable()) { ServerSocketChannel server = (ServerSocketChannel) key.channel(); SocketChannel client = server.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); System.out.println("Accepted new connection from " + client); } // 检查是否有数据可读 else if (key.isReadable()) { SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int bytesRead = client.read(buffer); if (bytesRead == -1) { client.close(); } else { buffer.flip(); while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } client.register(selector, SelectionKey.OP_WRITE); } } // 检查是否有数据可写 else if (key.isWritable()) { SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put("Hello from server!".getBytes()); buffer.flip(); client.write(buffer); client.close(); } // 处理完SelectionKey后,需要从集合中删除 keyIterator.remove(); } } }
}
- 我们首先创建了一个Selector实例,它将用于监控多个通道的状态。
- 然后,我们打开了一个ServerSocketChannel,将其设置为非阻塞模式,并绑定到指定的端口(在这个例子中是8080)。
- 我们将ServerSocketChannel注册到Selector,并指定我们关心ACCEPT事件,这意味着我们想要知道何时有新的连接请求。
- 进入一个无限循环,在循环中,我们调用selector.select()来阻塞等待直到至少有一个通道准备好进行I/O操作。
- 一旦select()返回,我们获取Selector中已选择的键的集合,并遍历它们。
- 对于每个键,我们检查它是否可接受(即是否有新的连接等待被接受)、是否可读(即是否有数据可以从通道中读取)或是否可写(即是否有数据可以写入通道)。
- 根据键的状态,我们执行相应的操作,如接受新连接、读取数据或写入数据。
- 在处理完一个键之后,我们必须从选定的键集合中删除它,否则下次选择操作时它仍会被包含在内。
客户端将连接到服务器,发送一条消息,并等待接收服务器的响应
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel; public class NonBlockingClient { public static void main(String[] args) throws IOException { // 打开一个SocketChannel并连接到服务器 SocketChannel socketChannel = SocketChannel.open(); socketChannel.configureBlocking(false); socketChannel.connect(new InetSocketAddress("localhost", 8080)); // 完成连接过程 while (!socketChannel.finishConnect()) { // 非阻塞模式下,可能需要多次调用finishConnect()来完成连接 System.out.println("Connecting to server..."); } System.out.println("Connected to server"); // 准备要发送的数据 String message = "Hello from client!"; ByteBuffer writeBuffer = ByteBuffer.wrap(message.getBytes()); // 发送数据到服务器 while (writeBuffer.hasRemaining()) { socketChannel.write(writeBuffer); } // 准备读取服务器的响应 ByteBuffer readBuffer = ByteBuffer.allocate(1024); // 读取服务器的响应 while (true) { int bytesRead = socketChannel.read(readBuffer); if (bytesRead == -1) { break; // 服务器已关闭连接 } if (bytesRead > 0) { readBuffer.flip(); while (readBuffer.hasRemaining()) { System.out.print((char) readBuffer.get()); } readBuffer.clear(); } } // 关闭SocketChannel socketChannel.close(); }
}
- 我们首先创建一个SocketChannel实例,并设置其为非阻塞模式。
- 使用connect()方法异步地连接到服务器。在非阻塞模式下,connect()方法可能立即返回,而连接过程可能尚未完成。因此,我们需要检查连接状态并可能需要多次调用finishConnect()来确保连接已经完成。
- 一旦连接建立,我们创建一个包含要发送的消息的ByteBuffer,并使用SocketChannel的write()方法发送数据。注意,在非阻塞模式下,write()方法可能不会发送所有的数据,因此我们需要在循环中调用它,直到所有数据都被发送。
- 接着,我们准备一个空的ByteBuffer来接收服务器的响应,并在一个循环中调用read()方法来读取数据。同样地,由于我们处于非阻塞模式,read()方法可能立即返回0(如果没有数据可读),或者返回实际读取的字节数。当read()返回-1时,表示服务器已经关闭了连接。
- 我们使用flip()方法来准备从ByteBuffer中读取数据,并在读取完数据后使用clear()方法来清空缓冲区,以便下次读取。
- 最后,我们关闭SocketChannel来释放资源。
九、总结
Java BIO和NIO是两种不同的I/O模型,它们在设计、工作原理和使用上有显著的区别。BIO是同步阻塞的I/O模型,它简单直接但性能有限;而NIO是同步非阻塞的I/O模型,它引入了多路复用器和缓冲区的概念,提高了系统的吞吐量和性能。在选择使用哪种模型时,需要根据具体的应用场景和需求进行权衡。如果并发连接数较少且对性能要求不高,可以选择使用BIO;如果并发连接数较多且对性能要求较高,可以选择使用NIO。