咱们一直说,学习IO
和NIO
,但为何要学习这些呢?
咱们分两块来看一下:html
本地的IO
很简单,就是文件嘛,或者缓存,或者其余的可保存数据的渠道。举个很简单的例子,若是咱们要把某些数据保存到文件里面,好比咱们这篇文章,要保存到硬盘中,确定就须要写入到硬盘中。这里的写入咱们就能够称为涉及到IO
的使用。java
网络的IO
理解起来会稍微不大同样,由于他不像本地那么直观。
咱们先放一下,来复习一下网络传输实际是怎样的。程序员
服务器 -> 路由器 -> tcp/http/其余协议 -> 路由器 -> 本地机器。通常状况下咱们的理解是这样的。若是咱们不往细了看,总体的流程是这样的。但实际上从 服务器->路由器或者 路由器->本地机器这个过程当中涉及到 内核和 用户态的一系列的协调,它们的协调处理才把数据真正传输完成。
服务器->路由器:这种状况下,数据会由应用程序,即 用户线程,经 内核线程,再经由 网卡,最后把数据传输到远程机器,这里数据在各个流程中的流转,咱们也都称他们涉及到IO
,由于他们涉及到存储。
路由器->本地机器:这种状况下,数据会由 网卡,经 内核线程,再传到 用户线程,即给到咱们的应用程序进行处理,这里的流程中的转换,也是涉及到IO
。
在Linux
和Unix
的哲学中,他们把全部的设备都当成是一个文件来处理,每个文件均可读可写,每个设备也是可读可写,这样的抽象真是完美无缺。缓存
Java
程序员之痛曾已什么时候,Java
程序员只有java.io
包中的那一系列相关的类,这些类,在咱们眼中称为BIO
,全称为Blocking-IO
,即阻塞性IO。什么叫阻塞性IO呢?服务器
阻塞性IO要求应用程序在处理时,须要等待当前的IO彻底处理完成后才能够继续后面的操做,好比读取文件,须要彻底读取成功/或出现异常,才返回;写入文件,则须要所有写入成功后/或抛出异常才返回。阻塞,阻塞,就意味着你一旦开始作某件事情,就是必定要等到这件事作完才能够。
这种状况在正常状况下是没问题的,但试想一下,若是当前机器的IO
负载比较高,你这里再来一个写入文件的操做,是否是要等到天荒地老;或者你来个读文件,原本都卡得快动不了了,你还读文件,估计是更惨了。网络
口说无凭,咱们来看段代码,看看咱们以前是怎么来对待这些IO
,而且被他们折磨的。框架
Server
阻塞性Server
有两层概念:socket
Server
会一直等待客户端的链接,一直到它正常创建链接,咱们的Server
都干不了其余事情。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
也有一样的两层概念:
咱们一样看一下代码:
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
中的发送消息设置断点。按照如下的步骤进行调试
Server
Client
Server
——这里咱们能够发现执行完后会卡住Client
——这里咱们继续执行到socket.close
后,只有close
后才会真正把消息发送出去。Server
,咱们发现已经正常返回了。从上面的现象,咱们能够下结论,Server
在读取Client
的发送数据时会阻塞,一直到收取消息完成,同理,Server
在发送数据到Client
时候也是同样的,也是会阻塞直到发送完成。
看完上面的阻塞性代码,你有什么想法呢?
想一想,假设若是咱们这样写代码,有多个客户端同时链接的时候,要怎么搞呢?
第一个客户端链接成功,发送完成消息,断开
第二个客户端链接
...
就这样,活生生变成了顺序化的程序了。
那咱们应该怎么办呢?总不能就这样将就用吧,让每一个用户等其余人用完,估计会被用户锤出翔啊。
这样英年早逝还怎么写代码呢?
聪明的程序员确定能想出办法的。
既然它阻塞住了,那我就把它放到另一个线程处理呗,怎么搞都不关我事。
那么又有了这样一个优化版本
说是阻塞的优化版,固然仍是阻塞了,不要想着能玩出什么花。
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)
这个很是吓人的死循环,估计放其余代码里面,头都要被人打爆,但在这里,是正常的,先不要激动。咱们这里针对每个链接都起一个新的线程,这样阻塞就不会影响到总体的运行了。
你们能够再运行测试一下,看看是否是已经不会 阻塞 了。
Server
Client
,能够设置断点在大括号,模拟发送完消息暂停Server
的输出咱们能够看到有两个输出:
这下牛叉了,不 阻塞 了。但真的OK吗?
咱们都知道,操做系统能够启用的线程数量是有限的,不能无限启动,而且线程的上下文切换成本是很高的。若是不受限制地开线程,会致使系统CPU飙升,估计系统都会不可用。因此若是咱们用这种方式,假设有10个客户端的时候,好像还没啥事,但当去到100个,甚至500个的时候,估计系统都会开始运行缓慢了——咱们这种没啥复杂业务的线程很快就结束了,对线程的占用时间比较短,影响不算太大。但当业务复杂,每一个线程执行时间比较长的时候,就会出问题了。
从上面咱们了解到当线程数量一多的时候,就会致使系统出现各类各样的问题。那应该怎么办呢?太多不行,那我限制一下总能够了吧。我用线程池,限制能够启动的线程数量,这样就不会由于线程数太多出问题了吧。
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,这几个是什么鬼东西,不知道的能够看看ThreadPoolExecutor
的JavaDoc
,Doug Lea大神写得很是清楚了。我这里大概描述下:
rejectHandler
,默认状况下为RejectedExecutionException
,即当有新的任务提交时,直接拒绝执行。注意,这里的条件里面的判断条件都是运行的线程。不运行的是不算入数量里面的。关于这个ThreadPoolExecutor
也是块硬骨头,后面再详细聊聊,咱们仍是回到正题的IO这里。
这里咱们用了一个线程池去执行咱们的socket
链接后的处理逻辑——即咱们的阻塞读取操做。那么各个线程之间的阻塞就不会对其余的线程形成影响。
但一样的,有了线程池咱们就高枕无忧了吗?
咱们看一下这里咱们总的线程数是10(最大线程数量)+10(队列数)=20,那假设20个线程都用完了,咱们的执行业务又须要去到几秒钟,那么后面提交的就会被拒绝了。
有人说,那简单,把线程数调大点,来个5000就行了。这。。。,估计没仔细看前面的,回到前面看看,线程太大会致使切换损耗加大,对性能会有很大的影响。那不能调大线程数,那就加大队列。呃,这也是能够的,只是若是咱们的线程处理原本就慢,加大队列只是徒增内存的压力而已,并不会有任何用处。
那,咱们就没办法了吗?干瞪眼吗?
程序员是不会认输的。。。
因此才有咱们这篇文章的NIO。
NIO
的横空出世NIO
是啥东西来的?有些人叫New IO
,都2020年了,这JDK1.5
出的咱们还叫New IO
,这想一想都感受怪怪的。实际上在当时刚出的时间来看,叫New IO
是没问题的,但慢慢随着时间的推移,就不该该这样的。而咱们看看New IO
的引入主要解决了什么问题——阻塞。因此,咱们把NIO
称为Non-Blocking IO
会更合适一点,即非阻塞IO。
非阻塞就表明它不阻塞吗?固然不是,NIO
也是支持阻塞调用的,就跟回到解放前同样,用着复杂的NIO
的API干着旧的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)
就不说了,只是写法上的区别哈。咱们看到基本上大致流程一致:
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
咱们前面看到Server
和Client
都有一个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
是基础类型的容器,注意是基础类型,而不是什么自定义类型,而且它最重要的几个属性是capacity,limit,position,咱们来讲一下这几个概念:
故名思义,容量是指当前这个Buffer
最大能容纳的内容,好比capacity
是20,那么最大就只能容纳20个咱们 指定类型的数据。
limit可能理解起来会比较难,它表示的是可读或可写的限制位置。
每个操做都会有它的起始位置,如读即读的起始位置,写即写的起始位置。
咱们用一张图来帮忙理解:
来源:http://tutorials.jenkov.com/j...
在上面的Write Mode中,只有在position
和limit
中的空间是容许写入,当大于limit
,则会抛出BufferOverflowException
;
而对于Read Mode来讲是相似的,只有在position
和limit
中的空间是容许读取的,当大于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)。挺好理解的。
在真正开始非阻塞的探索前,咱们先来看看多路复用这个东西。
这概念相应你们都挺熟的,毕竟提到多路复用基本上就至关因而poll
,select
,epoll
。
多路复用实际上有一个最大的好处:
系统负载小,由内核原生支持,不须要额外建立进程/线程。
说了这么多,什么叫多路复用呢?
多路复用的概念是这样的:
有一个原生的进程能够监视多个 描述符,一旦某个 描述符就绪,系统就能够通知到应用程序,此时应用程序再根据相应的 描述符执行相应的 逻辑便可。
那它又跟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(); } } } } } }
咱们能够看到代码比较复杂,先来理一下步骤:
ServerSocketChannel
,监听8001
端口configureBlocking(false)
设置channel
为非阻塞——关键 Selector.open
打开Selector
ACCEPT
select
判断是否有就绪的描述符,这里阻塞的 selectKeys
获取就绪的描述符selectKeys
返回的描述符,进行相应的处理——这里须要记得把处理完成的SelectionKey
删除掉,即remove
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(); } } } } } }
看完代码,咱们梳理一下上面的步骤:
SocketChannel
,链接8001
端口configureBlock(false)
设置channel
为非阻塞——关键 Selector.open
打开Selector
CONNECT
select
判断是否有就绪的描述符,这里阻塞的 selectKeys
获取就绪的描述符selectKeys
返回的描述符,进行相应的处理——这里须要记得把处理完成的SelectionKey
删除掉,即remove
register
对应的SelectionKey
这里咱们能够看到步骤基本上跟服务端的步骤是一致的,只是初始的描述符不一致,server
是ACCEPT
,而client
是CONNECT
。
咱们一直说非阻塞IO,那什么算是非阻塞IO。而咱们前面的BIO和NIO最大区别也就是在对IO的处理上。
BIO
BIO
使用的是直接调用读/写方法,一直到系统对其作出响应。
NIO
NIO
使用的阻塞描述符(或者说信号),直到信号OK了——即咱们代码里面的select
,直接返回,而后再进行处理,实际上在获得描述符的时候仍是阻塞的,只是在真正执行读/写操做的时候,这个时候IO已是ready的状态,这里IO已经不是阻塞的状态了。因此咱们这里写的非阻塞指的是IO,但描述符的获取仍是阻塞的。
说了这么多,咱们对NIO
和BIO
的一些介绍都已经基本上完了。如今基本上都比较少人直接使用NIO
或BIO
进行编码,都是经过netty
或者其余的一些高性能NIO
框架来使用。——dubbo
等在底层都使用了netty
做为网络层框架。
后面咱们会找机会介绍一下netty
在NIO
的使用上给予咱们的一些便利,和它为何更适合咱们使用。