一、五种IO模型
在《Unix网络编程》一书中提到了五种IO模型,分别是:阻塞IO、非阻塞IO、多路复用IO、信号驱动IO以及异步IO。
而Reactor模式实现了同步非阻塞模型,而Proactor模式实现了异步非阻塞模型
具体方面请参考我的另一篇博客
二、NIO,BIO,AIO选型
1.同步阻塞IO(BIO)
我们熟知的Socket编程就是BIO,一个socket连接一个处理线程(这个线程负责这个Socket连接的一系列数据传输操作)。
阻塞的原因在于:操作系统允许的线程数量是有限的,多个socket申请与服务端建立连接时,服务端不能提供相应数量的处理线程,没有分配到处理线程的连接就会阻塞等待或被拒绝。
缺点:
1、IO代码里read操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,浪费资源
2、如果线程很多,会导致服务器线程太多,压力太大,比如C10K问题
应用场景:
BIO 方式适用于连接数目比较小且固定的架构, 这种方式对服务器资源要求比较高, 但程序简单易理解。
2.同步非阻塞IO(NIO)
New IO是对BIO的改进,基于Reactor模型。
同步非阻塞,服务器实现模式为一个线程可以处理多个请求(连接),客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮询到连接有IO请求就进行处理,JDK1.4开始引入。
应用场景:
NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂。
3.异步非阻塞 I/O(AIO)
1.什么是AIO
AIO是对NIO的改进(所以AIO又叫NIO.2),它是基于Proactor模型的。
异步非阻塞 I/O 模型读请求会立即返回,说明 read 请求已经成功发起了,在后台完成读操作时,应用程序然后会执行其他处理操作。当 read 的响应到达时,就会产生一个信号或执行一个基于线程的回调函数来完成这次 I/O 处理过程。
所以异步最大的特点是,应用程序不需要自己去触发数据从内核空间到用户空间的拷贝。
为什么是应用程序去“触发”数据的拷贝,而不是直接从内核拷贝数据呢?
这是因为应用程序是不能访问内 核空间的,因此数据拷贝肯定是由内核来做,关键是谁来触发这个动作。
是内核主动将数据拷贝到用户空间并通知应用程序。还是等待应用程序通过Selector来查询,当数据就绪 后,应用程序再发起一个read调用,这时内核再把数据从内核空间拷贝到用户空间。
需要注意的是,数据从内核空间拷贝到用户空间这段时间,应用程序还是阻塞的。所以你会看到异步的效率 是高于同步的,因为异步模式下应用程序始终不会被阻塞。
下面我以网络数据读取为例,来说明异步模式的工作过程。
首先,应用程序在调用read API的同时告诉内核两件事情:数据准备好了以后拷贝到哪个Buffer,以及调用 哪个回调函数去处理这些数据。 之后,内核接到这个read指令后,等待网卡数据到达,数据到了后,产生硬件中断,内核在中断程序里把数 据从网卡拷贝到内核空间,接着做TCP/IP协议层面的数据解包和重组,再把数据拷贝到应用程序指定的 Buffer,最后调用应用程序指定的回调函数。
你可能通过下面这张图来回顾一下同步与异步的区别:
我们可以看到在异步模式下,应用程序当了“甩手掌柜”,内核则忙前忙后,但最大限度提高了I/O通信的 效率。
Windows的IOCP和Linux内核2.6的AIO都提供了异步I/O的支持,Java的NIO.2 API就是对操作系统异 步I/O API的封装。
AIO与NIO的区别:AIO是发出IO请求后,由操作系统自己去获取IO权限并进行IO操作;NIO则是发出IO请求后,由线程不断尝试获取IO权限,获取到后通知应用程序自己进行IO操作。
2.应用场景
一般适用于连接数较多且连接时间较长的应用,但实际上aio使用的地方并不多。
1.为什么大多数公司并没有使用AIO,而是使用了netty?
AIO的底层实现仍使用Epoll,并没有很好的实现异步,在性能上对比NIO没有太大优势
AIO的代码逻辑比较复杂,且Linux上AIO还不够成熟
Netty在NIO上做了很多异步的封装,是异步非阻塞框架
2.为什么Netty使用NIO而不是AIO?
在Linux系统上,AIO的底层实现仍使用Epoll,没有很好实现AIO,因此在性能上没有明显的优势,而且被JDK封装了一层不容易深度优化,Linux上AIO还不够成熟。
Netty是异步非阻塞框架,Netty在NIO上做了很多异步的封装。
4.各种I/O的对比
三、java NIO
3.2 java NIO和IO的主要区别
下表总结了Java NIO和IO之间的主要差别,我会更详细地描述表中每部分的差异。
IO | NIO |
---|---|
面向流 | 面向缓冲 |
阻塞IO | 非阻塞IO |
无 | 选择器 |
1、面向流与面向缓冲
Java IO和NIO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。
Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。
Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
即:一个流中读写数据,一个从缓冲读写数据
2、阻塞与非阻塞IO
Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
3、选择器(Selectors)
Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以在一个选择器中注册多个通道,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
3.2 NIO的核心组件
NIO 有三大核心组件: Channel(通道), Buffer(缓冲区),Selector(多路复用器)
1、channel 类似于流,每个 channel 对应一个 buffer缓冲区,buffer 底层就是个数组
2、channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理
3、NIO 的 Buffer 和 channel 都是既可以读也可以写
3.2.1 Channel
首先说一下Channel,国内大多翻译成“通道”。Channel和IO中的Stream(流)是差不多一个等级的。只不过Stream是单向的,譬如:InputStream, OutputStream.而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。
NIO中的Channel的主要实现有:
FileChannel
DatagramChannel
SocketChannel
ServerSocketChannel
3.3 nio处理网路请求实例
nio组件之间的工作流程如下
1.创建ServerSocketChannel并绑定端口
2.创建Selector多路复用器,并注册Channel
3.循环监听是否有感兴趣的事件发生selector.select();
4.获得事件的句柄,并进行处理
NIO底层在JDK1.4版本是用linux的内核函数select()或poll()来实现,跟上面的NioServer代码类似,selector每次都会轮询所有的sockchannel看下哪个channel有读写事件,有的话就处理,没有就继续遍历,JDK1.5开始引入了epoll基于事件响应机制来优化NIO。
NIO整个调用流程就是Java调用了操作系统的内核函数来创建Socket,获取到Socket的文件描述符,再创建一个Selector对象,对应操作系统的Epoll描述符,将获取到的Socket连接的文件描述符的事件绑定到Selector对应的Epoll文件描述符上,进行事件的异步通知,这样就实现了使用一条线程,并且不需要太多的无效的遍历,将事件处理交给了操作系统内核(操作系统中断程序实现),大大提高了效率。
3.4 Redis线程模型
Redis就是典型的基于epoll的NIO线程模型(nginx也是),epoll实例收集所有事件(连接与读写事件),由一个服务端线程连续处理所有事件命令。
Redis底层关于epoll的源码实现在redis的src源码目录的ae_epoll.c文件里,感兴趣可以自行研究。
思考:
1. select,poll,epoll 的区别
I/O多路复用底层主要用的Linux 内核·函数(select,poll,epoll)来实现,windows不支持epoll实现,windows底层是基于winsock2的select函数实现的(不开源)
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。select,poll原理相似都是一个线程去线性遍历,而epoll时事件通知方式。
epoll可以理解为Java设计模式中的观察者模式中的push模式。
四、java AIO
1. 基础概念
AIO自JDK1.7以后才开始支持,是异步非阻塞的,与NIO不同,当进行读写操作时,只需直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。即可以理解为, read/write方法都是异步的,完成后会主动调用回调函数。
AIO基于Proactor模型实现,分为发送请求和读取数据两个步骤:
发送请求:将数据写入的缓冲区后,剩下的交给操作系统去完成
读取数据:操作系统写回数据也是写到Buffer里面,完成后再通知客户端来进行读取数据。
可用这张图大概概括了上面过程
2.java aio api
AIO主要在java.nio.channels包下增加了下面四个异步通道
AsynchronousSocketChannel
AsynchronousServerSocketChannel
AsynchronousFileChannel
AsynchronousDatagramChannel
其中的read/write方法,会返回一个带回调函数的对象,当执行完读取/写入操作后,直接调用回调函数。
下面用Java的 NIO.2 API来编写一个服务端程序。
public class Nio2Server {
void listen(){
//1.创建⼀个线程池
ExecutorService es = Executors.newCachedThreadPool();
//2.创建异步通道群组
AsynchronousChannelGroup tg = AsynchronousChannelGroup.withCachedThreadPool(es, 1);
//3.创建服务端异步通道
AsynchronousServerSocketChannel assc = AsynchronousServerSocketChannel.open(tg);
//4.绑定监听端⼝
assc.bind(new InetSocketAddress(8080));
//5. 监听连接,传⼊回调类处理连接请求
assc.accept(this, new AcceptHandler());
}
}
上面的代码主要做了5件事情:
1. 创建一个线程池,这个线程池用来执行来自内核的回调请求。
2. 创建一个AsynchronousChannelGroup,并绑定一个线程池。
3. 创建AsynchronousServerSocketChannel,并绑定到AsynchronousChannelGroup。
4. 绑定一个监听端口。
5. 调用accept方法开始监听连接请求,同时传入一个回调类去处理连接请求。请你注意,accept方法的第一 个参数是this对象,就是Nio2Server对象本身,我在下文还会讲为什么要传入这个参数。
你可能会问,为什么需要创建一个线程池呢?
其实在异步I/O模型里,应用程序不知道数据在什么时候到 达,因此向内核注册回调函数,当数据到达时,内核就会调用这个回调函数。同时为了提高处理速度,会提 供一个线程池给内核使用,这样不会耽误内核线程的工作,内核只需要把工作交给线程池就立即返回了。
我们再来看看处理连接的回调类AcceptHandler是什么样的。
我们看到它实现了CompletionHandler接口,下面我们先来看看CompletionHandler接口的定义。
public interface CompletionHandler<V,A> {
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
}
CompletionHandler接口有两个模板参数V和A,分别表示I/O调用的返回值和附件类。
比如accept的返回值 就是AsynchronousSocketChannel,而附件类由用户自己决定,在accept的调用中,我们传入了一个 Nio2Server。因此AcceptHandler带有了两个模板参数:AsynchronousSocketChannel和Nio2Server。
CompletionHandler有两个方法:completed和failed,分别在I/O操作成功和失败时调用。completed方法 有两个参数,其实就是前面说的两个模板参数。也就是说,Java的NIO.2在调用回调方法时,会把返回值和 附件类当作参数传给NIO.2的使用者。
下面我们再来看看处理读的回调类ReadHandler长什么样子。
public class ReadHandler implements CompletionHandler<Integer, ByteBuffer> {
//读取到消息后的处理
@Override
public void completed(Integer result, ByteBuffer attachment) {
//attachment就是数据,调⽤flip操作,其实就是把读的位置移动最前⾯
attachment.flip();
//读取数据
...
}
void failed(Throwable exc, A attachment){
...
}
}
read调用的返回值是一个整型数,所以我们回调方法里的第一个参数就是一个整型,表示有多少数据被读取 到了Buffer中。
第二个参数是一个ByteBuffer,这是因为我们在调用read方法时,把用来存放数据的 ByteBuffer当作附件类传进去了,所以在回调方法里,有ByteBuffer类型的参数,我们直接从这个 ByteBuffer里获取数据。
相关的代码入门案例可参考我的开源代码
参考资料
1.Java NIO:浅析I/O模型https://www.cnblogs.com/dolphin0520/p/3916526.html
2.BIO、NIO和AIO的区别(简明版)https://www.cnblogs.com/ygj0930/p/6543960.html
3.Java NIO:IO与NIO的区别 https://www.cnblogs.com/xiaoxi/p/6576588.html
4.漫谈Java IO之 NIO那些事儿 https://www.cnblogs.com/xing901022/p/8672418.html
5.攻破JAVA NIO技术壁垒 https://blog.csdn.net/u013256816/article/details/51457215#comments