NIO网络编程(一)—— BIO和NIO

NIO网络编程(一)—— BIO和NIO

什么是BIO

网络编程的基本模型是Client/Server模型,也就是两个进程之间通过网络相互通信,其中服务端提供位置信息(绑定的IP地址和监听端口),客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手建立连接,如果连接建立成功,双方就可以通过网络套接字(Socket)进行通信。
在基于传统同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket 负责发起连接操作。连接成功之后,双方通过输入和输出流进行同步阻塞式通信。

首先,下面这副图的通信模型图来熟悉BIO的服务端通信模型:采用BIO通信模型的服务端,通常由一个独立的接收线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行通信管理。这就是典型的一请求一应答通信模型。

在这里插入图片描述

但是可以想象到,当客户端并发访问量增加后,服务端的线程个数会急剧上升,而服务端的性能会不断下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。

伪异步IO

为了改进BIO模型的问题,一种通过线程池或者消息队列实现1个或者多个线程处理N个客户端的模型被提出,由于它的底层通信机制依然使用同步阻塞IO,所以被称为“伪异步”。这个模型的服务器通过一个线程池来处理多个客户端的请求接入,通过线程池可以灵活地调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程耗尽。

具体来说,当有新的客户端接入时,将客户端的Socket封装成一个任务投递到服务器后端的线程池中进行处理,线程池维护一个消息队列和N个活跃线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。(关于线程池的内容可以阅读《Java多线程(十二) 类比理解线程池 && ThreadPoolExecutor》

下面这个例子就是一个伪异步IO模型的服务端代码,他开辟了一个线程池来接收用户的连接请求。

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadPoolServer {
    static int port = 5555;
    ServerSocket serverSocket = null;
    
    class ThreadServer implements Runnable {
        Socket socket;

        public PrintWriter getWriter(Socket socket) throws IOException {
            OutputStream socketoutput = socket.getOutputStream();
            PrintWriter printWriter = new PrintWriter(socketoutput, true);
            return printWriter;
        }

        public BufferedReader getReader(Socket socket) throws IOException {
            InputStream socketinput = socket.getInputStream();
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socketinput));

            return bufferedReader;
        }

        public ThreadServer(Socket s) {
            this.socket = s;
        }

        @Override
        public void run() {
            System.out.println("已有客户端连接,地址:" + socket.getInetAddress() + " 端口号:" + socket.getPort());

            try {
                PrintWriter writer = this.getWriter(socket);
                BufferedReader reader = this.getReader(socket);
                String msg = null;
                msg = reader.readLine();
                while ((msg) != null) {
                    System.out.println(socket.getInetAddress() + " " + socket.getPort() + " 发来的消息:" + msg);
                    writer.println("server收到了: " + msg);
                    msg = reader.readLine();
                }
            } catch (IOException e) {

            } finally {
                try {
                    if (socket != null)
                        socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

        }
    }

    public ThreadPoolServer()
    {
        this.serverSocket = null;
    }

    public void server() throws IOException {

        try {
            serverSocket = new ServerSocket(port);
            Socket socket = null;
            ExecutorService executorService = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),50,120, TimeUnit.SECONDS,new ArrayBlockingQueue<Runnable>(10000));
            while(true)
            {
                System.out.println("---------");
                socket = serverSocket.accept();
                System.out.println("收到客户端请求 "+socket.getInetAddress());
                executorService.execute(new ThreadServer(socket));
            }
        }catch (IOException e) {

        }finally {
            if(serverSocket!=null)
                serverSocket.close();
        }
    }

    public static void main(String[] args) throws IOException {
        ThreadPoolServer s = new ThreadPoolServer();
        s.server();
    }
}

当调用OutputStream的write方法写输出流的时候,它将会被阻塞,直到所有要发送的字节全部写入完毕,或者发生异常。通过TCP的知识可以知道,当消息的接收方处理缓慢的时候,将不能及时地从TCP缓冲区读取数据,这将会导致发送方的TCPwindow size不断减小,直到为0,双方处于Keep-Alive状态,消息发送方将不能再向TCP缓冲区写入消息,这时如果采用的是同步阻塞IO,write操作将会被无限期阻塞,直到TCPwindow size大于0或者发生I/O异常。

不仅如此,读和写操作都是同步阻塞的,阻塞的时间取决于对方IO线程的处理速度和网络I/O的传输速度。本质上来讲,我们无法保证生产环境的网络状况和对端的应用程序能足够快,如果我们的应用程序依赖对方的处理速度,它的可靠性就非常差。

伪异步IO实际上仅仅是对之前IO线程模型的一个简单优化,它无法从根本上解决同步I/O导致的通信线程阻塞问题。

NIO

上面介绍了BIO以及伪异步IO模型,并且对他们的缺点和问题都做了分析,那么解决他们的阻塞问题,就需要NIO了。

NIO全称是no-blocking IO,非阻塞IO。服务器程序接收客户连接、客户程序建立与服务器的连接,以及服务器程序和客户程序收发数据的操作都可以按非阻塞的方式进行。服务器程序只需要创建一个线程,就能完成同时与多个客户通信的任务。

网络中的阻塞状态

进行远程通信时,在客户程序中,线程在以下情况可能进入阻塞状态:

  • 请求与服务器建立连接时,即当线程执行Socket的带参数的构造方法,或执行Socket的connect()方法时,会进入阻塞状态,直到连接成功,此线程才从Socket的构造方法或connect()方法返回。
  • 线程从Socket的输入流读入数据时,如果没有足够的数据,就会进入阻塞状态,直到读到了足够的数据,或者到达输入流的末尾,或者出现了异常,才从输入流的read)方法返回或异常中断。下面三种read函数可以用来确定输入流中有多少数据:

int read():只要输入流中有一个字节,就算足够。
int read(byte[] buff):只要输入流中的字节数日与参数buff数组的长度相同,就算足够。
String readLine():只要输入流中有一行字符串,就算足够。值得注意的是,InputStream类并没有readLine(方法,在过滤流BufferedReader类中才有此方法。

  • 线程向Socket的输出流写一批数据时,可能会进入阻塞状态,等到输出了所有的数据,或者出现异常,才从输出流的 write()方法返回或异常中断。

在服务器程序中,线程在以下情况下可能会进入阻塞状态:

  • 线程执行ServerSocket的accept()方法,等待客户的连接,直到接收到了客户连接,才从accept()方法返回。
  • 线程从 Socket的输入流读入数据时,如果输入流没有足够的数据,就会进入阻塞状态。
  • 线程向Socket的输出流写一批数据时,可能会进入阻塞状态,等到输出了所有的数据,或者出现异常,才从输出流的write()方法返回或异常中断。

由此可见,无论在服务器程序还是客户程序中,当通过Socket的输入流和输出流来读写数据时,都可能进入阻塞状态。这种可能出现阻塞的输入和输出操作被称为阻塞IO。与此对照,如果执行输入和输出操作时,不会发生阻塞,则称为非阻塞IO

三大组件

NIO的三大组件是缓冲区(Buffer)、通道(Channel)和选择器(Selector)。

缓冲区 Buffer

Buffer是一个对象,它包含一些要写入或者要读出的数据。在 NIO类库中加入Buffer对象,体现了新库与原I/O的一个重要区别。在面向流的IO中,可以将数据直接写入或者将数据直接读到Stream对象中。在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。

Buffer有以下几种,其中使用较多的是ByteBuffer

  • ByteBuffer
    • MappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
  • CharBuffer

在这里插入图片描述

通道 Channel

Channel是一个通道,它就像自来水管一样,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上移动,而通道可以用于读、写或者二者同时进行。

常见的Channel有以下四种,其中FileChannel主要用于文件传输,其余三种用于网络通信

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel
多路复用器Selector

多路复用器提供选择已经就绪的任务的能力。简单来讲,Selector会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel 的集合,进行后续的I/O操作。这也就意味着只需要一个线程负责Selector 的轮询,就可以接入成千上万的客户端,这确实是个非常巨大的进步。

在使用selector之前,处理socket连接有下面两种方式:

  1. 使用多线程技术,为每个连接分别开辟一个线程,分别去处理对应的socket连接(BIO)
  2. 使用线程池技术,让线程池中的线程去处理连接(伪异步IO)

在这里插入图片描述
在这里插入图片描述
这两种方式的缺点在上部分都有介绍,而引入selector作用就是配合一个线程来管理多个 channel(fileChannel因为是阻塞式的,所以无法使用selector),获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下(不会让一个线程吊死在一个channel上),当一个channel中没有执行任务时,可以去执行其他channel中的任务。适合连接数多,但流量较少的场景。若事件未就绪,selector会阻塞线程,直到 channel 发生了就绪事件。这些事件就绪后,select 方法就会返回这些事件交给 thread 来处理

在这里插入图片描述

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值