BIO、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的使用,并对比了它们的区别和应用场景。

Java 中常见的 I/O 模型

当应用程序发起 I/O 调用后,会经历两个步骤:

  1. 内核等待 I/O 设备准备好数据
  2. 内核将数据从内核空间拷贝到用户空间。
1 BIO (Blocking I/O)

BIO 属于同步阻塞 IO 模型

BIO是一种同步阻塞I/O操作方式,即当线程执行输入或输出操作时,线程被阻塞,一直等待输入或输出完成并返回结果。在这种模型中,一个请求对应于一个线程,因此支持的并发量比较有限。

图源:《深入拆解Tomcat & Jetty》

在Java中,有几种阻塞式I/O的实现方式,也称为BIO。以下是其中几种常见的BIO:

  1. java.net.ServerSocketServerSocket类是用于创建服务器端的Socket对象。它侦听指定的端口,等待客户端的连接请求。当有新的连接请求到达时,accept()方法会阻塞当前线程,直到有客户端连接成功。详见案例1。
  2. java.net.Socket:Java标准库中的Socket类提供了阻塞式的网络I/O操作。它允许建立客户端和服务器之间的连接,并通过InputStream OutputStream 进行数据读写。当进行数据读取或写入时,Socket 会阻塞当前线程直到操作完成或发生异常。详见案例2。
  3. java.io.InputStreamjava.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:

  1. java.nio.channels.ChannelChannel 是 NIO 中的核心概念,它代表了一个打开的连接,可以进行读取和写入操作。与阻塞式I/O不同,Channel非阻塞的,它不会阻塞线程,而是立即返回。

  2. java.nio.channels.SelectorSelector 是 NIO 中多路复用的关键组件,可以监视多个 Channel 状态的对象。它可以检测 Channel 是否已经准备好进行读取或写入操作。通过 Selector可以使用单个线程处理多个 Channel 的I/O操作

  3. java.nio.ByteBufferByteBuffer 是 NIO 中用于数据读取和写入的缓冲区,使用 ByteBuffer 可以高效地进行数据传输。

    1、核心组件:SelectorChannelBuffer 被称为 NIO 的三大核心。在NIO中,数据的读写通常是通过将数据从 Channel 读取到 Buffer,或从 Buffer 写入到 Channel 中:

    img

    Selector 提供了高效的事件驱动机制,可以在单个线程中处理多个 Channel 的I/O操作,避免了为每个 Channel 创建独立线程的开销。

    2、使用方式:使用 Selector 时,可以注册一个或多个 ChannelSelector 上,并指定感兴趣的事件类型,例如读就绪、写就绪等。Selector 会不断地轮询这些 Channel 的状态,当有 Channel 准备就绪时,Selector 会通知相应的线程进行处理。

    3、总结:这三个核心组件共同构成了NIO的基础,通过 Channel 进行数据的读写操作,使用 Buffer 作为数据的中转存储区域,通过 Selector 实现多路复用和事件驱动。这种非阻塞的I/O模型可以提高系统的并发处理能力和性能。

  4. java.nio.channels.ServerSocketChannelServerSocketChannel 是用于创建服务器端的 Channel 对象。它可以监听指定的端口,并等待客户端的连接请求。与阻塞式的 ServerSocket 不同,ServerSocketChannel 是非阻塞的。

  5. java.nio.channels.SocketChannelSocketChannel 是用于创建客户端连接的 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 事件(serverChannelSelectionKeyOP_ACCEPT 标志位置为真),服务器端就可以接受新的连接并进行相应的处理。

2、解释上述 while 循环里面的流程:

  1. 调用 select() 方法用于轮询与通道关联的I/O事件,以检查是否有任何事件就绪。该方法会阻塞当前线程,直到至少一个通道就绪(如果不希望阻塞可以使用 selectNow() 方法)。一旦有通道就绪,select() 方法会返回就绪通道的数量。
  2. 通过调用 selectedKeys() 方法获取到一个 Set 对象,其中包含了所有就绪的 SelectionKey
  3. 遍历 Set 中的 SelectionKey,可以通过 isAcceptable()isConnectable()isReadable()isWritable() 方法判断具体是哪种就绪事件。
  4. 根据就绪事件类型进行相应的处理。

客户端(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();
    }
}

测试,先启动服务端,再启动客户端:

在这里插入图片描述

在这里插入图片描述

img

顺便提一嘴 netty(如果只需要搞懂NIO,这部分不需要掌握):

Netty是一个基于Java的高性能、异步事件驱动的网络应用框架。它提供了简单易用的API,能够帮助开发者快速构建可扩展的网络应用程序。Netty内部封装了NIO相关的组件,提供了更高级的抽象和功能,使得网络编程更加简洁和高效。

基本使用案例如下:

  1. 添加依赖:在项目的构建文件(如Maven的pom.xml)中添加Netty的依赖。

    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>版本号</version>
    </dependency>
    
  2. 创建服务端:

    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

  3. 创建客户端:

    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()方法进行同步等待,直到连接完成。

  4. 自定义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最大的不同在于编程模型和底层实现方式。

  1. 编程模型:
    • NIO使用事件驱动模型,应用程序需要显式地检查通道的事件状态并进行相应的操作(显示检查key)。
    • AIO使用回调模型,应用程序通过回调方法处理I/O操作完成事件。它在发起异步操作后立即返回,并在操作完成时通知应用程序调用相应的回调方法。
  2. 底层实现:
    • 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应该根据具体的需求和应用场景来决定。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值