IO的由来
我们一直说,学习IO
和NIO
,但为什么要学习这些呢?
我们分两块来看一下:
- 本地
本地的IO
很简单,就是文件嘛,或者缓存,或者其他的可保存数据的渠道。举个很简单的例子,如果我们要把某些数据保存到文件里面,比如我们这篇文章,要保存到硬盘中,肯定就需要写入到硬盘中。这里的写入我们就可以称为涉及到IO
的使用。
- 网络
网络的IO
理解起来会稍微不大一样,因为他不像本地那么直观。
我们先放一下,来复习一下网络传输实际是怎样的。
服务器 -> 路由器 -> tcp/http/其他协议 -> 路由器 -> 本地机器。一般情况下我们的理解是这样的。如果我们不往细了看,整体的流程是这样的。但实际上从服务器->路由器或者路由器->本地机器这个过程中涉及到内核和用户态的一系列的协调,它们的协调处理才把数据真正传输完成。
服务器->路由器:这种情况下,数据会由应用程序,即用户线程,经内核线程,再经由网卡,最后把数据传输到远程机器,这里数据在各个流程中的流转,我们也都称他们涉及到IO
,因为他们涉及到存储。
路由器->本地机器:这种情况下,数据会由网卡,经内核线程,再传到用户线程,即给到我们的应用程序进行处理,这里的流程中的转换,也是涉及到IO
。
在Linux
和Unix
的哲学中,他们把所有的设备都当成是一个文件来处理,每一个文件都可读可写,每一个设备也是可读可写,这样的抽象真是天衣无缝。
Java
程序员之痛
曾已何时,Java
程序员只有java.io
包中的那一系列相关的类,这些类,在我们眼中称为BIO
,全称为Blocking-IO
,即阻塞性IO。什么叫阻塞性IO呢?
阻塞性IO要求应用程序在处理时,需要等待当前的IO完全处理完成后才可以继续后面的操作,比如读取文件,需要完全读取成功/或出现异常,才返回;写入文件,则需要全部写入成功后/或抛出异常才返回。阻塞,阻塞,就意味着你一旦开始做某件事情,就是一定要等到这件事做完才可以。
这种情况在正常情况下是没问题的,但试想一下,如果当前机器的IO
负载比较高,你这里再来一个写入文件的操作,是不是要等到天荒地老;或者你来个读文件,本来都卡得快动不了了,你还读文件,估计是更惨了。
口说无凭,我们来看段代码,看看我们之前是怎么来对待这些IO
,并且被他们折磨的。
阻塞性Server
阻塞性Server
有两层概念:
- 我们的
Server
会一直等待客户端的连接,一直到它正常建立连接,我们的Server
都干不了其他事情。 - 连接建立后,
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
也有同样的两层概念:
- 当前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个的时候,估计系统都会开始运行缓慢了——我们这种没啥复杂业务的线程很快就结束了,对线程的占用时间比较短,影响不算太大。但当业务复杂,每个线程执行时间比较长的时候,就会出问题了。
阻塞的优化版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,这几个是什么鬼东西,不知道的可以看看ThreadPoolExecutor
的JavaDoc
,Doug Lea大神写得非常清楚了。我这里大概描述下:
- 我们定义了一个核心线程数为5,最大线程数为10,线程空闲时间为3,线程队列为10的线程池
- 当我们提交一个新的线程,正在运行的线程数未达到5时,则直接新建一个新的线程数
- 如果正在运行的线程数达到5了,看一下线程队列有没有满,如果还未达到10,则放到线程队列中
- 如果线程队列也满了,那我们再看一下正在运行线程数有没有达到最大的数量10了,如果还没达到,则直接启动一个新的
- 如果线程数已经达到了最大10了,则执行相应的
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,我们来说一下这几个概念:
- capacity——容量
故名思义,容量是指当前这个Buffer
最大能容纳的内容,比如capacity
是20,那么最大就只能容纳20个我们指定类型的数据。
- limit——大小限制
limit可能理解起来会比较难,它表示的是可读或可写的限制位置。
- position——可读可写的起始位置
每一个操作都会有它的起始位置,如读即读的起始位置,写即写的起始位置。
我们用一张图来帮忙理解:
来源: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)。挺好理解的。
多路复用(Multiplexing)
在真正开始非阻塞的探索前,我们先来看看多路复用这个东西。
这概念相应大家都挺熟的,毕竟提到多路复用基本上就相当于是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
的使用上给予我们的一些便利,和它为什么更适合我们使用。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。