【Java NIO】网络IO模型与Java中的NIO之间的联系

目录

1 前言

2 五种网络IO模型

2.1 阻塞IO模型(BIO)

2.2 非阻塞IO(NIO)

2.3 多路复用IO模型

2.4 异步IO(AIO)

2.5 信号驱动IO模型

3 Java NIO

3.1 channel通道

3.2 Buffer

3.3 selector

3.4 SelectionKey

4 Java NIO与IO的区别

4.1 面向流与面向缓冲区

4.2 阻塞与非阻塞IO

5 IO模型实现(使用socket)

5.1 实现阻塞IO模型

5.2 实现非阻塞IO模型

5.3 实现多路复用IO模型


1 前言

学习Java NIO这个知识点花费了几天时间,通过阅读大量的译文和博客文章,终于有点头绪了,虽说不是深入了解,但好歹是明白BIO、NIO、AIO这几种模型之间的区别,附带着了解了Java NIO的相关类源码,也算是有点收获。这里总结一下自己所了解的IO模型,以及它们在Java NIO中的应用。

2 五种网络IO模型

以linux系统为例,分别是阻塞IO模型(BIO)、非阻塞IO模型(NIO)、多路复用IO模型、异步IO模型(AIO)、信号驱动IO模型。对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:

  1. 等待数据在内核中准备就绪
  2. 将数据从内核拷贝到用户进程中

2.1 阻塞IO模型(BIO)

当进程发起read请求后,产生了一条系统调用:recvfrom,之后进程便陷入了阻塞状态直到内核return OK。内核中经历了 数据未就绪——>数据准备就绪——>将数据从内核拷贝到进程空间中——>return ok 这个过程,所以,阻塞IO模型的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。同理,进程发起的write请求也会阻塞,直到所有的数据都被写入到内核中。

在阻塞IO模型下,socket通信的server同一时刻只能处理一个客户端的连接请求,在多用户连接的场景下会导致其他用户连接等待,所以都会采用“一连接一线程”的方式来处理,server每accept一个connect,就会new一个线程去单独处理这个连接。为了避免过多的创建线程(线程会占用系统资源),可以使用线程池去管理,复用已创建的线程资源,减少线程的创建个数。

阻塞IO模式中,创建的用来处理accept连接的线程既要判断当前socket是否有数据可以读,也要负责数据的读写,所以线程创建后不管是否进行读写,都不能关闭,因为关闭了连接就没了。阻塞IO模型下,“因为创建太多线程而导致的频繁的上下文切换”这个缺点不存在,因为线程都是通过socket.read()来读数据,没有数据可读的时候就会阻塞,而阻塞的线程是不会被cpu执行的,上下文的切换(由一个线程切换到另一个线程)只涉及状态为“就绪”和“运行”的线程;当有数据可读时,阻塞线程会变成“就绪”状态,等待cpu进行上下文切换,而有数据读的话肯定就要切换啊,这个切换就不是资源浪费(缺点)啊。如果是NIO中的非阻塞模式来这么搞,“因为创建太多线程而导致的频繁的上下文切换”就是个实打实的缺点,因为socket.read()立刻返回,不会阻塞,所以线程就一直是就绪(如执行时间片用完)或运行状态,那就会导致频繁的上下文切换,而很多时候线程没有数据可读,但还是会切换到它,那这个切换就是白搞了,没用,这就会拖慢系统运行的效率,是个很大的缺点。

2.2 非阻塞IO(NIO

当进程发起read请求后,若内核中数据未准备就绪,不会阻塞。内核会返回一个标志,进程通过这个标志可以知道数据未就绪,从而可以做其他事情,在后续操作中,进程可以不断地发起read请求来判断数据是否就绪。若内核中数据就绪,进程会阻塞在本次read请求操作上,直到将数据从内核复制到进程空间中并return OK。write请求也类似,当进程执行write操作时,操作会立刻返回,可以通过内核返回的标志判断数据是否完全写入。

所以,该模型在等待数据就绪阶段是非阻塞的,在数据从内核复制到进程中时是阻塞的。非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。

在非阻塞IO模型中,可以只用一个线程(创建一个server线程之外的线程)来管理多个连接,即一个server线程在accept多个连接后,循环对这些连接执行read操作,若当前遍历连接数据已就绪,则读取数据(此时这个线程会被阻塞),然后继续遍历。但这种模式决不被推荐,因为,循环调用recv()将大幅度推高CPU 占用率;此外,在这个方案中recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如select()多路复用模式,可以一次检测多个连接是否活跃。

2.3 多路复用IO模型

它的基本原理就是Linux中select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。从图中可以看到,这次不需要我们主动执行read操作了,而是进程首先调用select函数,调用select之后进程会阻塞,直到内核中数据准备就绪返回标志通知进程,此时进程从阻塞中恢复,直接执行read操作读取数据,阻塞直到return OK。

多路复用IO模型在检测连接数据是否就绪和数据复制过程中都会阻塞。

显然,多路复用IO模型比非阻塞IO模型更适合一个线程管理多个连接的场景,因为使用select函数来判断数据是否准备就绪比主动执行recvfrom来轮训具有更好的性能,判断直接发生在内核空间,省去了大量的recvfrom系统调用。

系统调用:当操作系统接收到系统调用请求后,会让处理器进入内核模式,从而执行诸如I/O操作,修改基址寄存器内容等指令,而当处理完系统调用内容后,操作系统会让处理器返回用户模式,来执行用户代码。

注意

  1. 如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
  2. 该模型将数据探测和事件相应夹杂在一起,一旦某个连接的事件执行体庞大,则会大大推迟其它连接的数据探测和事件执行。

2.4 异步IO(AIO)

BIO、NIO和多路复用IO都属于synchronized IO模型,因为不管怎么样,进程都会被阻塞。

从图中可以看到,当进程请求一个aio_read系统调用后就直接返回,接下来在内核中自动完成数据的准备的复制工作,之后内核给进程发送一个信号,通知它数据准备完毕,然后进程执行read操作读取即可。

异步模型全程非阻塞,数据的准备工作都在内核中异步完成。

异步IO是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。

2.5 信号驱动IO模型

信号驱动IO,调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。

3 Java NIO

3.1 channel通道

通道类似于IO中的流(stream),但又有区别:

  • 流是单向的,read要建立inputstream,write要建立outputstream;而channel是双向的,只需要建立一次,既能read也能write。
  • 流读写时的数据可以不存放在缓冲区中(使用BufferedInputStream),而通道读写时的数据必须存放在缓冲区中(Buffer)。
  • 使用流进行读写时,线程会阻塞直到读写操作完成;通道可以进行非阻塞的IO,channel.read(ByteBuffer)或channel.write(ByteBuffer)之后会立即返回,若缓冲区中没有可使用的数据,线程能做其它事情,不会阻塞。

channel的具体实现主要有:

  • FileChannel:从文件中读写数据,不能切换为非阻塞模式
  • DatagramChannel:能通过UDP读写网络中的数据。
  • SocketChannel:能通过TCP读写网络中的数据。
  • ServerSocketChannel:用来监听新进来的TCP连接,向web服务器一样,对每一个新来的连接都会创建一个SocketChannel。

channel使用示例,使用FileChannel读取数据到Buffer中:

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();
ByteBuffer buf = ByteBuffer.allocate(48);

int bytesRead = inChannel.read(buf);

while (bytesRead != -1) {               // -1:表示读到了文件的末尾,其它:表示读到的字节个数

    System.out.println("Read " + bytesRead);
    buf.flip();                       //  切换到读模式(见下)          

    while(buf.hasRemaining()){                // hasRemaining()表示Buffer中是否还是数据可读
        System.out.print((char) buf.get());       // get()是从Buffer中读取一个字节
    }

    buf.clear();                            // 当Buffer中的数据读完后就清空Buffer
    bytesRead = inChannel.read(buf);        // 继续向通道中读取数据
}
aFile.close();

flip()是读写模式的切换,主要是对Buffer中position和limit的调整,hasRemaining()判断是否已经读(写)的最后。

  • position:在Buffer中read或write操作开始的位置。
  • limit:read模式下是最后一个可读数据的位置,write模式下是最后一个未写入数据的位置。
    public final Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }

    public final boolean hasRemaining() {
        return position < limit;
    }

channel使用示例,使用SocketChannel写数据。因为Write()方法无法保证能写多少字节到SocketChannel。所以,我们重复调用write()直到Buffer没有要写的字节为止。

SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));

ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());

buf.flip();

while(buf.hasRemaining()) {
    channel.write(buf);
}

3.2 Buffer

Buffer就是一个基本类型的数组,加上操作数组的一组方法的集合体。

Java NIO中有以下Buffer类型:

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

Buffer的基本用法一般有一下四个步骤:

  • 写入数据到Buffer:channel.read(Buffer)或Buffer.put()
  • 调用flip()方法:切换到read模式(移动limit和position)
  • 从Buffer读取数据:while(Buffer.hasRemaining()) Buffer.get();
  • 清空Buffer:读取完了Buffer中所有数据之后一定要清空(clear()或compact()),以便让Buffer可以再次被写入。

Buffer中的capacity,position,limit:

  • capacity:就是定义Buffer数组的长度。
  • position:在Buffer中read或write操作开始的位置。
  • limit:read模式下是最后一个可读数据的位置,write模式下是最后一个未写入数据的位置。

3.3 selector

selector(选择器)能够检测一到多个通道(SocketChannel或ServerSocketChannel),并且能够直到通道是否为某些事件(见下)做好了准备。这样一来,一个单独的线程也可以管理多个channel,从而管理多个网络连接。

首先明确一点,单线程使用一个selector可以管理多个通道。这就避免了一个连接建立一个线程,能减少系统资源的消耗和频繁的线程切换。而传统的阻塞IO模型必须一个socket建立一个线程来处理,因为通过流进行read和write都会导致当前调用方法的线程阻塞,阻塞后自然就不能管理其它socket了。基于阻塞IO模型的聊天室应用见:https://www.cnblogs.com/csu-lmw/p/9709782.html

通道注册

selector和channel配合使用,必须将channel注册到selector上,如下:

channel.configureBlocking(false);
SelectionKey key = channel.register(selector, Selectionkey.OP_READ);

register()方法的第二个参数是一个集合,表示selector对该注册channel的什么事件进行监听(即时通讯场景主要对read和write进行监听),共有四种类型的事件:

  • SelectionKey.OP_CONNECT:连接就绪,当某个channel成功连接到另一个服务器时触发(SocketChannel)。
  • SelectionKey.OP_ACCEPT:接受就绪,当ServerSocketChannel准备好接收新进入的连接时触发(ServerSocketChannel)。
  • SelectionKey.OP_READ:读就绪,当一个通道有数据可读时触发(SocketChannel)。
  • SelectionKey.OP_WRITE:写就绪,等待写数据的通道成为“写就绪”(SocketChannel)。

获取注册事件已经就绪的通道的数量

通过selector的select()就能获取在注册事件上就绪的channel的数量,主要有下面3个方法:

  • int select():阻塞直到至少有一个channel在注册事件上就绪,返回就绪channel的数量。
  • int select(long timeout):最长阻塞timeout毫秒,和select()一样。
  • int selectNow():不会阻塞,立即返回当前已就绪的通道数量,没有就是0。

获取注册事件已经就绪的通道

一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道。如下所示:(SelectionKey见下一节)

Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
while(true) {
  int readyChannels = selector.select();        // 获取就绪通道数量
  if(readyChannels == 0) continue;
  Set selectedKeys = selector.selectedKeys();
  Iterator keyIterator = selectedKeys.iterator();
  while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading.此处创建一个新线程来处理
    } else if (key.isWritable()) {
        // a channel is ready for writing.此处创建一个新线程来处理
    }
    keyIterator.remove();        // 将已处理的channel(SelectionKey)移除
  }
}

3.4 SelectionKey

当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。每个SelectionKey都对应一个channel,通过它能获取以下信息:

  • interest集合:对应channel注册的事件,包括connect,accept,read,write。可以看到interestSet 就是一个int值,它的二进制不同位的值代表了注册的事件。
int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;


public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;
  • read集合:channel注册的感兴趣的事件中已经就绪的事件。可以像检测interest集合一样检测readySet中哪些事件已经就绪,也可以调用下面的方法来判断。
int readySet = selectionKey.readyOps();

selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
  • channel:selectionKey所属的channel。
Channel  channel  = selectionKey.channel();
  • selector:channel注册的selector。
Selector selector = selectionKey.selector();

4 Java NIO与IO的区别

IONIO
面向流面向缓冲区
阻塞IO非阻塞IO
无选择器有选择器

4.1 面向流与面向缓冲区

 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。

Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。

4.2 阻塞与非阻塞IO

参考上面提到的阻塞IO模型与非阻塞IO模型。

Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。

Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。

5 IO模型实现(使用socket)

Java NIO是阻塞IO模型、非阻塞IO模型和多路复用IO模型的组合体,可以单独实现这三种模型中的任何一种,使用最多的是多路复用IO模型。使用的组件包括channel、buffer和selector,这里不介绍了。下面简单使用Java NIO代码体现这三种模型的socket通信。

5.1 实现阻塞IO模型

显然,如同Java IO一样,这里socketChannel.read(buf)会阻塞直到内核空间中数据准备和复制完毕,然后将数据读到缓冲区。

// client创建通道并建立连接
SocketChannel socketChannel = SocketChannel.open();
// #############这里配置非阻塞模式###################
socketChannel.configureBlocking(true);
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8888));
// 创建缓冲区并从通道中读数据到缓冲区中
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);        // bytesRead为读到的字节个数
byte[] bytes = new byte[Integer.MAX_VALUE];

// bytesRead = -1表示读到了尾部
while (bytesRead != -1){
    if (bytesRead == 0){
        // 没有数据可读
        // do something else
    }

    // 从缓冲区中取出读到的数据
    buf.flip();
    buf.get(bytes);
    buf.clear();
    bytesRead = socketChannel.read(buf);
}
// 打印结果
System.out.println(new String(bytes));

socketChannel.close();

5.2 实现非阻塞IO模型

通过socketChannel.configureBlocking(false);可以将通道设置为非阻塞模式,该模式下read方法执行候会直接返回,通过返回的int值可以判断是否有数据可读(0)、读到了多少字节数据(>0)、是否读完了数据(-1)。

// client创建通道并建立连接
SocketChannel socketChannel = SocketChannel.open();
// #############这里配置非阻塞模式###################
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8888));
// 创建缓冲区并从通道中读数据到缓冲区中
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);        // bytesRead为读到的字节个数
byte[] bytes = new byte[Integer.MAX_VALUE];

// bytesRead = -1表示读到了尾部
while (bytesRead != -1){
    if (bytesRead == 0){
        // 没有数据可读
        // do something else
    }

    // 从缓冲区中取出读到的数据
    buf.flip();
    buf.get(bytes);
    buf.clear();
    bytesRead = socketChannel.read(buf);
}
// 打印结果
System.out.println(new String(bytes));

socketChannel.close();

5.3 实现多路复用IO模型

通过selector选择器,可以让一个server线程管理大量建立的连接,阅读下面代码你就会发现设计的精妙之处。

public class Server implements Runnable{
    //1 多路复用器(管理所有的通道)
    private Selector seletor;
    //2 建立缓冲区
    private ByteBuffer readBuf = ByteBuffer.allocate(1024);
    private ByteBuffer writeBuf = ByteBuffer.allocate(1024);
    public Server(int port){
        try {
            //1 打开路复用器
            this.seletor = Selector.open();
            //2 打开服务器通道
            ServerSocketChannel ssc = ServerSocketChannel.open();
            //3 设置服务器通道为非阻塞模式
            ssc.configureBlocking(false);
            //4 绑定地址
            ssc.bind(new InetSocketAddress(port));
            //5 把服务器通道注册到多路复用器上,并且监听一个或多个事件
            //  SelectionKey.OP_CONNECT
						//	SelectionKey.OP_ACCEPT
						//	SelectionKey.OP_READ
						//	SelectionKey.OP_WRITE
            ssc.register(this.seletor, SelectionKey.OP_READ | SelectionKey.OP_WRITE);

            System.out.println("Server start, port :" + port);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void run() {
        while(true){
            try {
                //1 让多路复用器开始监听上面注册的事件(SelectionKey.OP_ACCEPT),select()阻塞到至少有一个通道在你注册的事件上就绪了
                // 当然也有select(long)和selectNow(),可以指定阻塞时间和不阻塞
                this.seletor.select();
                //2 获取所有就绪的通道
                Iterator<SelectionKey> keys = this.seletor.selectedKeys().iterator();
                //3 遍历所有通道
                while(keys.hasNext()){
                    //4 获取通道
                    SelectionKey key = keys.next();
                    //5 直接从容器中移除该通道
                    keys.remove();
                    //6 如果通道没有被cancel或close,进行事件检测(检测你上面注册时监听的事件)
                    if(key.isValid()){
                        //7 如果一个连接被服务器通道接受
                        if(key.isAcceptable()){
                            this.accept(key);
                        }
                        //8 如果该通道读就绪
                        if(key.isReadable()){
                            this.read(key);
                        }
                        //9 如果该通道写就绪
                        if(key.isWritable()){
                            this.write(key); 
                        }
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void write(SelectionKey key){}

    private void read(SelectionKey key) {
        try {
            //1 清空缓冲区旧的数据
            this.readBuf.clear();
            //2 获取之前注册的socket通道对象
            SocketChannel sc = (SocketChannel) key.channel();
            //3 读取数据
            int count = sc.read(this.readBuf);
            //4 如果没有数据
            if(count == -1){
                key.channel().close();
                key.cancel();
                return;
            }
            //5 有数据则进行读取 读取之前需要进行复位方法(把position 和limit进行复位)
            this.readBuf.flip();
            //6 根据缓冲区的数据长度创建相应大小的byte数组,接收缓冲区的数据
            byte[] bytes = new byte[this.readBuf.remaining()];
            //7 接收缓冲区数据
            this.readBuf.get(bytes);
            //8 打印结果
            String body = new String(bytes).trim();
            System.out.println("Server : " + body);

            // 9..可以写回给客户端数据 
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void accept(SelectionKey key) {
        try {
            //1 获取服务通道
            ServerSocketChannel ssc =  (ServerSocketChannel) key.channel();
            //2 执行阻塞方法,从服务器通道中接受客户端连接通道
            SocketChannel sc = ssc.accept();
            //3 设置通道为非阻塞模式
            sc.configureBlocking(false);
            //4 将客户端连接通道注册到多路复用器上,并设置读取标识
            sc.register(this.seletor, SelectionKey.OP_READ);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        new Thread(new Server(7788)).start();;
    }
}

参考:

http://ifeve.com/socket-channel/

http://ifeve.com/java-nio-vs-io/

https://www.cnblogs.com/barrywxx/p/8430790.html

https://blog.csdn.net/qq_34039868/article/details/105577401

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值