【我在拉勾训练营学技术】BIO、NIO、AIO你会用了吗

前言

文章内容输出来源:拉勾教育Java高薪训练营;

在面试中老是被问到说一下BIO、NIO、AIO。自己知道一个大概,但是说又说不清。刚好在训练营老师讲到了这部分,所以就整理下来。

概念

BIO/NIO/AIO 这些只是数据传输的输入输出流的一些形式而已。也就是说他们的本质就是输入输出流。只是存在同步异步,阻塞和非阻塞的问题。

同步异步

同步(synchronize)、异步(asychronize)是指应用程序和内核的交互而言的.

同步:指用户进程触发IO操作等待或者轮训的方式查看IO操作是否就绪。

举例:我们烧水,等待水烧开饮用。我们从开始烧水到水烧开,我们就一直等待或者过段时间来看下水烧开没有。这就是同步。

异步:当一个异步进程调用发出之后,调用者不会立刻得到结果。而是在调用发出之后,被调用者通过状态、通知来通知调用者,或者通过回调函数来处理这个调用。使用异步IO时,Java将IO读写委托给OS处理,需要将数据缓冲区地址和大小传给OS,OS需要支持异步IO操作

举例:我们烧水,用有提示的烧水壶。我们开始烧水后,就不用一直等待或者过段时间来查看了,我们可以做其他事情。当水烧好的时候,热水壶会给出提示,这个时候我们就知道水烧好了,可以饮用了。

阻塞非阻塞

阻塞和非阻塞是针对于进程访问数据的时候,根据 IO 操作的就绪状态来采取不同的方式。简单点说就是一种读写操作方法的实现方式. 阻塞方式下读取和写入将一直等待, 而非阻塞方式下,读取和写入方法会理解返回一个状态值.

扫描二维码关注公众号,回复: 11719439 查看本文章

我们还是烧水这个例子:

我烧水,一直在旁边候着,等待水烧开。这个就是同步阻塞。因为这段时间,我就做这一件事情。

我烧水,时不时过来看看,看烧开没。这个就是 同步非阻塞 。因为我只需过段时间来看下就可以了,这期间可以做其他事情。

我烧水,用烧水壶,不用自己来看。但是我也不做其他事,就在旁边候着。这个就是 异步阻塞

我烧水,用烧水壶,不用自己来看。我做其他事情去了,等水好了通知我就行。这个就是 异步非阻塞

BIO

上面我们知道了同步异步,阻塞非阻塞的概念,我们接下来就来分别看看 BIO、NIO、AIO 到底是啥。

BIO:同步阻塞的IO。B 为 blocking。‘’

也就是说,每一个请求请求对应一个线程。每个线程都会等待请求输入的 IO流。如果没有接收到,就一直处于阻塞的状态。知道获取到 IO 流就进行处理。

我们可以写一个例子。就最简单的socket 。同步阻塞方式。

我们创建一个服务端,在服务端有一下几步操作。

1、创建一个 ServerSocket。

2、死循环来等客户端发送的请求信息。

3、解析接收的信息。

4、发送返回信息。

代码如下:


public class Server {


    public static void main(String[] args) {


        try {
            ServerSocket socket = new ServerSocket(8080);

            while(true){
                System.out.println("waiting......");
                Socket accept = socket.accept();//同步阻塞
                System.out.println("beging......");
                new Thread(()->{
                    byte[] bytes = new byte[1024];
                    try {
                        int len = accept.getInputStream().read(bytes);//同步阻塞
                        String cilentMessage=new String(bytes,0,len);
                        System.out.println(cilentMessage);
                        String backMessage="server accepted message successful. message is "+cilentMessage;
                        accept.getOutputStream().write(backMessage.getBytes());
                        accept.getOutputStream().flush();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

接下来我们来写一个客户端。客户端做这几件事。

1、持续读取控制台输入的信息,每次读取一行。

2、创建 socket 连接,连接到服务端。

3、向服务端发送消息。

4、接收服务端发送的消息。

5、解析服务端发送的消息。

6、关闭 socket 连接。

代码如下:


public class Client {


    public static void main(String[] args) {

        try {
            System.out.println("client begin......");
            Scanner scanner = new Scanner(System.in);
            while (scanner.hasNext()){
                Socket socket = new Socket("127.0.0.1",8080);
                String message=scanner.nextLine();
                socket.getOutputStream().write(message.getBytes());
                socket.getOutputStream().flush();
                System.out.println("server back message......");
                byte[] bytes = new byte[1024];
                int len = socket.getInputStream().read(bytes);
                String backMessage=new String(bytes,0,len);
                System.out.println(backMessage);
                socket.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

分别启动服务端和客户端,测试效果如下:

image-20200713113231211

BIO 是一种最简单的IO交互方式。实现方式也很简单,就是同步阻塞,就是当没有收到消息时,就会一直处于等待状态,不会做其他事情。所以效率不高,一般项目中不会使用。

NIO

NIO :同步非阻塞的IO (no-blocking IO) .

服务器实现模式为一个请求一个通道,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有IO请求时才启动一个线程进行处理。

所以我们先来了解一些基本概念:

通道

NIO 引入的最重要的概念就是通道,Channel(通道)是指数据通道,也就是数据传输的通道。 数据可以从 buffer 中读取到 channel 中,也可以冲channel 中读取到 buffer 中。

缓冲区

buffer 缓存中像是一个缓存,暂时的存放数据,数据可以从 buffer 中读取到 channel 中,也可以冲channel 中读取到 buffer 中。

选择器

选择器是线程与通道之间的桥梁。使用选择器,借助单线程就可以对众多的 IO 通道进行监控和维护。

那 NIO 有什么特点呢?

我们上面可以看到,一个线程就可以处理多个通道。一个通道对应一个连接,所以当创建一个连接,都会注册到多路复用器,也就是 selector 。然后selector 通过轮寻的方式,每个一段时间执行有传输数据的通道。

有一个例子:就是幼儿园每个小孩上厕所都需要老师陪同。NIO就是,为整个幼儿园分配一个老师,老师会定期的询问,想要上厕所的孩子举手,然后统一带领这些孩子去上厕所。这样就避免了每个孩子上厕所,老师都要去一趟。通过多路复用,简化流程。

我们现在也手写一个NIO的例子,一样的分为客户端和服务端。我们先写服务端。

服务端

服务端主要做如下操作:

1、声明多路复用器

2、定义读写缓存区

3、编写初始化方法

4、编写启动类

5、编写执行方法

初始化 init 的方法中主要是

1、开启多路复用器

2、开启通道

3、设置非阻塞

4、绑定端口

5、注册通道

整体代码如下:


public class NIOServer extends Thread{

    //1.声明多路复用器
    private Selector selector;
    //2.定义读写缓冲区
    private ByteBuffer readBuffer = ByteBuffer.allocate(1024);
    private ByteBuffer writeBuffer = ByteBuffer.allocate(1024);

    //3.定义构造方法初始化端口
    public NIOServer(int port) {
        init(port);
    }

    //4.main方法启动线程
    public static void main(String[] args) {
        new Thread(new  NIOServer(8888)).start();
    }

    //5.初始化
    private void init(int port) {

        try {
            System.out.println("服务器正在启动......");
            //1)开启多路复用器
            this.selector = Selector.open();
            //2) 开启服务通道
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            //3)设置为非阻塞
            serverSocketChannel.configureBlocking(false);
            //4)绑定端口
            serverSocketChannel.bind(new InetSocketAddress(port));
            /**
             * SelectionKey.OP_ACCEPT   —— 接收连接继续事件,表示服务器监听到了客户连接,服务器可以接收这个连接了
             * SelectionKey.OP_CONNECT  —— 连接就绪事件,表示客户与服务器的连接已经建立成功
             * SelectionKey.OP_READ     —— 读就绪事件,表示通道中已经有了可读的数据,可以执行读操作了(通道目前有数据,可以进行读操作了)
             * SelectionKey.OP_WRITE    —— 写就绪事件,表示已经可以向通道写数据了(通道目前可以用于写操作)
             */
            //5)注册,标记服务连接状态为ACCEPT状态
            serverSocketChannel.register(this.selector, SelectionKey.OP_ACCEPT);
            System.out.println("服务器启动完毕");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void run(){
        while (true){
            try {
                //1.当有至少一个通道被选中,执行此方法
                this.selector.select();
                //2.获取选中的通道编号集合
                Iterator<SelectionKey> keys = this.selector.selectedKeys().iterator();
                //3.遍历keys
                while (keys.hasNext()) {
                    SelectionKey key = keys.next();
                    //4.当前key需要从动刀集合中移出,如果不移出,下次循环会执行对应的逻辑,造成业务错乱
                    keys.remove();
                    //5.判断通道是否有效
                    if (key.isValid()) {
                        try {
                            //6.判断是否可以连接
                            if (key.isAcceptable()) {
                                accept(key);
                            }
                        } catch (CancelledKeyException e) {
                            //出现异常断开连接
                            key.cancel();
                        }

                        try {
                            //7.判断是否可读
                            if (key.isReadable()) {
                                read(key);
                            }
                        } catch (CancelledKeyException e) {
                            //出现异常断开连接
                            key.cancel();
                        }

                        try {
                            //8.判断是否可写
                            if (key.isWritable()) {
                                write(key);
                            }
                        } catch (CancelledKeyException e) {
                            //出现异常断开连接
                            key.cancel();
                        }
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void accept(SelectionKey key) {
        try {
            //1.当前通道在init方法中注册到了selector中的ServerSocketChannel
            ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
            //2.阻塞方法, 客户端发起后请求返回.
            SocketChannel channel = serverSocketChannel.accept();
            ///3.serverSocketChannel设置为非阻塞
            channel.configureBlocking(false);
            //4.设置对应客户端的通道标记,设置次通道为可读时使用
            channel.register(this.selector, SelectionKey.OP_READ);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //使用通道读取数据
    private void read(SelectionKey key) {
        try{
            //清空缓存
            this.readBuffer.clear();
            //获取当前通道对象
            SocketChannel channel = (SocketChannel) key.channel();
            //将通道的数据(客户发送的data)读到缓存中.
            int readLen = channel.read(readBuffer);
            //如果通道中没有数据
            if(readLen == -1 ){
                //关闭通道
                key.channel().close();
                //关闭连接
                key.cancel();
                return;
            }
            //Buffer中有游标,游标不会重置,需要我们调用flip重置. 否则读取不一致
            this.readBuffer.flip();
            //创建有效字节长度数组
            byte[] bytes = new byte[readBuffer.remaining()];
            //读取buffer中数据保存在字节数组
            readBuffer.get(bytes);
            System.out.println("收到了从客户端 "+ channel.getRemoteAddress() + " :  "+ new String(bytes,"UTF-8"));
            //注册通道,标记为写操作
            channel.register(this.selector,SelectionKey.OP_WRITE);

        }catch (Exception e){

        }
    }

    //给通道中写操作
    private void write(SelectionKey key) {
        //清空缓存
        this.readBuffer.clear();
        //获取当前通道对象
        SocketChannel channel = (SocketChannel) key.channel();
        //录入数据
        Scanner scanner = new Scanner(System.in);

        try {
            System.out.println("即将发送数据到客户端..");
            String line = scanner.nextLine();
            //把录入的数据写到Buffer中
            writeBuffer.put(line.getBytes("UTF-8"));
            //重置缓存游标
            writeBuffer.flip();
            channel.write(writeBuffer);
            channel.register(this.selector,SelectionKey.OP_READ);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在执行方法run 方法中,获取选中的通道编号,遍历判断,状态是否为可接受、可读或者可写。分别进行不同的操作。

客户端

客户端比较简单。

1、开启通道

2、连接到服务器

3、向通道中写入数据

4、解析通道中的数据。


public class NIOClient {
    public static void main(String[] args) {
        //创建远程地址
        InetSocketAddress address  = new InetSocketAddress("127.0.0.1",8888);
        SocketChannel channel = null;
        //定义缓存
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        try {
            //开启通道
              channel = SocketChannel.open();
            //连接远程远程服务器
            channel.connect(address);
            Scanner sc = new Scanner(System.in);
            while (true){
                System.out.println("客户端即将给 服务器发送数据..");
                String line = sc.nextLine();
                if(line.equals("exit")){
                    break;
                }
                //控制台输入数据写到缓存
                buffer.put(line.getBytes("UTF-8"));
                //重置buffer 游标
                buffer.flip();
                //数据发送到数据
                channel.write(buffer);
                //清空缓存数据
                buffer.clear();

                //读取服务器返回的数据
                int readLen = channel.read(buffer);
                if(readLen == -1){
                    break;
                }
                //重置buffer游标
                buffer.flip();
                byte[] bytes = new byte[buffer.remaining()];
                //读取数据到字节数组
                buffer.get(bytes);
                System.out.println("收到了服务器发送的数据 : "+ new String(bytes,"UTF-8"));
                buffer.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != channel){
                try {
                    channel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

测试

服务端接收到客户端的消息,并想客户端发送消息。

客户端:向服务端发送消息,接收服务器发送的消息。

AIO

异步非阻塞IO。A代表asynchronize

当有流可以读时,操作系统会将可以读的流传入read方法的缓冲区,并通知应用程序,对于写操作,OS将write方法的流写入完毕是操作系统会主动通知应用程序。因此read和write都是异步 的,完成后会调用回调函数。

使用场景:连接数目多且连接比较长(重操作)的架构,比如相册服务器。重点调用了OS参与并发操作,编程较复杂。

我们一样的来创建一个例子。和AIO 主要的不同还是异步非阻塞,也就是通过子线程进行读写。

服务端

服务端我们要做如下几件事。

1、声明多路复用选择器。

2、定义读写缓冲区

3、初始化选择器和通道

4、主线程启动。

我们先写一个 ServerMain 作为启动类。

public class ServerMain {

    //定义一个选择器
    private Selector selector;


    /**
     * 在初始化中要做一下如下操作:
     * 1、开启多路复用器
     * 2、开启服务通道
     * 3、设置为非阻塞
     * 4、绑定端口
     * 5、标记选择器状态为可接受,表示可以接受通道注册到选择器上。
     */
    private void init() {
        try {
            System.out.println("init......");
            //开启多路复用器
            selector = Selector.open();
            //开启通道
            ServerSocketChannel channel = ServerSocketChannel.open();
            //设置为非阻塞
            channel.configureBlocking(false);
            //绑定端口
            channel.bind(new InetSocketAddress(8080));
            //标记选择器状态为可接受
            /**
             * SelectionKey.OP_ACCEPT   —— 接收连接继续事件,表示服务器监听到了客户连接,服务器可以接收这个连接了
             * SelectionKey.OP_CONNECT  —— 连接就绪事件,表示客户与服务器的连接已经建立成功
             * SelectionKey.OP_READ     —— 读就绪事件,表示通道中已经有了可读的数据,可以执行读操作了(通道目前有数据,可以进行读操作了)
             * SelectionKey.OP_WRITE    —— 写就绪事件,表示已经可以向通道写数据了(通道目前可以用于写操作)
             */
            channel.register(selector, SelectionKey.OP_ACCEPT);

            System.out.println("init finished......");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //定义启动方法。

    public static void main(String[] args) {
        ServerMain main = new ServerMain();
        main.init();
        main.process();
    }

    private void process(){
        //轮寻
        while(true){
            try {
                //每隔两秒中就轮寻一次选择器。
                Thread.sleep(2*1000);
                //通道选中的个数,至少有一个通道被选中才会执行。
                int count = selector.select();
                System.out.println("channel count is "+count);

                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while(iterator.hasNext()){
                    //获取key
                    SelectionKey key = iterator.next();
                    //从迭代器中取出这个key
                    iterator.remove();
                    new Thread(new Server(selector,key)).start();
                }

            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

在main 方法种做了两件事;初始化和创建子线程。

初始化

1、开启多路复用器
2、开启服务通道
3、设置为非阻塞
4、绑定端口
5、标记选择器状态为可接受,表示可以接受通道注册到选择器上。

创建子线程

1、每隔2s进行轮寻,从选择器中获取通道数量。
2、获取选中的通道编号集合
3、遍历
4、从迭代器中删除当前key
5、创建子线程

子线程才是真正进行 接收,读写操作的。我们创建一个Server 类如下:

public class Server extends Thread{

    //定义一个选择器
    private Selector selector;

    //定义读写缓冲区
    //定义读写缓冲区
    private ByteBuffer readBuffer=ByteBuffer.allocate(1024);
    private ByteBuffer writeBuffer=ByteBuffer.allocate(1024);

    private SelectionKey key;

    public Server(Selector selector,SelectionKey key){
        this.selector=selector;
        this.key=key;
    }

    @Override
    public void run() {
        try {
            process();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    private void process() throws Exception {
        try{
            //判断key 是否有效
            if (key.isValid()) {
                //如果可以接受
                if (key.isAcceptable()) {
                    System.out.println(Thread.currentThread().getName()+"accept");
                    accept(key);
                }

                //如果可读
                if(key.isReadable()){
                    System.out.println(Thread.currentThread().getName()+"read");
                    read(key);
                }

                //如果可写
                if(key.isWritable()){
                    System.out.println(Thread.currentThread().getName()+"write");
                    write(key);
                }
            }
        }catch (CancelledKeyException e){
            key.cancel();
        }
    }

    /**
     *给通道中写数据。从buffer 中给通道写数据。
     * @param key
     */
    private void write(SelectionKey key) throws IOException {
        writeBuffer.clear();

        SocketChannel channel = (SocketChannel) key.channel();

        String message = channel.toString();
        //写入缓冲区
        writeBuffer.put(message.getBytes("UTF-8"));
        //重置缓存游标
        writeBuffer.flip();
        //从缓冲区写入通道
        channel.write(writeBuffer);


        //重新标记为可读
        channel.register(selector,SelectionKey.OP_READ);
    }

    /**
     *使用通道读取数据。主要就是将通道中的数据读取到读缓存中。
     * @param key
     */
    private void read(SelectionKey key) throws IOException {
        readBuffer.clear();

        SocketChannel channel = (SocketChannel)key.channel();

        int len = channel.read(readBuffer);

        //如果通道没有数据
        if(len==-1){
            //关闭通道
            key.channel().close();
            //关闭key
            key.cancel();
            return;
        }

        //Buffer中有游标,游标不会重置,需要我们调用flip重置. 否则读取不一致
        readBuffer.flip();
        //创建有效字节长度数组
        byte[] bytes = new byte[readBuffer.remaining()];
        //读取buffer中数据保存在字节数组
        readBuffer.get(bytes);

        String clientMessage = new String(bytes, "UTF-8");
        System.out.println("accepted client message are "+clientMessage);

        //注册通道,标记为写操作
        channel.register(selector,SelectionKey.OP_WRITE);
    }

    /**
     *设置通道接受客户端数据,并设置通道为可读。
     * @param key
     */
    private void accept(SelectionKey key) throws IOException {
        //1.获取通道
        ServerSocketChannel socketChannel = (ServerSocketChannel) key.channel();
        //阻塞方法,获取客户端的请求
        SocketChannel channel = socketChannel.accept();

        //设置为非阻塞
        channel.configureBlocking(false);
        //设置对应客户端的通道标记,设置次通道为可读时使用
        channel.register(selector,SelectionKey.OP_READ);
    }

}

这里设置了 读写缓存区,从父类中获取 选择器 和key 。通过key 判断当前通道的状态是可接收,可读还是可写。分别对应不用的方法。

可读操作,就是将 channel 中的数据读入到 buffer 中。

可写操作,将 buffer 中的数据读入到 channel 中,返回给客户端。

客户端

客户端比较简单,主要做以下几件事,

1、开启一个通道

2、连接到服务器

3、向通道中写入数据

4、接收通道返回的数据。

我们写一个client 类。

public class Client extends Thread{

    private int index;


    public Client(int index){
        this.index=index;
    }

    @Override
    public void run() {
        process(index);
    }

    public void process(int i) {

        InetSocketAddress inetSocketAddress = new InetSocketAddress(8080);

        ByteBuffer buffer = ByteBuffer.allocate(1024);

        //开启通道
        try (SocketChannel channel = SocketChannel.open();){
            //连接到远程服务器
            channel.connect(inetSocketAddress);

            buffer.clear();
            String meaage = "client send message...."+i;
            //写入缓存中
            buffer.put(meaage.getBytes("UTF-8"));
            buffer.flip();
            //写入通道中
            channel.write(buffer);

            buffer.clear();

            //读取服务端的数据
            int len = channel.read(buffer);
            if(len==-1){
                return;
            }

            buffer.flip();
            byte[] bytes = new byte[buffer.remaining()];

            buffer.get(bytes);
            String serverMessage = new String(bytes, "UTF-8");
            System.out.println(i+" accepted message "+serverMessage);

        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}

我们再来写一个主类,用来模拟生成度多个通道同时调用效果。

public class ClientMain {

    public static void main(String[] args) {

        for(int i=0;i<10;i++){
            if(i%3==0){
                try {
                    Thread.sleep(1000*1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            new Client(i).start();
        }

    }
}

测试

好了,我们现在来启动测试下,先启动服务端。

启动后,没有通道就会处于阻塞状态。

接着我们启动客户端。


这个时候,服务端就可以接收到通道,然后就两秒钟获取一次,进行操作。

客户端的收到的服务端返回的信息。

总结

感兴趣的小伙伴可以动手实践下,印象更深哟

猜你喜欢

转载自blog.csdn.net/qq_27790011/article/details/108624519