Java中IO模型。
常用的四种IO模型
同步阻塞IO
在Java应用程序进程中,默认情况下,所有的socket连接的IO操作都是同步阻塞IO(BlockingIO);在阻塞式IO模型中,Java应用程序从IO系统调用开始,直到系统调用返回,在这段时间内,Java进程是阻塞的。返回成功后,应用进程开始处理用户空间的缓存区数据。
在Java中发起一个socket的read读操作的系统调用,流程大致如下:
- 从Java启动IO读read系统调用开始,用户线程就进入阻塞状态。
- 当系统内核收到read系统调用,就开始准备数据。一开始,数据可能还没开始到达内核缓冲区(例如,还没有收到一个完整的socket数据包),这个时候内核就要等待。
- 内核一直等到完整的数据到达,就会将数据从内核缓冲区中复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到用户缓冲区中的字节数)。
- 直到内核返回后,用户线程才会接触阻塞的状态,重新运行起来。
总之,阻塞IO的特点是:在内核进行IO执行的两个阶段,用户线程都被阻塞了。
同步非阻塞NIO
socket连接默认是阻塞模式,在Linux系统下,可以通过设置将socket变成为非阻塞的模式(Non-Blocking)。使用非阻塞模式的IO读写,叫作同步非阻塞IO(None Blocking IO),简称为NIO模式。在NIO模型中,应用程序一旦开始IO系统调用,会出现以下两种情况:
- 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以,为了读取到最终的数据,用户线程需要不断地发起IO系统调用。
- 在内核缓冲区中有数据的情况下,是阻塞的,直到数据从内核缓冲复制到用户进程缓冲。复制完成后,系统调用返回成功,应用进程开始处理用户空间的缓存数据。
举个例子。发起一个非阻塞socket的read读操作的系统调用,流程如下:
- 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以,为了读取到最终的数据,用户线程需要不断地发起IO系统调用。
- 内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到的用户缓冲区的字节数)。
- 用户线程读到数据后,才会解除阻塞状态,重新运行起来。也就是说,用户进程需要经过多次的尝试,才能保证最终真正读到数据,而后继续执行。同步非阻塞IO的特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止。同步非阻塞IO的优点:每次发起的IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。同步非阻塞IO的缺点:不断地轮询内核,这将占用大量的CPU时间,效率低下。
IO多路复用模型
举个例子来说明IO多路复用模型的流程。发起一个多路复用IO的read读操作的系统调用,流程如下:
- 选择器注册。在这种模式中,首先,将需要read操作的目标socket网络连接,提前注册到select/epoll选择器中,Java中对应的选择器类是Selector类。然后,才可以开启整个IO多路复用模型的轮询流程。
- 就绪状态的轮询。通过选择器的查询方法,查询注册过的所有socket连接的就绪状态。通过查询的系统调用,内核会返回一个就绪的socket列表。当任何一个注册过的socket中的数据准备好了,内核缓冲区有数据(就绪)了,内核就将该socket加入到就绪的列表中。当用户进程调用了select查询方法,那么整个线程会被阻塞掉。
- 用户线程获得了就绪状态的列表后,根据其中的socket连接,发起read系统调用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
- 复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了数据,继续执行。
IO多路复用模型的优点:与一个线程维护一个连接的阻塞IO模式相比,使用select/epoll的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接(Connection)。系统不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。Java语言的NIO(New IO)技术,使用的就是IO多路复用模型。在Linux系统上,使用的是epoll系统调用。
IO多路复用模型的缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。
异步IO模型 - AIO (Asynchronous IO)
在异步IO模型中,在整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓冲区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。
发起一个异步IO的read读操作的系统调用,流程如下:
- 当用户线程发起了read系统调用,立刻就可以开始去做其他事情,用户线程不阻塞。
- 内核就开始了IO的第一个阶段:准备数据。等到数据准备好了,内核就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存)。
- 内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调接口,告诉用户线程read操作完成了。
- 用户线程读取用户缓冲区的数据,完成后续的业务操作。
异步IO模型的特点:在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接收内核的IO操作完成的事件,或者用户线程需要注册一个IO操作完成的回调函数。正因为如此,异步IO有的时候也被称为信号驱动IO。
异步IO异步模型的缺点:应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。
JAVA NIO 模型
Java NIO由以下三个核心组件组成:
- Channel (通道)
- Buffer (缓冲区)
- Selector (选择器)
从Java 1.4版本之后,Java的IO类库从阻塞IO升级为了非阻塞IO,即-JAVA NIO(New IO),底层使用的是IO多路复用模型。
NIO与OIO的区别,主要体现在三个方面:
- OIO是面向流的,NIO是面向缓冲区的。
OIO操作中,我们以流式的方式顺序地从一个流(stream)中读取字节,不能随意改变读取指针的位置。在NIO中,引入了Channel和Buffer的概念,读取和写入只需要从通道中读取数据到缓冲区,或将数据从缓冲区中写入到通道中。
- OIO的操作是阻塞的,而NIO的操作是非阻塞的。
OIO的阻塞体现在调用一个read方法读取一个文件内容,那么调用read的线程会被阻塞,直到read操作完成。
- OIO没有选择器,而NIO是有选择器的概念的。
NIO的实现,是基于底层的选择器的系统调用。NIO的选择器,需要底层操作系统提供支持, 而OIO不需要用到选择器。
NIO Buffer类
Buffer类是一个非线程安全的类,Buffer类是一个抽象类,对应于Java的主要数据类型,在NIO中有8种缓冲区类,分别如下:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedByteBuffer。前7种Buffer类型,覆盖了能在IO中传输的所有的Java基本数据类型。第8种类型MappedByteBuffer是专门用于内存映射的一种ByteBuffer类型。
Buffer类的属性:
capacity (容量): capacity容量指的是写入的数据对象的数量;
positiohn (读写位置): 缓冲区中喜爱一个要被读或者写的元素的索引;
limit (上限): 缓冲区当前的数据量;
mark (标记):调用mark()方法设置mark=position,再调用reset()可以让position恢复到mark标记的位置即postion=mark;
NIO Buffer的操作方法
allocate()
创建缓冲区put()
写入到缓冲区;要写入缓冲区,需要调用put方法。put方法很简单,只有一个参数,即为所需要写入的对象。不过,写入的数据类型要求与缓冲区的类型保持一致。flip()
读写模式反转;调用flip方法后,之前写入模式的position的值会变成可读上限的值,新的读取模式下的position,会变成0,表示从头开始读取。清除之前的mark标记,因为mark保存的是写模式下的临时位置get()
从缓冲区读取;读取操作会改变可读位置position的值,而limit值不会改变。如果position值和limit的值相等,表示所有数据读取完成,position指向了一个没有数据的元素位置,已经不能再读了。此时再读,会抛出BufferUnderflowException异常。rewind()
数据倒带;已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind调整了缓冲区position属性,position重置为0,可以重读缓冲区中所有的数据,limit保持不变,数据量还是一样的,仍然表示能从缓冲区中读取多少个元素。mark()
和reset()
mark()将当前的position的值保存起来,放到mark属性中,reset()方法将mark的值恢复到position中。clear()
清空缓冲区;在读取模式下,调用clear方法将缓冲区切换为写入模式,此方法会将position清零,limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。
NIO Channel类
最为重要的四种Channel(通道)实现:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。
FileChannel文件通道
- 获取FileChannel文件通道
1
2
3
4
5
6
7
8
9// 创建文件输入流
FileInputStream fileInputStream = new FileInputStream("filePath");
// 获取文件流的通道
FileChannel inChannel = fileInputStream.getChannel();
// 创建文件输出流
FileOutputStream fileOutputStream = new FileOutputStream("filePath");
// 获取文件流通道
FileChannel outChannel = fileOutputStream.getChannel(); - 读取FileChannel通道
在大部分应用场景从通道读取数据都会调用通道的int read(ByteBufferbuf)方法,它从通道读取到数据写入到ByteBuffer缓冲区,并且返回读取到的数据量。
1 | // 获取一个字节缓冲区 注意,新建的ByteBuffer默认是写入模式。在读取数据时需要调用flip或者clear方法切换 |
- 写入FileChannel通道
写入数据到通道,在大部分应用场景,都会调用通道的int write(ByteBufferbuf)方法。此方法的参数——ByteBuffer缓冲区,是数据的来源。write方法的作用,是从ByteBuffer缓冲区中读取数据,然后写入到通道自身,而返回值是写入成功的字节数。
1 | byteBuffer.flip(); |
- 关闭通道
当通道使用完成后,必须将其关闭。
1 | channel.close(); |
- 强制刷新到磁盘
由于性能原因,要保证写入的通道的缓存数据最终都写入磁盘,要调用FileChannel的force()方法。
1 | channel.force(true); |
SocketChannel套接字通道/ServerSocketChannel
在NIO中,涉及网络连接的通道有两个,一个是SocketChannel负责连接传输,一个ServerSocketChannel负责连接监听。
NIO的SocketChannel对应OIO的Socket类, 一般同时位于服务器端和客户端。对应于一个连接,两端都有一个负责传输的SocketChannel。
NIO的ServerSocket对应OIO的ServerSocket类,一般位于服务器端。
无论是SocketChannel还是ServerSocketChannel都支持阻塞和非阻塞两种模式,调用configureBlocking方法。socketChannel.configureBlocking(false)设置为非阻塞模式,socketChannel.configureBlocking(true)设置为阻塞模式。
- 获取SocketChannel传输通道
1
2
3
4
5
6
7
8
9
10
11
12
13/* 客户端 */
// 获得一个套接字传输通道
SocketChannel socketChannel = SocketChannel.open();
// 设置为非阻塞模式
socketChannel.configureBlocking(false);
// 对服务器的IP和端口发起连接
socketChannel.connect(new InetSocketAddress("127.0.0.1", 80));
```java
非阻塞情况下,与服务器的连接可能还没有真正建立,socketChannel.connect方法就返回了,因此需要不断地自旋,检查当前是否是连接到了主机:
```java
while(!socketChannel.finishConnect()) {
//...
}1
2
3
4
5
6
7/* 服务器端 */
// 通过事件,获取服务器监听通道
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
// 获取新连接的套接字通道
SocketChannel socketChannel = serverSocketChannel.accept();
// 设置为非阻塞模式
socketChannel.configureBlocking(false); - 读取SocketChannel传输通道
1
2
3
4// 获取一个字节缓冲区 - 写入模式
ByteBuffer byteBuffer = ByteBuffer.allocate(20);
// 如果返回-1,表示读取到了对方的输出结束标志
int read_length = socketChannel.read(byteBuffer); - 写入到SocketChannel传输通道
1
2
3// 默认的写入模式切换为读取模式
byteBuffer.flip();
socketChannel.write(buffer);
关闭SocketChannel传输通道
1 | // 终止输出方法,向对方发送一个输出的结束标志 |
DatagramChannel数据报通道
DatagramChannel是采用UDP进行传输的面向非连接的协议,只要直到服务器的IP和端口,就可以直接向对方发送数据。
- 获取DatagramChannel数据报通道
1 | // 获取通道 |
读取DatagramChannel数据报通道数据
1
2ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
SocketAddress address = datagramChannel.receive(byteBuffer);写入DatagramChannel数据通道
1
2
3
4
5
6// 把缓冲区切换到读取模式
byteBuffer.flip()
// 调用send方法,把数据发送到目标IP和端口
datagramChannel.send(byteBuffer, new InetSocketAddress(NIODemoConfig.SOCKET_SERVER_IP, NIODemoConfig.SOCKET+SERVER_PORT));
// 清空缓冲区,切换到写入模式
byteBuffer,clear();关闭DatagramChannel数据报通道
1 | datagramChannel.close(); |
NIO Selector 选择器
选择器的作用是完成IO的多路复用,一个通道代表一个连接通路,通过选择器可以同时监控多个通道的IO状况,选择器和通道的关系,是监控和被监控的关系。
通道和选择器之间的关系,通过register(注册
)的方式完成。调用通道的Channel.register(Selector selector, int ops)
方法,可以将通道实例注册到一个选择器中。
- Selector selector: 指定通道注册到的选择器实例;
- int operation 指定选择器要监控的IO事件类型。可供选择器监控的通道IO事件类型,包括以下四种:
- 可读就绪:SelectionKey.OP_READ
- 可写就绪:SelectionKey.OP_WRITE
- 连接就绪:SelectionKey.OP_CONNECT
- 接收就绪:SelectionKey.OP_ACCEPT
除了FileChannel文件通道外,其他选择器都是可选择的。这是因为其他三个通道都继承了一个SelectableChannel这个抽象类。
- SelectionKey选择键: 指的是被选择器选中的IO事件;一个IO事件发生(就绪状态达成)后,如果之前在选择器中注册过,就会被选择器选中,并放入SelectionKey选择键集合;如果之前没有注册过,即使发生了IO事件,也不会被选择器选中。
选择器使用流程:
- 获取选择器实例
Selector选择器的类方法open()的内部,是向选择器SPI(SelectorProvider)发出请求,通过默认的SelectorProvider(选择器提供者)对象,获取一个新的选择器实例。1
2// 调用静态工厂方法open()来获取
Selector selector = Selector.open(); - 将通道注册到选择器中
其次,还需要注意:一个通道,并不一定要支持所有的四种IO事件。例如服务器监听通道ServerSocketChannel,仅仅支持Accept(接收到新连接)IO事件;而SocketChannel传输通道,则不支持Accept(接收到新连接)IO事件。如何判断通道支持哪些事件呢?可以在注册之前,可以通过通道的validOps()
方法,来获取该通道所有支持的IO事件集合。1
2
3
4
5
6
7
8// 获取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 绑定连接
serverSocketChannel.bind(new InetSocketAddress(SystemConfig.SOCKET_SERVER_PORT));
// 将通道注册到选择器上,并制定监听事件为接收就绪事件
serverSocketChannel.register(Selector, SelectionKey.OP_ACCEPT); - 轮询感兴趣的IO就绪事件
通过Selector选择器的select()方法,选出已经注册的、已经就绪的IO事件,保存到SelectionKey选择键集合中, 该方法返回int类型的IO事件通道数量
;SelectionKey集合保存在选择器实例内部,是一个元素为SelectionKey类型的集合(Set)。调用选择器的selectedKeys()方法,可以取得选择键集合。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 轮询,选择IO就绪事件 有多个重载的实现方法
while (selector.select() > 0) {
Set selectKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 根据具体IO事件类型,执行对应的业务逻辑
if(key.isAcceptable()) {
// IO事件:ServerSocketChannel服务器监听通道有新连接
} else if (key.isConnectable()) {
// IO事件:传输通道连接成功
} else if (key.isReadable()) {
// IO事件:传输通道可读
} else if (key.isWritable()) {
// IO事件:传输通道可写
}
//处理完成后,移除选择键
keyIterator.remove();
}
}