首先先回顾IO的概念,IO流是实现输入和输出的基础,而计算机角度的IO就是输入设备以及输出设备,流就是一组有顺序的、有起点的和重点的集合,是对于数据传输的总称和抽象。输入时是从数据源到程序,输出时是由程序到数据源。
IO模型
IO模型分类
IO模型有阻塞IO模型,非阻塞IO模型,IO复用模型,信号驱动式IO模型和异步IO模型。其实也可以分为两类,同步IO和异步IO。
IO操作可以分为两部分:发起IO请求和进行IO操作。同步IO和异步IO的区别在于进行IO操作的方式,如果实际的IO读写操作阻塞以请求进程,就是同步IO,将IO操作交给操作系统来进行,只需要接收接收结果那么就是异步IO,所以阻塞式IO、非阻塞式IO、IO复用模型、信号驱动式IO模型和都是同步IO模型,阻塞式IO和非阻塞式IO说的是对于发起IO请求是否会被阻塞,阻塞式IO是只能监听一个客户端,而非阻塞式是其中的选择器监听多个通道。
阻塞IO模型(同步阻塞)
阻塞IO模型用户进程发起请求IO调用,这是内核中的数据还未准备好,用户进程就会阻塞一直等待内核数据准备好,再从内核缓冲区中拷贝数据到用户空间,拷贝成功,返回成功指示为止。阻塞IO经典的就是阻塞Socket和Java BIO。
阻塞IO缺点就是性能低,如果内核中数据一直没有准备好,那么就会一直阻塞。
非阻塞IO模型(同步非阻塞)
非阻塞IO会去请求IO调用时,内核如果数据没有准备好,可以先返回一个错误数据,通过轮询的方式来请求IO调用,在数据准备好后再阻塞用户进程。NIO就是非阻塞IO模型
虽然非阻塞IO解决了阻塞IO请求调用阻塞的问题,但是是通过轮询频繁的进行系统调用实现的,使性能还是较低的,消耗了CPU的资源,可以使用IO复用模型来解决这个问题。
非阻塞IO模型步骤如下:
- 应用进程向内核发送recvfrom请求读取内核数据
- 内核数据未准备好,返回错误码EWOULDBLOCK
- 进程轮询调用recvfrom,进程不阻塞等待,继续发送请求
- 内核数据准备好,内核缓冲区拷贝数据到用户空间,拷贝成功后返回成功指示
- 处理数据
recvfrom()用来接收远程主机指定的Socket传送的数据,并将数据由参数buf指向的内存空间。
IO复用模型(同步阻塞)
在IO复用模型中,select、poll、epoll都是多路复用器,这些多路复用器都有各自的不同,IO复用模型的原理就是利用多路复用器监控内核中数据状态,等到数据准备好给多路复用器返回可读条件,然后进程再通过recvfrom()进行系统调用将数据复制等到应用缓冲区,再到复制完成,处理数据。(在此期间是阻塞的),改善了NIO用户进程自己反复轮询调用recvfrom()来查看内核中的数据状态。
IO 多路复用之 select
select多路复用器,它与IO复用模型的基本原理过程是大体相同的,只是它监听的最大连接数有限制,在Linux系统上基本是1024,而且当事件发生后,只直到有几个事件发生,不知道是哪几个,因此当select返回后,需要通过遍历fdset来找到相应的fd,也就是需要遍历所有流。
那么poll解决了监听的最大连接数有限的问题,但是仍需要通过遍历所有的文件描述符来找到就绪的Socket,这样的话如果大量客户端连接上来,就会随着文件描述符的增多而性能降低。
IO 多路复用之 epoll
因为select和poll在某些情况下还存在一些问题,因此有了epoll的诞生。它采用了事件驱动来实现。
epoll()先通过epoll_create在内核中创建一个空间并返回一个文件描述符代表着这个空间,epoll_ctl来注册一个fd文件描述符,存放在这个空间中,一旦某个fd就绪时,内核会采用回调机制,迅速激活这个fd,当线程调用epoll_wait()时,返回的是链表其中是就绪的事件。最后的最后还是需要进程自己去调用recvfrom()进行系统调用内核中的数据,将数据从内核复制到用户空间。
select | poll | epoll | |
---|---|---|---|
底层数据结构 | 数组 | 链表 | 红黑树+双链表 |
获取就绪的fd | 遍历 | 遍历 | 事件回调 |
事件复杂度 | O(n) | O(n) | O(1) |
最大连接数 | 1024 | 无限制 | 无限制 |
fd数据拷贝 | 每次调用select都需要将fd从用户空间调用到内核空间 | 每次调用poll都需要将fd从用户空间调用到内核空间 | 使用内存映射,不需要用户空间频繁拷贝fd到内核空间 |
信号驱动IO模型(同步非阻塞)
信号驱动IO模型不再需要反复确认数据是否准备好,用户进程向内核发送一个信号,然后就去干其他事情,等到内核数据准备就绪时,给用户进程返回一个信号,通知应用进程数据准备好的可读状态。用户进程收到信号后,立刻调用recvfrom()进行系统调用内核数据并进行操作。
但说了这么多其实这些IO模型都还是同步IO模型。他们的共同点就是进程进行系统调用内核中的数据时,进行操作还是会阻塞,也就是在数据从内核复制到应用缓冲的时候,都是阻塞的。而AIO就是真正的异步IO模型。
异步IO模型
同步IO尽管在发起IO请求的阶段进行了改善,由阻塞进程到不阻塞进程,但在数据从内核复制到应用缓冲的时候还是阻塞的。因此我们引入了异步IO模型,它在数据从内核复制到应用缓冲时是不阻塞的。AIO实现了IO全流程的非阻塞,应用进程向内核进行系统调用,内核会立即返回,但返回的并不是处理结果,只是告诉应用进程这个请求提交成功了。内核将数据准备好后,内核将数据从内核复制到应用缓冲,复制完成后递交信号告诉应用进程IO操作执行完毕。
异步IO模型是非阻塞的,它只用向内核发送一次请求,并能立刻得到返回,并不会陷入阻塞状态,主要就是数据状态的询问和数据拷贝进行阻塞,异步IO很好的解决了这个问题。在现实场景中,比如大批量的转账,后端接收到数据可以先告诉前端提交成功,等处理完成后再返回处理的结果。
JavaNIO概述
核心思想
NIO中客户端发送IO请求并不会被阻塞,核心就是注册感兴趣的特定IO事件,当特定事件发生时,就通知用户进程。NIO实现非阻塞IO进程的核心就是Selector(将select、poll、epoll都进行了综合),Selector就是注册各种IO事件的地方。
从图中可以看出,当有读或写等任何注册的事件发生时,可以从Selector中获取SelectionKey,通过得到的SelectionKey得到当前发生的事件和事件所属的通道SelectableChannel,以获得客户端送来的数据。NIO非阻塞是指不阻塞IO事件本身,也就是请求IO时不阻塞,但是select()方法其实是阻塞的。
JavaNIO的核心部分
Java NIO的组成其实还有很多的类和组件,但是核心的就是上图中说到的,Buffer(缓冲区),Channel(通道),Selector(选择器)。
Buffer(缓冲区):本质上就是一个块内存空间,这个内存空间可以写入数据,可以读取数据,Java将这块内存包装成为了Java NIO的对象,并提供了一系列的方法,便于访问和调用。
Buffer(缓冲区)的基本属性有容量(capacity)缓冲区作为一块内存肯定有它的容量大小,容量不能为负,内存容量在创建后就不可以再更改了。限制(limit)表示缓冲区中可以操作数据的大小(limit后的数据不能进行读写),limit不能为负,也不能大于缓冲区的容量。在写入模式时等于buffer的容量,在读入模式时等于缓冲区中数数据所占的大小。位置(position)下一个要读写数据的索引位置,不能为负也不能超过限制大小。标记(mark)是一个记录索引,调用mark()方法可以记录当前的positon。重置(reset)reset()方法可以回到标记的位置,position回到这个位置。
Java NIO中的缓冲区buffer提供了很多不同数据类型的类,以byte buffer来举例,byte buffer可以是两种类型:一种是基于直接内存(非堆内存),另一种是基于非直接内存(堆内存),基于直接内存的IO操作会提高JVM的工作性能,因为是直接作用于本地IO操作。而基于非直接内存的IO操作,因为是在堆内存中,所以需要先从本进程中复制数据到直接内存中,再在本地IO操作。
从数据流的角度来看,非直接内存的作用链:
本地IO---->直接内存---->非直接内存---->直接内存---->本地IO
直接内存的作用链:
本地IO---->直接内存---->本地IO
很明显,在做IO处理时,比如网络发送大量数据时,直接内存会具有更高的效率。直接内存使用allocateDirect创建,但是它比申请普通的堆内存需要耗费更高的性能。不过这部分的数据是在JVM之外的,因此它不会占用应用的内存。所以当你有很大的数据要缓存,并且它的生命周期又很长,那么就比较适合使用直接内存。只是一般来说,如果不是能带来很明显的性能提升,还是推荐直接使用堆内存。字节缓冲区是直接缓冲区还是非直接缓冲区可通过调用其isDirect()方法来确定。
使用场景:存储的数据量大且生命周期很长;网络高并发场景,IO操作频繁
Channel(通道):Channel表示IO源与目标打开的连接,Channel通道类似流,但Channel在读数据和写数据时并不会阻塞,是双向的,流通常读写的单项的。通道是支持读取或者写入缓冲区的,也可以异步地读写,但是是不能直接访问数据的。
常用的Channel实现类
- FileChannel: 用于读取、写入、映射和操作文件的通道。
- DatagramChannel:通过UDP读写网络中的数据通道。
- SocketChannel: 通过TCP读写网络中的数据。
- ServerSocketChannel: 可以监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel。[ServerSocketChanne 类似ServerSocket , SocketChannel类似Socket]
Selector选择器
Selector是Java NIO的组件,可以检查一个或多个NIO通道,负责多个通道看哪个已经将数据准备就绪,这样的话,一个线程就可以负责多个Channel,从而管理多个网络连接,提高IO效率
总结:
- 一个Channel通道对应一个Buffer缓冲区
- 一个Selector对应多个Channel
- Buffer缓冲区是一个内存块,底层是数组
- 程序切换到哪个Channel是由事件决定的
- Selector根据不同的事件,在通道上切换
- 数据中的读取和写入是通过Buffer来进行操作的,Java BIO的读取和写入是通过输入流或者输出流(都是单向流)来实现的。而Java NIO中的Buffer可以读取数据也可以输出数据,是双向的,并不会阻塞。
- 通道是负责连接IO设备的通道,缓冲区是负责存放数据的,操作缓冲区对数据进行处理。简而言之就是Channel负责连接IO设备并传输数据,Buffer负责存取数据。
将数据写出到目标文件中
public class WDemo1 {
public static void main(String[] args) {
try {
//让流与目标文件来连接
FileOutputStream fileout=new FileOutputStream("wdemo1.txt");
//得到流中的通道
FileChannel channel=fileout.getChannel();
//创建缓冲区
ByteBuffer bf= ByteBuffer.allocate(1024);
bf.put("两只老虎爱跳舞,小兔子乖乖拔萝卜!".getBytes());
//切换到写出模式
bf.flip();
channel.write(bf);
channel.close();
System.out.println("写数据成功");
} catch (FileNotFoundException e) {
System.out.println("写数据失败");
e.printStackTrace();
} catch (IOException e) {
System.out.println("写数据失败");
e.printStackTrace();
}
}
}
将目标文件中的数据读取并显示出来
public class RDemo1 {
public static void main(String[] args) {
try {
FileInputStream input=new FileInputStream("wdemo1.txt");
FileChannel channel=input.getChannel();
ByteBuffer bf=ByteBuffer.allocate(1024);
channel.read(bf);
bf.flip();
String s=new String(bf.array(),0,bf.remaining());
System.out.println(s);
} catch (FileNotFoundException e) {
System.out.println("读取失败");
e.printStackTrace();
} catch (IOException e) {
System.out.println("读取失败");
e.printStackTrace();
}
}
}
从一个IO设备中(文件)传输到另一个文件中,要确保它传输的完整,所以clear()方法和flip()方法的使用是很必要的。在读取一部分到缓冲区时需要在操作前清空缓存区中的内容,读取后再写出,写出前也要切换到写出模式就需要调用flip()这个时候limit()会限制到当前数据长度的位置。
public class RWDemo {
public static void main(String[] args) throws Exception {
File input=new File("D:\\Java\\Javapic.png");
File output=new File("D:\\Tend\\Javapic1.png");
FileInputStream is=new FileInputStream(input);
FileOutputStream os=new FileOutputStream(output);
//通过channel与目标文件建立连接
FileChannel cis=is.getChannel();
FileChannel cos=os.getChannel();
//创建一个缓冲区,大小为1024
ByteBuffer bf=ByteBuffer.allocate(1024);
while(true){
//先清空缓存再读取数据到缓存,不然可能会重复
bf.clear();
//先读到缓冲区内
int flag=cis.read(bf);
//缓冲区中无数据了,就退出
if(flag==-1){
break;
}
//切换到写出模式,写出缓冲区
bf.flip();
cos.write(bf);
}
cis.close();
cos.close();
}
}
将文本读到另一个文件下,将数据分散读取(Scatter)到两个缓冲区,再通过聚集写入(Gathering)到Channel中(聚集写入(Gathering)是指将多个Buffer中的数据“聚集”到Channnel,主要是定义多个缓冲区用来做数据分散,再通过一个同类型的Buffer数组来接收这些缓冲区)
分散读取(Scatter)是指Channel通道的数据读入到多个缓冲区中去。
public class RWDemo2 {
public static void main(String[] args) throws Exception {
FileInputStream input=new FileInputStream("wdemo1.txt");
FileOutputStream output=new FileOutputStream("demo2.txt");
FileChannel is=input.getChannel();
FileChannel os=output.getChannel();
//定义两个缓冲区
ByteBuffer bf1= ByteBuffer.allocate(6);
ByteBuffer bf2=ByteBuffer.allocate(1024);
ByteBuffer[] bfs=new ByteBuffer[]{bf1,bf2};
is.read(bfs);
for(ByteBuffer bf:bfs){
bf.flip();
System.out.println(new String(bf.array(),0,bf.remaining()));
}
os.write(bfs);
os.close();
is.close();
}
}
加入Selector的服务端流程
public class ServerDemo {
public static void main(String[] args) {
try {
//当客户端连接服务端时,服务端会通过ServerSocektChannel得到SocketChannel
//也就是获得通道
ServerSocketChannel channel=ServerSocketChannel.open();
//切换非阻塞模式
channel.configureBlocking(false);
//绑定连接
channel.bind(new InetSocketAddress(9999));
//获取选择器
Selector selector= Selector.open();
//将通道注册到选择器上,并指定“监听接收事件”
channel.register(selector, SelectionKey.OP_ACCEPT);
//轮询式的获取选择器上已经“准备就绪”的事件
while(selector.select()>0){
System.out.println("轮一轮");
//获取当前选择器中所有注册的“选择键(已就绪的监听事件)”
Iterator<SelectionKey> it=selector.selectedKeys().iterator();
while(it.hasNext()){
//获取准备“就绪”的事件
SelectionKey sk=it.next();
//判断具体时什么事件准备就绪
if(sk.isAcceptable()){
//若是接收就绪,获得客户端连接
SocketChannel schannel=channel.accept();
//切换非阻塞模式
schannel.configureBlocking(false);
//将该通道注册到选择器上
schannel.register(selector,SelectionKey.OP_READ);
}else if(sk.isReadable()){
//获取当前选择器上“读就绪”状态的通道
SocketChannel schannel=(SocketChannel)sk.channel();
//读取数据
ByteBuffer buf=ByteBuffer.allocate(1024);
int len=0;
while((len=schannel.read(buf))>0){
buf.flip();
System.out.println(new String(buf.array(),0,len));
buf.clear();
}
}
it.remove();
}
}
} catch (IOException e) {
System.out.println("IO连接失败");
e.printStackTrace();
}
}
}
客户端流程
public class ClientDemo {
public static void main(String[] args) {
try {
//获取通道
SocketChannel schannel=SocketChannel.open(new InetSocketAddress("127.0.0.1",9999));
//切换到非阻塞模式
schannel.configureBlocking(false);
//分配指定大小的缓冲区
ByteBuffer buf=ByteBuffer.allocate(1024);
//发送数据给服务器
Scanner scanner=new Scanner(System.in);
System.out.println("请说:");
while(scanner.hasNext()){
String s=scanner.nextLine();
buf.put(("波妞:"+s).getBytes());
buf.flip();
schannel.write(buf);
buf.clear();
}
schannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
在群聊的情况下,客户端和服务端的流程
BIO和NIO的区别
- BIO以流的方式来处理数据,NIO以块的方式来处理数据,块的IO效率比流的IO效率高很多。
- BIO是同步阻塞IO,NIO是同步非阻塞IO,主要区别在于在IO请求的时候NIO不阻塞用户进程,Java中用select()方法进行阻塞。
- BIO是基于字节流和字符流来进行数据操作,NIO 是基于Buffer缓冲区和Channel通道来进行数据处理。数据总是从通道读到缓冲区或者是从缓冲区写入到通道中,Selector用于监听多个通道的事件,这样就可以实现用一个线程完成对多个客户端通道的监听。