面试官:BIO、NIO、AIO之间有什么区别?

WilliamGates
发布于 2023-9-28 10:41
浏览
0收藏

一、简介

在计算机中,IO 传输数据有三种工作方式,分别是: BIO、NIO、AIO

在讲解 BIO、NIO、AIO 之前,我们先来回顾一下这几个概念:同步与异步,阻塞与非阻塞

同步与异步的区别

  • 同步就是发起一个请求后,接受者未处理完请求之前,不返回结果。
  • 异步就是发起一个请求后,立刻得到接受者的回应表示已接收到请求,但是接受者并没有处理完,接受者通常依靠事件回调等机制来通知请求者其处理结果。

阻塞和非阻塞的区别

  • 阻塞就是请求者发起一个请求,一直等待其请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
  • 非阻塞就是请求者发起一个请求,不用一直等着结果返回,可以先去干其他事情,当条件就绪的时候,就自动回来。

而我们要讲的 BIO、NIO、AIO 就是同步与异步、阻塞与非阻塞的组合。

  • BIO:同步阻塞 IO;
  • NIO:同步非阻塞 IO;
  • AIO:异步非阻塞 IO;

不同的工作方式,带来的传输效率是不一样的,下面我们以网络 IO 为例,一起看看不同的工作方式下,彼此之间有何不同

二、BIO

BIO 俗称同步阻塞 IO,是一种非常传统的 IO 模型,也是最常用的网络数据传输处理方式,优点就是编程简单,但是缺点也很明显,I/O 传输性能一般比较差,CPU 大部分处于空闲状态。

采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听所有客户端的连接,当服务端接受到多个客户端的请求时,所有的客户端只能排队等待服务端一个一个的处理。

BIO 通信模型图如下!

面试官:BIO、NIO、AIO之间有什么区别?-鸿蒙开发者社区

一般在服务端通过​​while(true)​​​循环中会调用​​accept() ​​方法监听客户端的连接,一旦接收到一个连接请求,就可以建立通信套接字进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成。

服务端操作,样例程序如下

public class BioServerTest {

    public static void main(String[] args) throws IOException {
        //初始化服务端socket并且绑定 8080 端口
        ServerSocket serverSocket = new ServerSocket(8080);
        //循环监听客户端请求
        while (true){
            try {
                //监听客户端请求
                Socket socket = serverSocket.accept();

                //将字节流转化成字符流,读取客户端输入的内容
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                //读取一行数据
                String str = bufferedReader.readLine();
                //打印客户端发送的信息
                System.out.println("服务端收到客户端发送的信息:" + str);

                //向客户端返回信息,将字符转化成字节流,并输出
                PrintWriter printWriter = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()),true);
                printWriter.println("hello,我是服务端,已收到消息");

                // 关闭流
                bufferedReader.close();
                printWriter.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

客户端操作,样例程序如下

public class BioClientTest {

    public static void main(String[] args){
        //创建10个线程,模拟10个客户端,同时向服务端发送请求
        for (int i = 0; i < 10; i++) {
            final int j = i;//定义变量
            new Thread(new Runnable() {

                @Override
                public void run(){
                    try {
                        //通过IP和端口与服务端建立连接
                        Socket socket =new Socket("127.0.0.1",8080);
                        //将字符流转化成字节流,并输出
                        PrintWriter printWriter = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()),true);
                        String str="Hello,我是" + j + "个,客户端!";
                        printWriter.println(str);

                        //从输入流中读取服务端返回的信息,将字节流转化成字符流
                        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                        //读取内容
                        String result = bufferedReader.readLine();
                        //打印服务端返回的信息
                        System.out.println("客户端发送请求内容:" + str + " -> 收到服务端返回的内容:" + result);

                        // 关闭流
                        bufferedReader.close();
                        printWriter.close();
                        // 关闭socket
                        socket.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }
}

最后,依次启动服务端、客户端,看看控制台输出情况如何。

服务端控制台结果如下:

服务端收到客户端发送的信息:Hello,我是8个,客户端!
服务端收到客户端发送的信息:Hello,我是9个,客户端!
服务端收到客户端发送的信息:Hello,我是7个,客户端!
服务端收到客户端发送的信息:Hello,我是5个,客户端!
服务端收到客户端发送的信息:Hello,我是4个,客户端!
服务端收到客户端发送的信息:Hello,我是3个,客户端!
服务端收到客户端发送的信息:Hello,我是6个,客户端!
服务端收到客户端发送的信息:Hello,我是2个,客户端!
服务端收到客户端发送的信息:Hello,我是1个,客户端!
服务端收到客户端发送的信息:Hello,我是0个,客户端!

客户端控制台结果如下:

客户端发送请求内容:Hello,我是8个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是9个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是7个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是5个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是4个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是3个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是6个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是2个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是1个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息
客户端发送请求内容:Hello,我是0个,客户端! -> 收到服务端返回的内容:hello,我是服务端,已收到消息

随着客户端的请求次数越来越多,可能需要排队的时间会越来越长,因此是否可以在服务端,采用多线程编程进行处理呢?

答案是,可以的!

下面我们对服务端的代码进行改造,服务端多线程操作,样例程序如下:

public class BioServerTest {

    public static void main(String[] args) throws IOException {
        //初始化服务端socket并且绑定 8080 端口
        ServerSocket serverSocket = new ServerSocket(8080);
        //循环监听客户端请求
        while (true){
            //监听客户端请求
            Socket socket = serverSocket.accept();
            new Thread(new Runnable() {

                @Override
                public void run(){
                    try {
                        String threadName = Thread.currentThread().toString();
                        //将字节流转化成字符流,读取客户端输入的内容
                        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                        //读取一行数据
                        String str = bufferedReader.readLine();
                        //打印客户端发送的信息
                        System.out.println("线程名称" + threadName + ",服务端收到客户端发送的信息:" + str);

                        //向客户端返回信息,将字符转化成字节流,并输出
                        PrintWriter printWriter = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()),true);
                        printWriter.println("hello,我是服务端,已收到消息");

                        // 关闭流
                        bufferedReader.close();
                        printWriter.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }
}

依次启动服务端、客户端,服务端控制台输出结果如下:

线程名称Thread[Thread-8,5,main],服务端收到客户端发送的信息:Hello,我是4个,客户端!
线程名称Thread[Thread-4,5,main],服务端收到客户端发送的信息:Hello,我是8个,客户端!
线程名称Thread[Thread-0,5,main],服务端收到客户端发送的信息:Hello,我是1个,客户端!
线程名称Thread[Thread-7,5,main],服务端收到客户端发送的信息:Hello,我是5个,客户端!
线程名称Thread[Thread-5,5,main],服务端收到客户端发送的信息:Hello,我是2个,客户端!
线程名称Thread[Thread-9,5,main],服务端收到客户端发送的信息:Hello,我是3个,客户端!
线程名称Thread[Thread-1,5,main],服务端收到客户端发送的信息:Hello,我是0个,客户端!
线程名称Thread[Thread-3,5,main],服务端收到客户端发送的信息:Hello,我是7个,客户端!
线程名称Thread[Thread-2,5,main],服务端收到客户端发送的信息:Hello,我是9个,客户端!
线程名称Thread[Thread-6,5,main],服务端收到客户端发送的信息:Hello,我是6个,客户端!

当服务端接收到客户端的请求时,会给每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,最后线程会销毁。

但是这样的编程模型也有很大的弊端,如果出现 100、1000、甚至 10000 个客户端同时请求服务端,采用这种编程模型,服务端也会创建与之相同的线程数量,线程数急剧膨胀可能会导致线程堆栈溢出、创建新线程失败等问题,最终可能导致服务端宕机或者僵死,不能对外提供服务

三、伪异步 BIO

为了解决上面提到的同步阻塞 I/O 面临的一个链路需要一个线程处理的问题,后来有人对它的编程模型进行了优化。

在服务端通过使用 Java 中​​ThreadPoolExecutor​​线程池机制来处理多个客户端的请求接入,防止由于海量并发接入导致资源耗尽,让线程的创建和回收成本相对较低,保证了系统有限的资源得以控制,实现了 N (客户端请求数量)大于 M (服务端处理客户端请求的线程数量)的伪异步 I/O 模型。

伪异步 IO 模型图,如下图:

面试官:BIO、NIO、AIO之间有什么区别?-鸿蒙开发者社区

采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架,当有新的客户端接入时,将客户端的 Socket 封装成一个 Task 投递到线程池中进行处理。

服务端采用线程池处理客户端请求,样例程序如下:

public class BioServerTest {

    public static void main(String[] args) throws IOException {
        //在线程池中创建5个固定大小线程,来处理客户端的请求
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        //初始化服务端socket并且绑定 8080 端口
        ServerSocket serverSocket = new ServerSocket(8080);
        //循环监听客户端请求
        while (true){
            //监听客户端请求
            Socket socket = serverSocket.accept();
            //使用线程池执行任务
            executorService.execute(new Runnable() {

                @Override
                public void run(){
                    try {
                        String threadName = Thread.currentThread().toString();
                        //将字节流转化成字符流,读取客户端输入的内容
                        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                        //读取一行数据
                        String str = bufferedReader.readLine();
                        //打印客户端发送的信息
                        System.out.println("线程名称" + threadName + ",服务端收到客户端发送的信息:" + str);

                        //向客户端返回信息,将字符转化成字节流,并输出
                        PrintWriter printWriter = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()),true);
                        printWriter.println("hello,我是服务端,已收到消息");

                        // 关闭流
                        bufferedReader.close();
                        printWriter.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    }
}

依次启动服务端、客户端,服务端控制台输出结果如下:

线程名称Thread[pool-1-thread-4,5,main],服务端收到客户端发送的信息:Hello,我是6个,客户端!
线程名称Thread[pool-1-thread-2,5,main],服务端收到客户端发送的信息:Hello,我是8个,客户端!
线程名称Thread[pool-1-thread-3,5,main],服务端收到客户端发送的信息:Hello,我是9个,客户端!
线程名称Thread[pool-1-thread-5,5,main],服务端收到客户端发送的信息:Hello,我是5个,客户端!
线程名称Thread[pool-1-thread-1,5,main],服务端收到客户端发送的信息:Hello,我是7个,客户端!
线程名称Thread[pool-1-thread-5,5,main],服务端收到客户端发送的信息:Hello,我是2个,客户端!
线程名称Thread[pool-1-thread-5,5,main],服务端收到客户端发送的信息:Hello,我是0个,客户端!
线程名称Thread[pool-1-thread-1,5,main],服务端收到客户端发送的信息:Hello,我是1个,客户端!
线程名称Thread[pool-1-thread-5,5,main],服务端收到客户端发送的信息:Hello,我是3个,客户端!
线程名称Thread[pool-1-thread-1,5,main],服务端收到客户端发送的信息:Hello,我是4个,客户端!

本例中测试的客户端数量是 10,服务端使用 java 线程池来处理任务,线程数量为 5 个,服务端不用为每个客户端都创建一个线程,由于线程池可以设置消息队列的大小和最大线程数,因此它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。

在活动连接数不是特别高的情况下,这种模型还是不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。

但是,它的底层仍然是同步阻塞的 BIO 模型,当面对十万甚至百万级请求接入的时候,传统的 BIO 模型无能为力,因此我们需要一种更高效的 I/O 处理模型来应对更高的并发量。


文章转载自公众号:Java极客技术

标签
已于2023-9-28 10:41:57修改
收藏
回复
举报
回复
    相关推荐