关于java NIO的探讨

现在使用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也有很多细节没有探究。期待小伙伴们的交流,谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值