IO和NIO的那些事

IO的由来

咱们一直说,学习IONIO,但为何要学习这些呢?
咱们分两块来看一下:html

  • 本地

本地的IO很简单,就是文件嘛,或者缓存,或者其余的可保存数据的渠道。举个很简单的例子,若是咱们要把某些数据保存到文件里面,好比咱们这篇文章,要保存到硬盘中,确定就须要写入到硬盘中。这里的写入咱们就能够称为涉及到IO的使用。java

  • 网络

网络的IO理解起来会稍微不大同样,由于他不像本地那么直观。
咱们先放一下,来复习一下网络传输实际是怎样的。程序员

服务器 -> 路由器 -> tcp/http/其余协议 -> 路由器 -> 本地机器。通常状况下咱们的理解是这样的。若是咱们不往细了看,总体的流程是这样的。但实际上从 服务器->路由器或者 路由器->本地机器这个过程当中涉及到 内核用户态的一系列的协调,它们的协调处理才把数据真正传输完成。
服务器->路由器:这种状况下,数据会由应用程序,即 用户线程,经 内核线程,再经由 网卡,最后把数据传输到远程机器,这里数据在各个流程中的流转,咱们也都称他们涉及到 IO,由于他们涉及到存储。
路由器->本地机器:这种状况下,数据会由 网卡,经 内核线程,再传到 用户线程,即给到咱们的应用程序进行处理,这里的流程中的转换,也是涉及到 IO

LinuxUnix的哲学中,他们把全部的设备都当成是一个文件来处理,每个文件均可读可写,每个设备也是可读可写,这样的抽象真是完美无缺缓存

Java程序员之痛

曾已什么时候,Java程序员只有java.io包中的那一系列相关的类,这些类,在咱们眼中称为BIO,全称为Blocking-IO,即阻塞性IO。什么叫阻塞性IO呢?服务器

阻塞性IO要求应用程序在处理时,须要等待当前的IO彻底处理完成后才能够继续后面的操做,好比读取文件,须要彻底读取成功/或出现异常,才返回;写入文件,则须要所有写入成功后/或抛出异常才返回。阻塞,阻塞,就意味着你一旦开始作某件事情,就是必定要等到这件事作完才能够。

这种状况在正常状况下是没问题的,但试想一下,若是当前机器的IO负载比较高,你这里再来一个写入文件的操做,是否是要等到天荒地老;或者你来个读文件,原本都卡得快动不了了,你还读文件,估计是更惨了。网络

口说无凭,咱们来看段代码,看看咱们以前是怎么来对待这些IO,而且被他们折磨的。框架

阻塞性Server

阻塞性Server有两层概念:socket

  1. 咱们的Server会一直等待客户端的链接,一直到它正常创建链接,咱们的Server都干不了其余事情。
  2. 链接创建后,Server还会一直等待客户端的发送或者Server会主动发送消息给客户端

咱们直接看一下代码:tcp

public class ServerSocketTest {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        //这里会阻塞一直到有链接创建
        Socket socket = serverSocket.accept();
        //这里读取由客户端发过来的内容
        System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine());
        socket.close();
        serverSocket.close();
    }

}

能够看到我这里有两行注释,第一个是接收客户端的链接,这里是会阻塞,直到创建链接才会正常返回。而第二个则会读取客户端发过来的内容,这里会一直阻塞到客户端调用write发送完成为止。所以这里对应了咱们上面说的两层阻塞概念。ide

阻塞性Client

阻塞性Client也有一样的两层概念:

  1. 当前Client会和服务端等待和客户端的链接,正常创建链接后才会返回
  2. 链接创建后,Client会发送消息给服务端,这里会阻塞直到发送成功,而且等待服务端的返回,一样也会阻塞直到返回。

咱们一样看一下代码:

public class ClientSocketTest {

    public static void main(String[] args) throws IOException {
        //创建和服务端的链接
        Socket socket = new Socket("localhost", 8080);
        //发送消息给服务端
        socket.getOutputStream().write("helloworld".getBytes());
        socket.close();
    }

}

这里咱们演示发送消息给服务端。
单纯说可能仍是比较难理解阻塞这个概念的,咱们能够运行上面的示例。在Server中的读取客户端输入行设置断点,在Client中的发送消息设置断点。按照如下的步骤进行调试

  1. 启动Server
  2. 启动Client
  3. 单步执行Server——这里咱们能够发现执行完后会卡住
  4. 单步执行Client——这里咱们继续执行到socket.close后,只有close后才会真正把消息发送出去。
  5. 回到Server,咱们发现已经正常返回了。

从上面的现象,咱们能够下结论,Server在读取Client的发送数据时会阻塞,一直到收取消息完成,同理,Server在发送数据到Client时候也是同样的,也是会阻塞直到发送完成。

痛定思痛

看完上面的阻塞性代码,你有什么想法呢?
想一想,假设若是咱们这样写代码,有多个客户端同时链接的时候,要怎么搞呢?

第一个客户端链接成功,发送完成消息,断开
第二个客户端链接
...

就这样,活生生变成了顺序化的程序了。

那咱们应该怎么办呢?总不能就这样将就用吧,让每一个用户等其余人用完,估计会被用户锤出翔啊。
Image.png
这样英年早逝还怎么写代码呢?
聪明的程序员确定能想出办法的。

阻塞的优化版

既然它阻塞住了,那我就把它放到另一个线程处理呗,怎么搞都不关我事。
那么又有了这样一个优化版本
说是阻塞的优化版,固然仍是阻塞了,不要想着能玩出什么花。

public class ServerSocketTest {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while(true) {
            new Thread(() -> {
                //这里会阻塞一直到有链接创建
                Socket socket = null;
                try {
                    socket = serverSocket.accept();
                    //这里读取由客户端发过来的内容
                    System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine());
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }

}

这里咱们看到咱们来了个while(true)这个很是吓人的死循环,估计放其余代码里面,头都要被人打爆,但在这里,是正常的,先不要激动。咱们这里针对每个链接都起一个新的线程,这样阻塞就不会影响到总体的运行了。
你们能够再运行测试一下,看看是否是已经不会 阻塞 了。

  1. 运行Server
  2. 运行两个Client,能够设置断点在大括号,模拟发送完消息暂停
  3. 查看Server的输出

咱们能够看到有两个输出:
Image2.png
这下牛叉了,不 阻塞 了。但真的OK吗?

问题

咱们都知道,操做系统能够启用的线程数量是有限的,不能无限启动,而且线程的上下文切换成本是很高的。若是不受限制地开线程,会致使系统CPU飙升,估计系统都会不可用。因此若是咱们用这种方式,假设有10个客户端的时候,好像还没啥事,但当去到100个,甚至500个的时候,估计系统都会开始运行缓慢了——咱们这种没啥复杂业务的线程很快就结束了,对线程的占用时间比较短,影响不算太大。但当业务复杂,每一个线程执行时间比较长的时候,就会出问题了。

阻塞的优化版2

从上面咱们了解到当线程数量一多的时候,就会致使系统出现各类各样的问题。那应该怎么办呢?太多不行,那我限制一下总能够了吧。我用线程池,限制能够启动的线程数量,这样就不会由于线程数太多出问题了吧。

public class ServerSocketTest {

    private static final ExecutorService EXECUTOR_SERVICE = new ThreadPoolExecutor(5, 10, 3, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(10), r -> {
                Thread t = new Thread(r);
                t.setName("处理线程");
                return new Thread(r);
            });

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while(true) {
            EXECUTOR_SERVICE.execute(() -> {
                //这里会阻塞一直到有链接创建
                Socket socket = null;
                try {
                    socket = serverSocket.accept();
                    //这里读取由客户端发过来的内容
                    System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine());
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
    }

}

先来看一下,咱们的线程池定义,5,10,3,10,这几个是什么鬼东西,不知道的能够看看ThreadPoolExecutorJavaDocDoug Lea大神写得很是清楚了。我这里大概描述下:

  1. 咱们定义了一个核心线程数为5,最大线程数为10,线程空闲时间为3,线程队列为10的线程池
  2. 当咱们提交一个新的线程,正在运行的线程数未达到5时,则直接新建一个新的线程数
  3. 若是正在运行的线程数达到5了,看一下线程队列有没有满,若是还未达到10,则放到线程队列中
  4. 若是线程队列也满了,那咱们再看一下正在运行线程数有没有达到最大的数量10了,若是还没达到,则直接启动一个新的
  5. 若是线程数已经达到了最大10了,则执行相应的rejectHandler,默认状况下为RejectedExecutionException,即当有新的任务提交时,直接拒绝执行。

注意,这里的条件里面的判断条件都是运行的线程。不运行的是不算入数量里面的。关于这个ThreadPoolExecutor也是块硬骨头,后面再详细聊聊,咱们仍是回到正题的IO这里。

这里咱们用了一个线程池去执行咱们的socket链接后的处理逻辑——即咱们的阻塞读取操做。那么各个线程之间的阻塞就不会对其余的线程形成影响。
但一样的,有了线程池咱们就高枕无忧了吗?
咱们看一下这里咱们总的线程数是10(最大线程数量)+10(队列数)=20,那假设20个线程都用完了,咱们的执行业务又须要去到几秒钟,那么后面提交的就会被拒绝了。

有人说,那简单,把线程数调大点,来个5000就行了。这。。。,估计没仔细看前面的,回到前面看看,线程太大会致使切换损耗加大,对性能会有很大的影响。那不能调大线程数,那就加大队列。呃,这也是能够的,只是若是咱们的线程处理原本就慢,加大队列只是徒增内存的压力而已,并不会有任何用处。

那,咱们就没办法了吗?干瞪眼吗?

程序员是不会认输的。。。
Image3.png
因此才有咱们这篇文章的NIO

NIO的横空出世

NIO是啥东西来的?有些人叫New IO,都2020年了,这JDK1.5出的咱们还叫New IO,这想一想都感受怪怪的。实际上在当时刚出的时间来看,叫New IO是没问题的,但慢慢随着时间的推移,就不该该这样的。而咱们看看New IO的引入主要解决了什么问题——阻塞。因此,咱们把NIO称为Non-Blocking IO会更合适一点,即非阻塞IO

非阻塞就表明它不阻塞吗?固然不是,NIO也是支持阻塞调用的,就跟回到解放前同样,用着复杂NIOAPI干着旧的java.io干的事情。这好很差,相信你有本身的见解。

为了区分前面的普通IO和咱们如今的NIO,咱们把以前的IO称为BIO,请你们注意。

NIO真的是非阻塞吗?

前面咱们说了非阻塞不表明它就是原生非阻塞,你一样能够写出阻塞的代码。嗯,是的,咱们要回到解放前,来看看这种非通常的作法。

NIO版阻塞Server

阻塞版的NIO,服务端代码咱们能够看看。

public class BlockingMyServer {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8080));
        //这里会阻塞一直到有链接创建
        SocketChannel socketChannel = null;
        try {
            socketChannel = serverSocketChannel.accept();
            //这里读取由客户端发过来的内容
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
            int num = socketChannel.read(byteBuffer);
            System.out.println(new String(byteBuffer.array(), 0, num));
            socketChannel.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        serverSocketChannel.close();
    }

}

从上面代码咱们能够看到,比正常的BIO代码复杂了一些,主要是引入了一个新的ByteBuffer类。这个类是啥东西呢?后面咱们再看,咱们先来看看这段代码跟以前的BIO的有什么流程上的区别吗?while(true)就不说了,只是写法上的区别哈。咱们看到基本上大致流程一致:

  1. 绑定端口
  2. 读取客户端传输内容

NIO版阻塞Client

阻塞版的NIO客户端代码以下:

public class BlockingMyClient {

    public static void main(String[] args) throws IOException {
        //创建和服务端的链接
        SocketChannel socket = SocketChannel.open(new InetSocketAddress(8080));
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        byteBuffer.put("helloworld".getBytes());
        byteBuffer.flip();
        //阻塞直到写入成功
        socket.write(byteBuffer);
        socket.close();
    }

}

咱们能够看到大致流程也跟BIO客户端代码相似。但一样也有一个奇怪的ByteBuffer

NIO中的容器Buffer

咱们前面看到ServerClient都有一个ByteBuffer,这究竟是个啥玩意。
那接下来咱们一块儿来看一下Buffer这个东西。
咱们首先能够看到Buffer这个类的JavaDoc文档的第一名话:

A container for data of a specific primitive type.
A buffer is a linear, finite sequence of elements of a specific primitive type.  Aside from its content, the essential properties of a buffer are its capacity, limit, and position

咱们能够看到Buffer基础类型容器,注意是基础类型,而不是什么自定义类型,而且它最重要的几个属性是capacitylimitposition,咱们来讲一下这几个概念:

  • capacity——容量
故名思义,容量是指当前这个 Buffer最大能容纳的内容,好比 capacity是20,那么最大就只能容纳20个咱们 指定类型的数据。
  • limit——大小限制
limit可能理解起来会比较难,它表示的是可读或可写的限制位置。
  • position——可读可写的起始位置
每个操做都会有它的起始位置,如读即读的起始位置,写即写的起始位置。

咱们用一张图来帮忙理解:
Image4.png
来源:http://tutorials.jenkov.com/j...

在上面的Write Mode中,只有在positionlimit中的空间是容许写入,当大于limit,则会抛出BufferOverflowException
而对于Read Mode来讲是相似的,只有在positionlimit中的空间是容许读取的,当大于limit,则会抛bm BufferUnderflowException异常。
至于为何这两个异常不使用同一个,估计只有JSR的专家才能解释了。

有了这部分知识的补充,咱们回到上面的场景,咱们为何要调用flip呢,由于咱们put完数据 后,此时的position已是跟limit是在同一个位置了,若是咱们此时调用write,则会从当前的position继续读数据以经过socket传输,但这明显是有问题的,后面并无任何数据 ,咱们须要把position置到从头开始,而且其余的limit也必须设置为上次写入的大小,由于须要调用flip
咱们直接看一下flip的代码就能够容易理解了:

public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}

把当前的位置,做为可读/可写的限制,以后把位置置为0,而把标记置为未指定(-1)。挺好理解的。

多路复用(Multiplexing)

在真正开始非阻塞的探索前,咱们先来看看多路复用这个东西。
这概念相应你们都挺熟的,毕竟提到多路复用基本上就至关因而pollselectepoll
多路复用实际上有一个最大的好处:

系统负载小,由内核原生支持,不须要额外建立进程/线程。

说了这么多,什么叫多路复用呢?
多路复用的概念是这样的:

有一个原生的进程能够监视多个 描述符,一旦某个 描述符就绪,系统就能够通知到应用程序,此时应用程序再根据相应的 描述符执行相应的 逻辑便可

那它又跟NIO有啥关系呢?
咱们前面说了这么多,IO的阻塞的最主要的缘由就是不知道读写何时结束。若是系统告诉我,何时能够读写,那么我在那个合适的时候去作合适的事情,那不就很省事了。其余时间该干吗干吗去。

NIO版非阻塞Server

前面咱们使用了NIO实现了阻塞版的Server,那感受真是酸爽,用一个原本不是这样用的API,硬是这样搞,太别扭了。因此,下面咱们来实现一版正常的NIO非阻塞Server,这里咱们要用到上面说的多路复用的知识。
多路复用的概念在NIO里面的对应概念是Selector。咱们直接来看代码:

public class MyServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress("localhost", 8001));

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

        String str = "";
        while(!Thread.currentThread().isInterrupted()) {
            //这里是一直阻塞,直到有描述符就绪
            selector.select();
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
            while(keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();
                //链接创建
                if (key.isAcceptable()) {
                    try {
                        SocketChannel clientChannel = serverSocketChannel.accept();
                        clientChannel.configureBlocking(false);
                        clientChannel.register(selector, SelectionKey.OP_READ);
                    } catch (ClosedChannelException e) {
                        e.printStackTrace();
                    }
                }
                //链接可读,这时能够直接读
                else if (key.isReadable()) {
                    ByteBuffer readBuffer = ByteBuffer.allocate(1024);

                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    try {
                        int num = socketChannel.read(readBuffer);
                        str = new String(readBuffer.array(), 0, num);
                        System.out.println("received message:" + str);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

}

咱们能够看到代码比较复杂,先来理一下步骤:

  1. 打开ServerSocketChannel,监听8001端口
  2. 使用configureBlocking(false) 设置channel为非阻塞——关键
  3. 调用Selector.open打开Selector
  4. 注册起始的描述符——通常状况下确定是ACCEPT
  5. 调用select判断是否有就绪的描述符,这里阻塞的
  6. 使用selectKeys获取就绪的描述符
  7. 遍历selectKeys返回的描述符,进行相应的处理——这里须要记得把处理完成的SelectionKey删除掉,即remove
  8. 处理完成后须要从新注册须要关注的描述符,即从新register对应的SelectionKey

咱们看到多路复用的实现代码比较复杂,步骤也比原来的BIO的复制不少。但咱们须要看到这里一个最大的进步就是由原来的等待处理变成了由系统来通知咱们去处理。而这里的通知,咱们是经过selectKeys方法来实现的。

NIO非阻塞Client

咱们这里的Client也是使用多路复用的方式来使用,咱们直接看一下代码。

public class MyClient {

    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress("localhost", 8001));

        Selector selector = Selector.open();
        socketChannel.register(selector, SelectionKey.OP_CONNECT);
        while(!Thread.currentThread().isInterrupted()) {
            //阻塞直到有ready的SelectionKey返回
            selector.select();
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = keys.iterator();
            while(keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();
                //链接已经创建了
                if (key.isConnectable()) {
                    try {
                        socketChannel.finishConnect();
                        //注册写描述符
                        socketChannel.register(selector, SelectionKey.OP_WRITE);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                //socket可写,能够发东西给服务端了
                else if (key.isWritable()) {
                    ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
                    writeBuffer.put("hello world".getBytes());
                    try {
                        writeBuffer.flip();
                        socketChannel.write(writeBuffer);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

看完代码,咱们梳理一下上面的步骤:

  1. 打开SocketChannel,链接8001端口
  2. 使用configureBlock(false)设置channel为非阻塞——关键
  3. 调用Selector.open打开Selector
  4. 注册起始的描述符——这里客户端须要注册CONNECT
  5. 调用select判断是否有就绪的描述符,这里阻塞的
  6. 使用selectKeys获取就绪的描述符
  7. 遍历selectKeys返回的描述符,进行相应的处理——这里须要记得把处理完成的SelectionKey删除掉,即remove
  8. 处理完成后须要从新注册须要关注的描述符,即从新register对应的SelectionKey

这里咱们能够看到步骤基本上跟服务端的步骤是一致的,只是初始的描述符不一致,serverACCEPT,而clientCONNECT

何谓非阻塞

咱们一直说非阻塞IO,那什么算是非阻塞IO。而咱们前面的BIONIO最大区别也就是在对IO的处理上。

  • BIO

BIO使用的是直接调用读/写方法,一直到系统对其作出响应。

  • NIO

NIO使用的阻塞描述符(或者说信号),直到信号OK了——即咱们代码里面的select,直接返回,而后再进行处理,实际上在获得描述符的时候仍是阻塞的,只是在真正执行读/写操做的时候,这个时候IO已是ready的状态,这里IO已经不是阻塞的状态了。因此咱们这里写的非阻塞指的是IO,但描述符的获取仍是阻塞的。

总结

说了这么多,咱们对NIOBIO的一些介绍都已经基本上完了。如今基本上都比较少人直接使用NIOBIO进行编码,都是经过netty或者其余的一些高性能NIO框架来使用。——dubbo等在底层都使用了netty做为网络层框架。
后面咱们会找机会介绍一下nettyNIO的使用上给予咱们的一些便利,和它为何更适合咱们使用。

参考文章

http://tutorials.jenkov.com/java-nio/buffers.html

相关文章
相关标签/搜索