Java BIO与NIO

网络IO模型

为了弄清JavaBIO(传统的 java.io 包)和NIO(Java 1.4 引入的 java.nio 包)原理以及区别,首先我们先介绍一下Unix/Lniux中的网络IO模型,阻塞IO模型、非阻塞IO模型、IO多路复用模型。

基础概念

在介绍网络IO模型的过程中,涉及到一些基础概念,下面介绍这些基本概念。

  1. 用户空间与内核空间

用户空间是普通进程所处的区域,如JVM。用户空间是非特权区域,比如在该区域执行的代码不能直接访问硬件设备。

内核空间是特权空间,比如它能与设备控制器通讯,控制着整个用户区域进程的运行状态。

注: I/O 都直接或间接通过内核空间。

为什么要将内存划分为用户空间和内核空间?

为了保证操作系统的稳定性安全性。用户程序不可以直接访问硬件资源,如果用户程序需要访问硬件资源,必须调用操作系统提供的接口,这个调用接口的过程也就是系统调用

系统调用(system call)每一次系统调用都会存在两个内存空间之间的数据交互,通常的网络传输也是一次系统调用,通过网络传输的数据先是从内核空间接收到远程机器的数据,然后再从内核空间复制到用户空间,供用户程序使用。

  1. 文件描述符(fd),Linux 下系统各组件都是以文件描述符的形式存在的,例如 socket。

  2. 同步与异步

    以 “调用” 为例,所谓同步,就是 在发出一个 “调用请求” 时,在没有得到结果之前,该 “调用请求” 就不返回,但是一旦调用返回就得到返回值了。换句话说,就是由 "调用者" 主动等待 “被调用者” 的结果。像我们平时写的,方法 A 调用Math.random()方法、方法 B 调用String.substring()方法都是同步调用,因为调用者主动在等待这些方法的返回。

    所谓异步,则正好相反,当一个异步调用请求发出之后,调用者不会立刻得到这个请求真正的执行完后得出的结果,立即返回的可能只是一个伪结果 。因此异步调用适用于那些对数据一致性要求不是很高的场景,或者是执行过程很耗时的场景。如果这种场景下,我们希望获取异步调用的结果,"被调用者"可以通过状态、通知来通知调用者,或通过回调函数处理这个调用。

  3. 进程执行网络I/O中read操作

    1. 内核缓冲区准备数据,等待网络数据分组到达,然后将其复制到内核缓冲区
    2. 内核缓冲区数据拷贝到用户缓冲区
阻塞IO模型

当用户进程开始调用了 recvfrom函数后,就开始了 IO 的 第一阶段:内核缓冲区准备数据。对于网络 IO 来说,数据只有在积累到一定的量的时候才会发送,这个时候内核缓冲区就要等待足够的数据到来。而在用户缓冲区这边,用户进程会一直被操作系统阻塞。当内核缓冲区数据准备好了,此时就会将内核缓冲区中的数据拷贝到用户缓冲区,然后 由操作系统唤醒被阻塞的用户进程 并将结果返回给用户进程,此时用户进程才重新运行起来。所以,阻塞 IO 的特点就是在 IO 执行的两个阶段都被阻塞了

listenfd = socket(...);
bind(listenfd, 服务器ip和端口,...);
listen(listenfd,...);
while(true){
    connfd = accept(listenfd,...); // 阻塞
    receive(connfd,...); // 阻塞
}
非阻塞IO模型

当用户进程开始调用了 recvfrom函数后,如果内核缓冲区中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个 read 操作后,并没有被阻塞,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作,就这样一直进行下去,到这里第一阶段都是一直在轮训。一旦内核缓冲区中的数据准备好了,并且又再次收到了用户进程的 read 请求,那么它马上就将数据从内核缓冲区拷贝到用户缓冲区,然后返回给用户线程,这是第二阶段。所以,用户进程在第一阶段其实并没有被操作系统一直阻塞,而是需要不断的主动询问内核缓冲区数据好了没有。只有在第二阶段数据拷贝到时候会被阻塞

IO多路复用

IO 多路复用实际上就是通过一种机制,一个进程可以监视多个fd,一旦某个fd 就绪(一般是读就绪或者写就绪),能够通知程序进行相应的操作,这种机制目前有 selectpollepoll,下面对它们进行具体介绍。

  • select

用户调用 select ()后,用户进程会阻塞,直到有fd就绪,它就会将它刚刚监控的所有的fd对应的标识符的集合 fd_set(注意,这里将内核缓冲区中数据已经就绪的fd的标识会打上一个标记)返回给用户进程,然后用户进程再去遍历fd_set找出其中内核缓冲区中数据已经就绪的fd的标识符,然后调用recvfrom()开始第二阶段。

注,select()监视的fd分 3 类,分别是writefdsreadfds,和exceptfds

select 优点:

  1. select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。

select 缺点:

  1. 单个进程能监控的fd的数量是有一定限制的,它由FD_SETSIZE限制,默认值是1024,如果修改的话,就需要重新编译内核,不过这会带来网络效率的下降 。
  2. select 模型下内核地址空间和用户地址空间每次数据复制都是复制所有的fd。 随着fd数目的增加,可能只有很少一部分fd 是活跃的,但是 select 每次调用时都会遍历整个fd_set,检查每个fd 的数据就绪的状态,这就导致效率很低。
  • poll

poll 本质上和 select 没有区别,它也是将整个fd_set告诉给用户进程。和 select 不同的是它没有最大连接数的限制,原因是它是基于链表来存储的。

poll 缺点:

  1. 模型下内核地址空间和用户地址空间每次数据复制都是复制所有的fd

  2. poll是水平触发的,即如果报告了fd处于就绪状态后,没有被处理,那么下次poll时会再次报告该 fdfd 增加时,线性扫描导致性能下降。

  • epoll

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd变为就绪态,并且只会通知一次。还有一个特点是,epoll使用 事件 的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似 callback 的回调机制来激活该 fdepoll_wait 就可以收到通知。

epoll的优点:

  1. 没有最大并发连接的限制,它支持的fd上限受操作系统最大文件句柄数;
  2. 效率提升,不同于 select 和 poll,epoll只会对活跃(数据处于就绪状态) 的fd进行操作,这是因为在内核实现中epoll是根据每个fd上面的 callback 函数实现的,只有活跃的fd才会主动的去调用 callback 函数,其他状态的fd则不会。epoll的性能不会受fd总数的限制。
  3. select/poll 都需要内核把fd消息通知给用户空间,而 epoll是通过内核和用户空间mmap同一块内存实现。
  • select,poll,epoll比较
类别 select poll epoll
支持的最大连接数 FD_SETSIZE限制 基于链表存储,没有限制 受系统最大句柄数限制
fd剧增的影响 线性扫描fd导致性能很低 同 select 基于fd上 callback 实现,没有性能下降的问题
消息传递机制 内核需要将消息传递到用户空间,需要内核拷贝 同 select epoll通过内核与用户空间共享内存来实现
IO多路复用与阻塞IO对比
  1. 阻塞 IO 和非阻塞 IO 用户进程都是只是调用recvfrom一个函数,而多路复用中用户进程还会再调用一个 select 函数,当用户进程调用了 select,那么整个进程会被阻塞,而同时,操作系统会 “监视” 所有 select 负责的 socket 所对应的的内核缓冲区的数据,当任何一个 socket 所对应的内核缓冲区中的数据准备好了,就会返回可读条件的通知。此时用户进程再调用 read 操作,将数据从内核缓冲区拷贝到用户缓冲区。
  2. 在 IO 多路复用模型中,对于每一个 socket,一般都设置成为非阻塞的,但是,整个用户进程实际上是一直被阻塞的。只不过用户进程是被 select 这个函数阻塞的 ,而不是被 socket IO 给阻塞的(或者也可以理解为是操作系统阻塞的)。

多路复用与阻塞IO性能比较多路复用更适合同时处理多连接请求,当连接请求数少时更适合使用阻塞IO。因为多路复用需要两个 system call (select/poll/epollrecvfrom),而阻塞 IO 只调用了一个system call (recvfrom)。所以,如果处理的连接数不是很高的话,使用 select 的 web server 不一定比使用 多线程 + 阻塞 IO 的 web server 性能更好,可能延迟还更大。

BIO

BIO 是传统的java.io 包,它是基于流模型实现的,交互的方式是同步、阻塞方式(基于阻塞IO模型实现的),也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。

优点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。

下面是基于BIO实现的C/S通信

// 同步阻塞IO-服务端
public class Server {
    public static void main(String[] args) {
        try {
            ServerSocket server = new ServerSocket(10001);
            while (true) {
                Socket client = server.accept();
                InputStream in = client.getInputStream();
                BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                System.out.print(reader.readLine());
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
// 同步阻塞IO-客户端
public class Client {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1",10001);
            OutputStream stream = socket.getOutputStream();
            byte[] b = "hello".getBytes();
            stream.write(b);
            stream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

上述方案的性能是较差的,因为acceptread是阻塞的,所以服务端是串行化处理客户端的请求。只有当每一个客户端请求处理完毕,才能处理下一个客户端的请求。可以将read操作交给一个新的线程去处理,若每一个请求都使用一个新的线程去处理read操作,当请求数量过多时,线程数过多,会影响执行效率,可以采用线程池执行read操作。

// 伪异步IO(线程池)-服务端 
public class ServerV1 {
    public static void main(String[] args) throws IOException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        ServerSocket serverSocket = new ServerSocket(10001);
        try {
            while (true) {
                Socket socket = serverSocket.accept();
                executorService.execute(new ServerHandler(socket));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

class ServerHandler implements Runnable{

    Socket socket;

    public ServerHandler(Socket socket) {
        this.socket = socket;
    }

    @SneakyThrows
    @Override
    public void run() {
        System.out.print("Thread: " + Thread.currentThread().getName() + " ");
        InputStream in = socket.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(in));
        String res;
        while (true) {
            if ((res = reader.readLine()) == null) break;
            System.out.println(res);
        }
    }
}
NIO

NIO是 Java 1.4 引入的java.nio包,它提供了ChannelSelectorBuffer等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序(基于IO多路复用模型实现的),同时提供了更接近操作系统底层高性能的数据操作方式。

1. Channel

通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。

通道与流的不同之处在于,流是单向的,通道是双向的,可以用于读、写或者同时用于读写。

  • FileChannel,从文件中读取数据
  • DatagramChannel,基于UDP读取网络中的数据
  • SocketChannel,基于TCP读取网络中的数据
  • ServerSocketChannel,监听TCP连接,为TCP连接建立SocketChannel
2. Buffer

不会直接对通道进行读写数据,而是要先经过缓冲区。即发送给一个通道的所有数据都必须首先放到缓冲区中,从通道中读取的任何数据都要先读到缓冲区中。

Buffer是在数组数组的基础上进行封装,方便使用。

Buffer中有几个重要的属性,通过这几个属性来显示数据存储的信息

  • capacity,Buffer 所能容纳元素的最大个数,即底层数组的长度。在创建时被指定,不可更改。
  • position,下一个要读写的数据的位置
  • limit,可供读写的最大位置,用于限制 position
  • mark,位置标记,用于记录某一次的读写位置,可以通过 reset 重新回到这个位置
2. Selector

Selector使用基于epoll的多路复用模型实现的,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel server = ServerSocketChannel.open();
        server.configureBlocking(false);

        Selector selector = Selector.open();
        server.register(selector, SelectionKey.OP_ACCEPT);

        ServerSocket socket = server.socket();
        SocketAddress address = new InetSocketAddress(10001);
        socket.bind(address);

        while (true){
            selector.select();
            Set keys = selector.selectedKeys();
            Iterator iterator = keys.iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                if (key.isAcceptable()){
                    ServerSocketChannel server1 = (ServerSocketChannel) key.channel();
                    SocketChannel client = server1.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                }
                if (key.isReadable()){
                    SocketChannel client = (SocketChannel) key.channel();
                    readData(client);
                    client.close();
                }
                iterator.remove();
            }
        }
    }
    public static void readData(SocketChannel client) throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        StringBuilder data = new StringBuilder();
        while (true) {
            buffer.clear();
            int n = client.read(buffer);
            if (n == -1) {
                break;
            }
            buffer.flip();
            int limit = buffer.limit();
            char[] dst = new char[limit];
            for (int i = 0; i < limit; i++) {
                dst[i] = (char) buffer.get(i);
            }
            data.append(dst);
            buffer.clear();
        }
        System.out.println(data.toString());
    }
}
引用

https://www.cnblogs.com/tkzL/p/11494134.html

https://blog.csdn.net/anxpp/article/details/51512200

http://ifeve.com/tag/channel/

https://www.zhihu.com/question/337609338/answer/775135962
http://www.tianxiaobo.com/2018/03/04/Java-NIO%E4%B9%8B%E7%BC%93%E5%86%B2%E5%8C%BA/

你可能感兴趣的:(Java BIO与NIO)