别挠头了!我教你什么是BIO,NIO,AIO

什么是网络IO模型?

网络 IO 模型指的是程序在进行网络通信时所采用的 IO(Input/Output)方式。目前比较常见的有如下几种方式:

1. BIO: Blocking IO 即同步阻塞式IO

2. NIO: No Blocking IO 即同步非阻塞式IO

3. AIO: Asynchronous IO 即异步非阻塞IO(常见但是开发的时候一般不用)

什么是BIO?

先看一段代码:

/**
 * BIO网络IO模型服务端代码
 */
public class Server {
    public static void main(String[] args) throws IOException {
        // 创建ServerSocket,并绑定端口号
        ServerSocket serverSocket = new ServerSocket(8080);
        while (true) {
            // 阻塞等待客户端连接
            Socket socket = serverSocket.accept();
            // 读取客户端发送的数据,如果没有数据可读或者读数据的过程中会一直阻塞
            InputStream is = socket.getInputStream();
            byte[] buffer = new byte[1024];
            int len = is.read(buffer);
            String msg = new String(buffer, 0, len);
            System.out.println("Received message from client: " + msg);
            // 发送响应给客户端,如果没有数据可读或者读数据的过程中会一直阻塞
            OutputStream os = socket.getOutputStream();
            os.write("Hello, Client!".getBytes());
            // 关闭socket连接
            socket.close();
        }
    }
}

/**
 * BIO网络IO模型客户端代码
 */
public class Client {
    public static void main(String[] args) throws IOException {
        // 创建Socket,并连接服务端
        Socket socket = new Socket("localhost", 8080);
        // 向服务端发送数据
        OutputStream os = socket.getOutputStream();
        os.write("Hello, Server!".getBytes());
        // 读取服务端响应的数据
        InputStream is = socket.getInputStream();
        byte[] buffer = new byte[1024];
        int len = is.read(buffer);
        String msg = new String(buffer, 0, len);
        System.out.println("Received message from server: " + msg);
        // 关闭socket连接
        socket.close();
    }
}

这就是经典的BIO模型下的客户端和服务端网络连接。从服务端的代码中可以看到主要有两个地方存在阻塞:

1. 服务端等待客户端连接的时候阻塞

2. 进行读写的时候,数据没有准备好就会阻塞(比如客户端一直不发数据)

对于第一种情况的阻塞,只会让accept()所在的线程做不了其他事情,但不会影响客户端的连接。

但是如果读写时候发生阻塞,那么其他的客户端要连接服务端,就会出现无法连接的情况。因为在一个while循环中阻塞住就不会进入下一次循环。

针对读写阻塞,无法连接多个客户端的情况,一种比较容易想到的方案是把读写放到其他线程。

这样读写就不会阻塞while循环了,也就不会影响其他客户端连接服务器了。

代码如下:

public class BIOServer {
    private static final int PORT = 8080;
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(PORT);
        System.out.println("Server started on port " + PORT);
        while (true) {
            Socket client = server.accept();
            System.out.println("Accepted connection from " + client.getRemoteSocketAddress());
            //多线程方式处理读写操作
            new Thread(new ClientHandler(client)).start();
        }
    }
}


class ClientHandler implements Runnable {
    private Socket client;
    public ClientHandler(Socket client) {
        this.client = client;
    }
    @Override
    public void run() {
        try {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = client.getInputStream().read(buffer)) > 0) {
                String request = new String(buffer, 0, len);
                System.out.println("Received request: " + request);
                String response = "Hello from server";
                client.getOutputStream().write(response.getBytes());
                client.getOutputStream().flush();
            }
            client.close();
            System.out.println("Connection closed by client");
        } catch (IOException e) {
            //处理异常
        }
    }
}

每次一个链接过来都会把要读写的操作单独开一个线程,这样while就不会被读写阻塞,可以允许多个客户端链接。

但是要注意,这里读写虽然不阻塞while所在的线程,依旧会阻塞新开辟的线程。

比如A客户端链接到了服务器,于是服务器给它开辟了一个线程A。

可是该客户端一直不发数据,那么线程A则一直会被阻塞在哪里(此时线程什么也没干,还占着资源)。

这个模型被称为BIO的原因就是accept(),read(),write()会发生阻塞。

另外因为读写阻塞的存在,新创建的线程就会被阻塞而无法释放。如果并发量比较大的情况下就会有大量的线程被创建。

默认情况下Java中一个线程需要分配1M的内存空间。这对服务器的资源造成了很大的浪费。

大佬们意识到阻塞问题的严重性,于是捣鼓出了NIO模型,专注于解决阻塞问题。

那BIO用线程池行吗?

线程池是可以限制创建线程的多少。但是只限制达不到目的,因为并发量比较大的情况下,一旦客户端连接的数量超过了限制的最大值,就会导致客户端连接不上服务器。

什么是NIO?

先上代码为敬:

public class NIOServer {
    public static void main(String[] args) throws IOException {
        // 创建一个 Selector
        Selector selector = Selector.open();
        // 创建一个 ServerSocketChannel,并将其绑定到本地地址 8888 上
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.socket().bind(new InetSocketAddress(8888));
        serverSocketChannel.configureBlocking(false);
        // 将 ServerSocketChannel 注册到 Selector 中,并指定需要监听 OP_ACCEPT 事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while (true) {
            // 阻塞直到有事件发生
            selector.select();
            // 获取所有 SelectionKey
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            // 处理每一个 SelectionKey
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                // 如果 SelectionKey 是 OP_ACCEPT,则处理新的连接
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    //非阻塞
                    SocketChannel client = server.accept();
                    System.out.println("Accepted connection from " + client);
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                    // 如果 SelectionKey 是 OP_READ,则读取数据
                } else if (key.isReadable()) {
                    SocketChannel client = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    client.read(buffer);
                    buffer.flip();
                    byte[] bytes = new byte[buffer.remaining()];
                    buffer.get(bytes);
                    System.out.println("Received message: " + new String(bytes));
                    // 返回响应给客户端
                    ByteBuffer responseBuffer = ByteBuffer.wrap("Hello, client!".getBytes());
                    client.write(responseBuffer);
                }
            }
        }
    }
}

代码虽长,却非常简单。

NIO只需要关注三个东西:

1. Channel :可以理解为BIO中的Socket通道,只不过BIO的Socket是单向的,这个是双向的,可以同时读写。

2. Selector :选择器。把那些活跃的Channel(想要链接服务端的Channel,需要读写数据的Channel)挑出来,供后续的处理。

3. SelectionKey :标记Channel想要进行什么操作,比如连接操作,读写操作。处理Channel的时候会根据Key来进行相应的操作。

代码的大致原理如下图:

别挠头了!我教你什么是BIO,NIO,AIO

比如客户端连接服务器,可能会经过如下步骤:

1. 客户端A要连接服务器,服务器接收到之后就会打开一个ServerSocketChannel

2. 然后就会把这个ServerSocketChannel(可以理解为BIO中的ServerSocket)注册到Selector(也就是交给Selector管理这个Channel),并绑定一个OP_ACCEPT(代表Channel需要进行连接)事件

3. 然后会有一个循环,不断的从Selector获取活跃的Key并进行处理

4. 一旦发现了OP_ACCEPT,就会创建一个SocketChannel(相当于BIO中的Socket),此时一个连接通道就建立完成了

5. 然后会把这个SocketChannel注册到Selector中,并绑定一个OP_READ事件

6. 如果客户端A发送了一个数据,那么Selector就会监控到这个动作,下一次循环的时候就会从Selector中取出活跃的Channel,并根据对应的OP_READ事件进行处理。

这就是NIO处理连接的大体流程。

可能有人会有疑问。这哪里变成非阻塞了?

对照BIO。NIO的非阻塞就是上面说的那两个:

1. 服务端不会阻塞的等待客户端链接,即server.accept()不会阻塞。

2. 数据没有准备好的时候,读写不会阻塞。即NIO不会存在像BIO那样有线程什么也不干,白白楞在那。

之所以不会阻塞,是因为在NIO中一个线程会处理多个连接。并且 处理的都是Selector过滤之后的活跃连接(有accept或者读写操作的连接),所以不存在线程啥也不干,空等的阻塞现象。

这里的阻塞很多人其实没有理解明白,网上很多人其实自己都不理解,写出的文章更是难以自圆其说。

为了行文流畅,这里先不花篇幅解释这个概念。下文会集中讲解。

什么是Netty?

因为NIO的API封装的并不好,业务端开发的时候会写很多的模板代码。所以Netty对NIO进行了再次的封装,并在NIO的基础上进行了一些优化。

Netty就不做过多介绍了,只要理解了上面的NIO模型,Netty很容易理解。

什么是AIO?

该模型是异步非阻塞。你可以理解为在NIO中如果读一个数据,是自己的应用程序在处理这些数据。

但是如果是AIO,就相当于在自己的程序中定义了一段逻辑,但是执行的时候是由操作系统直接执行的,和应用没有关系。

也就是说如果发现IO读写是自己的程序进行读写,那就是同步。如果不是那就是异步。

一个故事说明BIO,NIO,AIO

比如一个餐馆。每个顾客就是一个客户端,每个服务员就是一个线程。

在BIO模型下餐馆会给每个顾客配一个服务员,如果该顾客有需求,那么服务员就处理顾客的需求。如果顾客没有要求,那服务员就在旁边傻站着发呆。

在NIO模型下餐馆会让一个服务员负责多个顾客。哪个顾客有需求,服务员就去服务那个顾客。如果收到一个需求就会一直处理,直到处理好返回给顾客。并且会经常问顾客们:“还需不需要什么服务?”。

在AIO模型下一个服务员同样负责多个顾客。但是如果有多个顾客点了菜,那么服务员会写一个纸条(回调函数),纸条上写好客户的需求以及如何处理,然后交给一个神秘大佬(操作系统)。神秘大佬操作完成之后会通知服务员。相当于服务员把自己的活外包了。

可以仔细体会下,NIO和AIO的区别就是IO的操作由谁来完成。如果由应用程序自己来完成,那就是传说中的同步IO,否则就是传说中的异步IO。

究竟什么是多路复用?

多路复用就是一个线程管理多个连接。路可以理解为一个个的Channel,多个客户端就有多个Channel。

但是并不是每个Channel都是活跃的,所以经过Selector之后,会把活跃的Channel挑出来,并对这些活跃的Channel进行处理。

这个一个线程过滤多个连接并处理多个连接的动作就是多路复用,即多个连接复用一个线程。

select,poll,epoll 和 网络IO模型究竟是什么关系?

select,poll,epoll是多路复用的Linux操作系统层面的实现(Selector的底层实现)。网上资料比较多,没有什么歧义,此处不展开。

什么是阻塞?

其实网络IO模型中的阻塞指的是线程的 空等 现象。

就比如BIO中开启的线程,可能什么也不干,等着客户端发数据,这种情况就是阻塞。

再比如BIO中服务端accept()空等客户端的连接,这种情况也是阻塞。

那NIO中accept(),read(),write()有人说也是阻塞,因为这些操作执行过程中需要时间,看起来也阻塞了线程。

但是注意这里线程是真正的在干活,在工作,所以这种情况不叫阻塞,而是叫同步。

什么是异步和同步?

就像上面所说,NIO中的accept(),read(),write()操作需要应用程序亲自进行数据的操作就叫同步。

相反,如果在应用程序中遇到这种操作直接往下走,而把这些操作交给操作系统执行,执行完成后通知应用程序,那么这种就叫异步。

总结一下,就是说应用程序自己花时间读写数据就叫同步,如果应用程序自己不花时间,而是把这种操作交给其他人(比如操作系统),那么就是异步。

那你思考一个问题,如果一个线程遇到了读数据的操作,然后开了一个子线程处理这种操作。这种情况属于同步还是异步?

这种情况属于业务线程模型是异步,但是IO模型是同步。其实你可以这么理解,把业务线程和IO当作两个层面的东西。

在一个业务线程中如果遇到读写自己不操作,而交给另一个线程操作,这叫业务线程的异步。

那么同样在IO模型层面,如果应用自己不操作,而交给操作系统,那么这就叫做IO异步。

更简单的理解方式就是你就看read(),write(),accept()等方法在自己程序中调用的时候会不会直接返回,如果是,那就是异步,如果不是那就是同步。

多路复用一定要非阻塞吗?

是,多路复用是一个线程负责处理多个连接。如果是阻塞的,只要有一个连接把线程阻塞了。那这个线程就废了,其他连接也处理不了。

BIO一定不能用吗?

技术都有它的应用场景。如果并发量比较低,是可以用BIO的。

在连接数比较小的情况下BIO模型因为没有多路复用遍历活跃连接的过程,并且每个连接独享线程。性能不一定比NIO差。

Redis用的什么IO模型?

Redis底层也是多路复用。经常听到的别人口中的Redis是单线程,但是还是非常快的原因就是Redis是用epoll实现的多路复用。

正因为是多路复用,如果一个命令耗时太长就可能占用线程的时间过长。影响其他命令的执行。

所以用Redis的时候一定要关注执行的命令时间复杂度如何。比如keys*一般情况下公司是禁止执行的。

小思考题: 你能用一句话说明白什么才算是高性能吗?快发到评论区吧[看]