10

IO的由来

我们一直说,学习IONIO,但为什么要学习这些呢?
我们分两块来看一下:

  • 本地

本地的IO很简单,就是文件嘛,或者缓存,或者其他的可保存数据的渠道。举个很简单的例子,如果我们要把某些数据保存到文件里面,比如我们这篇文章,要保存到硬盘中,肯定就需要写入到硬盘中。这里的写入我们就可以称为涉及到IO的使用。

  • 网络

网络的IO理解起来会稍微不大一样,因为他不像本地那么直观。
我们先放一下,来复习一下网络传输实际是怎样的。

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

LinuxUnix的哲学中,他们把所有的设备都当成是一个文件来处理,每一个文件都可读可写,每一个设备也是可读可写,这样的抽象真是天衣无缝

Java程序员之痛

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

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

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

口说无凭,我们来看段代码,看看我们之前是怎么来对待这些IO,并且被他们折磨的。

阻塞性Server

阻塞性Server有两层概念:

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

我们直接看一下代码:

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发送完成为止。因此这里对应了我们上面说的两层阻塞概念。

阻塞性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


shun记忆
92 声望4 粉丝