现在使用NIO的场景越来越多,很多技术框架或多或少的使用NIO技术,但是我对其一直没有深入了解过,最近对其做了一些总结,部分观点为个人观点,如有偏颇请您指正。
本文主要想回答这样几个问题:什么是IO?什么是IO操作?什么是NIO?什么是java中的NIO?我先说下我的理解,下文进行分析。
NIO相关概念的定义
I/O: I/O即输入/输出(input/output),输入输出的对象可以是磁盘、硬盘、网口等。
I/O操作: 对I/O的操作即为I/O操作。
按照输入输出是否阻塞可以分为 BIO/NIO/AIO(阻塞的概念会在下文提出)。在java中的BIO操作在java.io包中,NIO操作在java.nio包中。
java.io包中关于I/O的操作主要是流操作,按方向分可以分为输入流、输出流;按数据单元分类可以分为字节流、字符流;还有其他分类方式,这里不做过多探讨。
java.nio包中关于I/O的操作主要是缓冲区操作。
狭义的NIO: 数据从IO设备到内核空间(读场景),或者数据从内核空间到IO设备(写场景),用户进程不被阻塞,用户进程采用轮询方式询问内核是否已传输好数据。这种IO传输方式称为NIO。
广义的NIO: 数据从IO设备到内核空间、内核空间到用户空间(读场景),或者数据从用户空间到内核空间、内核空间到IO设备(写场景),任一阶段用户进程不被阻塞或者阶段不存在即可称为NIO,如果两段都不被阻塞,则称为AIO。
java中的NIO: 面向缓冲区的IO操作。核心操作为基于Channel(通道)和Buffer(缓冲区)进行IO操作,通过Selector(选择区)监听多个通道的事件。
对这些概念的梳理花费了我不少时间,我们先从操作系统层面上来进行理解。下面涉及到操作系统的,都是指32位Linux操作系统,Windows层面上没有研究过。
内核空间与用户空间
以32位的Linux系统为例,虚拟存储器大小为4G,为了保证用户进程不直接操作内核,保证内核的安全,操作系统将虚拟空间划分成两部分。其中最高的1个G字节为内核空间,低位的3个字节为用户空间。每个进程通过系统调用进入内核,内核是由所有进程共享。
Linux网络I/O模型
用户进程无法直接操作I/O设备,必须通过系统调用请求内核来完成I/O动作,内核会为每个I/O设备维护一个buffer。
从这个模型中可以看到,从I/O中获取数据到内核buffer需要时间,从buffer到用户空间也需要时间。
根据这两段时间等待方式的不同,分为BIO/NIO/AIO等。
由此我们解释了【是否阻塞】:从I/O中获取数据到内核buffer这段时间的等待方式;从buffer到用户空间这段时间的等待方式。
【缓冲区】:即buffer,由内核维护的一段空间,用于I/O传输。
下面我们来看下对于这两段时间的不同处理方式:
1、阻塞I/O(Blocking I/O BIO)
当用户调用recvfrom,内核开始第一阶段:等待数据写入buffer完成。从用户进程来看,进程处于阻塞状态。当内核接收完数据,传给用户进程,阻塞状态解除。
因此在BIO中,IO的两个阶段都被阻塞了。
2、非阻塞I/O(Non-Blocking I/O)
当用户进程调用recvfrom时,系统不会阻塞用户进程,而是立刻返回一个ewouldblock错误,从用户进程角度讲 ,并不需要等待,而是马上就得到了一个结果。用户进程判断标志是ewouldblock时,就知道数据还没准备好,于是它就可以去做其他的事了,于是它可以再次发送recvfrom,一旦内核中的数据准备好了。并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
3、I/O复用(I/O Multiplexing)
I/O复用看起来和BIO很类似,但是可以处理多个connection。当用户进程调用select/poll/epoll时,整个进程会被阻塞,内核会【监视】其负责的所有socket,当其中一个socket的数据准备好后,select就会返回,这时候用户进程再调用read操作,将数据从内核拷贝到用户进程。
这里有必要了解一下【监视】这个动作的过程,select/poll/epoll的原理各不相同。
select:对socket进行轮询扫描,维护了一个数据结构用于存放fd(文件描述符)信息,单个进程所能打开的fd是有一定限制的,32位默认1024个。
poll:poll与select类似,也是采用轮询扫描。但是它采用链表来存储fd信息,没有最大连接数的限制。
epoll:采用回调的机制,当socket活跃时才会主动通知,效率不会随着fd数目增加而下降;用事件表来维护fd信息,没有最大连接数的限制。
4、信号驱动I/O(Signal Driven I/O)
用户进程建立SIGIO信号处理程序,通过系统调用sigaction执行信号处理函数,用户进程不会被阻塞。当数据准备好了,系统会为该进程生成一个SIGIO信号,通知用户进程数据已经准备好,用户进程就可以调用recvfrom把数据从内核中拷贝出来。
5、异步I/O(AIO)
与信号驱动IO有些类似,区别在于:信号驱动IO中,内核通知用户进程何时启动IO操作;而在AIO中,内核通知用户进程IO操作何时完成。
关于零拷贝
我们再回到这个网络模型,在用户空间和IO设备之间加入内核空间保护了我们数据的安全性,但某些场景下,是不是有多余的性能损耗呢?上文中我们讨论了几种IO操作,但是都没有回避这两段拷贝,有没有可能减少拷贝呢?
硬件和软件之间的数据传输可以通过使用 DMA 来进行,DMA 进行数据传输的过程中几乎不需要CPU参与,这样CPU就能被释放出去,但是内核空间与用户空间的拷贝是需要CPU参与的。
我们来看一个场景:复制。复制实际上是一个读取数据、写入数据的场景。上面的模型变成这样:
共进行了4次拷贝,其中的第2步和第3步,分别是从内核空间到用户空间、用户空间到内核空间。有没有可优化的空间呢?
一、通过mmap(内存映射)实现
1)mmap系统调用导致文件的内容通过DMA复制到内核缓冲区,该缓冲区会与用户进程共享。
2)write系统调用导致内核将数据从内核缓冲区复制到 socket相关联的内核缓冲区。‘
3)DMA模块将socket缓冲区数据传输给IO设备。
可以看到通过mmap方式,需要3次拷贝。
二、通过sendfile实现
1)sendfile系统调用导致文件内容通过DMA被复制到内核缓冲区。
2)只将文件描述符、偏移量等信息加入到socket缓冲区,DMA模块直接从内核缓冲区将数据传递给IO设备。
通过sendfile方式需要2次拷贝。
java中的NIO
我们对于NIO已经有了一定的储备,再来了解下java中的NIO。
上文有提到JAVA NIO核心操作为基于Channel(通道)和Buffer(缓冲区)进行IO操作,通过Selector(选择区)监听多个通道的事件。
Channel和Stream相比,Channel是双向的,Stream是单向的。每次从Stream中的依次读取一个或者多个字节,这些字节不会被缓存住,也不能前后移动流里面的数据。NIO有Buffer的概念增加了灵活性。同时还有Selector来管理多个通道。
我们设想一下,多个Channel通过Selector管理,有的从Channel读取数据,有的向Channel写数据,这是不是有点似曾相识?
言归正传,Channel的主要实现有FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel,分别对应文件IO、UDP和TCP(Server和Client)。
Buffer实现有:ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分别对应基本数据类型: byte, char, double, float, int, long, short。还有MappedByteBuffer, HeapByteBuffer, DirectByteBuffer等。
关于Buffer,了解一下capacity、position、limit、mark等概念:
capacity:缓冲区数组的总长度
position:下一个要操作的数据元素的位置
limit:缓冲区数组中不可操作的下一个元素的位置:limit<=capacity
mark:用于记录当前position的前一个位置或者默认是-1
案例一,以LongBuffer为例:
/**
* 观察缓冲区基本属性
* position位置:下一个要读写的位置
* limit限制:最大读写限制
* capacity容量:buffer最大数据容量
* flip:从写模式切换到读模式
*/
private static void testBuffer() {
LongBuffer longBuffer = LongBuffer.allocate(10);
System.out.println(longBuffer.position() + " " + longBuffer.limit() + " " + longBuffer.capacity());
longBuffer.put(1L);
longBuffer.put(2L);
System.out.println(longBuffer.position() + " " + longBuffer.limit() + " " + longBuffer.capacity());
longBuffer.flip();
System.out.println(longBuffer.position() + " " + longBuffer.limit() + " " + longBuffer.capacity());
}
执行结果:
0 10 10
2 10 10
0 2 10
案例二、FileChannel与传统IO对比:
/**
* 使用文件流写文件
*/
private static void testFileOutputStream() {
String info = "1111111111";
File file = new File("d:/testFileOutputStream.txt");
FileOutputStream output = null;
BufferedOutputStream bufferedOutputStream = null;
Date begin = new Date();
try {
output = new FileOutputStream(file);
bufferedOutputStream = new BufferedOutputStream(output);
for(int i = 0;i< 500000;i++){
bufferedOutputStream.write(info.getBytes());
}
Date end = new Date();
System.out.println((end.getTime() - begin.getTime()));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (output != null) {
try {
output.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
/**
* 使用FileChannel写文件
* channel通道负责传输,Buffer负责存储
*/
private static void testChannel() {
String info = "1111111111";
File file = new File("d:/testChannel.txt");
FileOutputStream output = null;
FileChannel fout = null;
try {
Date begin = new Date();
output = new FileOutputStream(file);
fout = output.getChannel();
ByteBuffer buf = ByteBuffer.allocate(5000000);
for(int i = 0;i< 500000;i++){
buf.put(info.getBytes());
}
buf.flip();
fout.write(buf);
Date end = new Date();
System.out.println((end.getTime() - begin.getTime()));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fout != null) {
try {
fout.close();
} catch (Exception e) {
e.printStackTrace();
}
}
if (output != null) {
try {
output.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
/**
* 测试MappedByteBuffer
*/
private static void testMappedByteBuffer() {
String info = "1111111111";
RandomAccessFile output = null;
FileChannel fout = null;
try {
Date begin = new Date();
output = new RandomAccessFile("d:/testChannel1.txt","rw");
fout = output.getChannel();
MappedByteBuffer buf1 = fout.map(FileChannel.MapMode.READ_WRITE, 0, 5000000);
for(int i = 0;i< 500000;i++){
buf1.put(info.getBytes());
}
buf1.flip();
Date end = new Date();
System.out.println((end.getTime() - begin.getTime()));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fout != null) {
try {
fout.close();
} catch (Exception e) {
e.printStackTrace();
}
}
if (output != null) {
try {
output.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
执行结果:
96
68
38
测试文件5M,可以看到,用ByteBuffer效率是传统IO1.4倍,用MappedByteBuffer是传统IO的2.5倍。大文件更加明显。
ByteBuffer的具体的实现有HeapByteBuffer和DirectByteBuffer,分别对应Java堆缓冲区与对外内存缓冲区,封装了对byte数组的操作。
MappedByteBuffer利用了mmap原理,减少了拷贝。
案例三、SocketChannel与Selector
客户端代码:
package nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.concurrent.TimeUnit;
/**
* @author wuyc
* @version 0.0.1
* @date 2019/3/21
*/
public class TestClient {
public static void main(String[] args){
client();
}
public static void client(){
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel socketChannel = null;
try
{
//打开连接
socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("192.168.39.236",8080));
if(socketChannel.finishConnect())
{
int i=0;
while(true)
{
TimeUnit.SECONDS.sleep(1);
String info = "I'm "+i+++"-th information from client";
buffer.clear();
buffer.put(info.getBytes());
buffer.flip();
while(buffer.hasRemaining()){
System.out.println(buffer);
socketChannel.write(buffer);
}
}
}
}
catch (IOException | InterruptedException e)
{
e.printStackTrace();
}
finally{
try{
if(socketChannel!=null){
//关闭连接
socketChannel.close();
}
}catch(IOException e){
e.printStackTrace();
}
}
}
}
服务端代码:
package nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
/**
* @author wuyc
* @version 0.0.1
* @date 2019/3/12
*/
public class TestServer {
private static final int BUF_SIZE=1024;
private static final int PORT = 8080;
private static final int TIMEOUT = 3000;
public static void main(String[] args)
{
selector();
}
public static void handleAccept(SelectionKey key) throws IOException{
ServerSocketChannel ssChannel = (ServerSocketChannel)key.channel();
SocketChannel sc = ssChannel.accept();
//设置非阻塞
sc.configureBlocking(false);
//将Channel注册到Selector上
sc.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocateDirect(BUF_SIZE));
}
public static void handleRead(SelectionKey key) throws IOException{
SocketChannel sc = (SocketChannel)key.channel();
ByteBuffer buf = (ByteBuffer)key.attachment();
long bytesRead = sc.read(buf);
while(bytesRead>0){
buf.flip();
while(buf.hasRemaining()){
System.out.print((char)buf.get());
}
System.out.println();
buf.clear();
bytesRead = sc.read(buf);
}
if(bytesRead == -1){
sc.close();
}
}
public static void handleWrite(SelectionKey key) throws IOException{
ByteBuffer buf = (ByteBuffer)key.attachment();
buf.flip();
SocketChannel sc = (SocketChannel) key.channel();
while(buf.hasRemaining()){
sc.write(buf);
}
buf.compact();
}
public static void selector() {
Selector selector = null;
ServerSocketChannel ssc = null;
try{
//
selector = Selector.open();
//打开连接
ssc= ServerSocketChannel.open();
//绑定
ssc.socket().bind(new InetSocketAddress(PORT));
//设置非阻塞,只有这样才能和Selector结合使用
ssc.configureBlocking(false);
//将Channel注册到Selector上
ssc.register(selector, SelectionKey.OP_ACCEPT);
while(true){
if(selector.select(TIMEOUT) == 0){
System.out.println("==");
continue;
}
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while(iter.hasNext()){
SelectionKey key = iter.next();
if(key.isAcceptable()){
//监听连接
handleAccept(key);
}
if(key.isReadable()){
handleRead(key);
}
if(key.isWritable() && key.isValid()){
handleWrite(key);
}
if(key.isConnectable()){
System.out.println("isConnectable = true");
}
iter.remove();
}
}
}catch(IOException e){
e.printStackTrace();
}finally{
try{
if(selector!=null){
selector.close();
}
if(ssc!=null){
//关闭连接
ssc.close();
}
}catch(IOException e){
e.printStackTrace();
}
}
}
}
总结一下JAVA NIO开发服务端程序的步骤:
1、创建 ServerSocketChannel 和业务处理线程池。
2、绑定监听端口,并配置为非阻塞模式。
3、创建 Selector,将之前创建的 ServerSocketChannel 注册到 Selector 上,监听 SelectionKey.OP_ACCEPT。
4、循环执行 Selector.select() 方法,轮询就绪的 Channel。
5、轮询就绪的 Channel 时,如果是处于 OP_ACCEPT 状态,说明是新的客户端接入,调用 ServerSocketChannel.accept 接收新的客户端。
6、设置新接入的 SocketChannel 为非阻塞模式,并注册到 Selector 上,监听 OP_READ。
7、如果轮询的 Channel 状态是 OP_READ,说明有新的就绪数据包需要读取,则构造 ByteBuffer 对象,读取数据。
未尽的探讨
java NIO的API使用起来还是比较复杂的,考虑到 客户端频繁的接入和断开、网络闪断、半包读写、失败缓存、网络阻塞等问题,想要用JAVA NIO编写高可靠性的代码并不是一件容易的事情。这也会引导我们继续学习新的内容–Netty,关于Netty相关的知识后续可能会进行总结。
关于NIO的讨论还有很多点没有涉及,操作系统层面还有很多细节本文没有描述透彻,JAVA NIO也有很多细节没有探究。期待小伙伴们的交流,谢谢!