NIO学习笔记

一、学习背景

        最近在做监控系统,前端页面数据通过socket长连接的方式,后端定时分用户推送数据到前端页面。在做的过程中,深感此实现方案的不爽,每个页面的打开,需要后台启动一个线程,实现长连接,线程阻塞。所以想学习下NIO的socketChannel方式,非阻塞的方式,实现前后端socket连接,并实现数据的推送。

二、NIO概述

        NIO主要有三大核心:Channel(通道)、Buffer(缓冲区)、Selector(选择器)。传统的IO基于字节流和字符流进行操作,一条线程在读完流中所有数据之前,是阻塞状态。NIO通过Channel和Buffer进行操作,数据总是从通道读取到缓存区Buffer,或者从缓存区Buffer写入到通道。Selector(选择器)用户监听多个通道事件,实现单线程可以监听多个通道,不需要再为每个通道开启一条线程。

        传统的IO是面对流的,NIO是面对缓冲区的,这是IO和NIO的最大区别。传统IO调用read()或者write()方法时,线程是阻塞状态,知道有一些数据被读取,或者数据完全写入。NIO是非阻塞的,一个线程对某个通道发出读取数据请求,它仅能获得目前可用的数据,写入到Buffer的数据,如果没有数据可用时,是什么也获取不到,不会让线程阻塞着,所以直至数据变得可读取之前,线程可以干其它事情。

2.1 Channel

        NIO中的Channel类似于IO中的stream,只不过Channel是双向的。NIO中主要的Channel有:

  • FileChannel——文件的IO
  • DatagramChannel——UDP报文
  • SocketChannel——TCP的客户端
  • ServerSocketChannel——TCP的服务端

2.2 Buffer

        缓冲区,NIO中的Buffer分为:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer,分别对应着基本数据类型:byte、char、double、float、int、long、short。

2.3 Selector

        选择器,NIO实现单线程管理多个Channel的关键,Channel会注册到Selector上,调用Selector的select()方法,可以循环遍历所有注册的Channel,获取Channel事件,以达到单线程监听多个Channel。

        由以上Selector的实现原理,可以知道,Selector适合响应消息短,流量低的连接,如果响应消息很长,很耗时间,会造成其它Channel得不到及时响应,影响整体的处理效率。

三、代码实现说明

3.1 FileChannel

        文件类型的管道。通过传统IO读取文件信息代码实现和FileChannel+Buffer读取文件信息的代码实现,来说明FileChannel工作原理。

3.1.1 FileInputStream传统IO实现方式

        实现方式可以看出,整个文件需要先加载到内存,变成BufferedInputStream,不适合大文件的读取。

public class IOTest {

    public static void main(String[] args){

        String filePath = "D:\\cc\\niotest\\nomal_io.txt";

        File file  =new File(filePath);

        if (!file.exists()){
            System.out.println("文件不存在");
            return;
        }

        FileInputStream fileInputStream = null;

        InputStream inputStream = null;

        try {
            fileInputStream = new FileInputStream(filePath);
            inputStream = new BufferedInputStream(fileInputStream);

            byte[] buf = new byte[1024];

            int bytesRead = inputStream.read(buf);

            while (bytesRead != -1){
                for (int i=0;i<bytesRead;i++){
                    System.out.print((char)buf[i]);
                }

                bytesRead = inputStream.read(buf);
            }
        } catch (IOException e){
            e.printStackTrace();
        }finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            if (fileInputStream != null) {
                try {
                    fileInputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

3.1.2 NIO读取文件

public class NioTest {

    private static final String FILE_PATH = "D:\\cc\\niotest\\nomal_io.txt";

    public static void main(String[] args){

        FileInputStream inputStream = null;

        try {
            inputStream = new FileInputStream(FILE_PATH);

            //获取文件的管道
            FileChannel fileChannel = inputStream.getChannel();

            //设置缓冲区
            ByteBuffer buffer = ByteBuffer.allocate(1024);

            //通过管道读取数据到缓冲池
            int bytesRead = fileChannel.read(buffer);

            while (bytesRead != -1){

                //切换缓冲池工作模式,需要缓冲池中的数据往外输出
                buffer.flip();

                while (buffer.hasRemaining()){
                    byte one =  buffer.get();
                    System.out.print((char)one);

                }


                buffer.clear();
                bytesRead = fileChannel.read(buffer);
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

3.2 Buffer

        缓冲池,从3.1中NIO的示例代码可以看出,Buffer在使用时,一般有一下步骤:

  • 分配空间(ByteBuffer buffer = ByteBuffer.allocate(1024),还有allocateDirector后面再说)
  • 写入数据到Buffer,(int byteReads = fileChannel.read(buffer),其中byteReads标识读取到数据的大小,-1标识文件读取完成)
  • 调用flip()方法 (标识缓冲池buffer由写入状态切换到输出状态,可调用get()方法获取)
  • 调用clear()方法或者compact()方法,调整buffer池开始写入数据的索引,参见3.2.2小节。

        Buffer缓冲池,实际上是一个连续的数组,Channel从文件、网络读取数据到Buffer池,池可以通过get()方法取出数据,或者供其他Channel再次读取。

        向Buffer中写数据

  • 从Channel中写数据到Buffer——channel.read(buffer)
  • 通过Buffer的put()方法——buffer.put() 

         从Buffer中读取数据

  • 从Buffer中读取数据到Channel——channel.write(buf)
  • 使用get()方法获取buffer数据——buffer.get() 

 3.2.1 理解Buffer工作原理

        Buffer缓冲区,主要通过四个变量(索引)来记住区中数据的位置,以达到不同通道的读取,以及重复读取。

        四个变量分别是:

索引说明
capacity缓冲区数组的总长度
position下一个要操作的数据元素的位置
limit缓冲区数组中不可操作的下一个元素的位置,limit<=capacity
mark用来记录当前position的前一个位置,默认值为-1

        示例说明,ByteBuffer.allocate(11),通过该代码创建一个大小为11的数组缓冲区,初始时,以上索引的位置如下图,position为0,capacity和limit默认都是数组长度11。

        当写入5个字节时,变化如下图

         这时,需要将buffer中的数据写入到一个Channel,调用buffer.flip()方法后,以上索引变化如下图

        当5个字符都写入到Channel后,调用buffer.clear()方法,清理缓冲区,各索引又回到初始位置,就可以继续向缓冲区中写数据了。 

 3.2.2 buffer移动position索引的两种方法clear和compact区别

        首先,buffer.clear(),真如字面意思,buffer中的数据已经被清理掉了吗?答案是否定的,只是将position索引置为0,可以从0索引位置开始写数据,其实buffer中的数据并没有被真的清理,只是在buffer写入时,被覆盖了。

        clear()方法和compact()方法有什么区别?

        还是从需求上来看这两个方法的区别。上面的例子,写入的5个数据,通过mark控制,只读取了3个数据,其他两个数据没有读取,这时向先向buffer中写一波数据,如果使用clear(),position索引移动到0位置,就不能在读取没有读过的数据了。

        所以有了compact()方法,使用该方法,会将未读数据先拷贝到buffer的起始位置,将position置为最后一个未读元素的后面。

骚操作:

        可以使用buffer.mark()方法,标记Buffer中一个特定的position,之后可以使用Buffer.reset()恢复到mark标记的position.

        可以使用Buffer.rewind()方法,将position重新设置为0,实现数据重新读取。

3.3 SocketChannel

        NIO的强大功能,部分来自于Channel的非阻塞性,传统的socket可能会无限期的阻塞,比如调用accept()方法时,可能会等待一个客户端的连接而阻塞。对于read()方法,可能会因为没有数据可读而阻塞,直到连接端传来数据。但是在调用方法之前,并不清楚其是否阻塞,NIO的Channel一个重要特性,可以通过配置其非阻塞行为,实现非阻塞式的通信。

channel.configureBlocking(false)

        在非阻塞式信道上调用一个方法,总是会立即返回,调用的返回值,可以标识指示了所请求的操作完成程度。比如一个非阻塞式的ServerSocketChannel上调用accept()方法,如果没有客户端连接过来,立即返回null,如果有客户端连接过来,就返回SocketChannel对象。

3.3.1 代码样例说明

        为了说明非阻塞式信道和阻塞式信道的区别,样例代码在客户端使用NIO实现,服务端分别使用传统IO实现和NIO实现两种方式。

        client实现,采用NIO非阻塞方式

        注意,在Buffer中数据写入Channel时,因为一次没有办法保证Buffer中多少数据写到Channel,所以需要while循环判断Buffer中是否还有数据没有写入到Channel。

public class Client {

    public static void main(String[] args){
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        SocketChannel socketChannel = null;

        try {
            socketChannel = SocketChannel.open();
            socketChannel.configureBlocking(false);

            socketChannel.connect(new InetSocketAddress("127.0.0.1",8080));

            if (socketChannel.finishConnect()){
                int i = 0;
                while (true){
                    TimeUnit.SECONDS.sleep(5);
                    String info = "I'm "+i+++"-th information from client";

                    buffer.put(info.getBytes());
                    buffer.flip();
                    while (buffer.hasRemaining()){
                        socketChannel.write(buffer);
                    }

                    buffer.clear();

                }
            }
        } catch (IOException | InterruptedException e){
            e.printStackTrace();
        } finally {
            if (socketChannel != null) {
                try {
                    socketChannel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

         server端采用传统IO阻塞方式实现,serverSocket.accept()方法会阻塞到有client接入。

public class ServerByIO {

    public static void main(String[] args){
        ServerSocket serverSocket = null;

        InputStream in = null;

        try {
            serverSocket = new ServerSocket(8080);

            int recvMsgSize = 0;

            byte[] recvBuf = new byte[1024];
            while (true){

                //没有客户端接入,会一直阻塞
                Socket clnSocket = serverSocket.accept();
                SocketAddress address = clnSocket.getRemoteSocketAddress();
                System.out.println("Handling client at "+address);
                in = clnSocket.getInputStream();

                while ((recvMsgSize = in.read(recvBuf)) != -1){
                    byte[] temp = new byte[recvMsgSize];
                    System.arraycopy(recvBuf,0,temp,0,recvMsgSize);
                    System.out.println(new String(temp));
                }
            }
        } catch (IOException e){
            e.printStackTrace();
        } finally {

            if (serverSocket != null) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

        服务端的NIO非阻塞式实现,获取client的socketChannel,设置其非阻塞模式,在调用read方法时,就不会阻塞,会立即返回,大小为0时,说明客户端还在休眠,没有写入数据到Channel。

public class ServerByNIO {

    public static void main(String[] args) {

        ServerSocketChannel ssc = null;

        ByteBuffer buffer = ByteBuffer.allocate(1024);

        try {
            ssc = ServerSocketChannel.open();

            ssc.socket().bind(new InetSocketAddress(8080));

            ssc.configureBlocking(false);

            while (true) {
                SocketChannel clientSocket = ssc.accept();

                if (clientSocket == null) {
                    System.out.println("===");
                    TimeUnit.SECONDS.sleep(5);
                    continue;
                }

                //新的通道对象设置非阻塞模式
                clientSocket.configureBlocking(false);

                SocketAddress address = clientSocket.getRemoteAddress();

                System.out.println("handling client at " + address.toString());

                while (true){

                    int recvSize = clientSocket.read(buffer);

                    if (recvSize == 0){
                        System.out.println("还没有数据写入clientSocket通道");

                        TimeUnit.SECONDS.sleep(1);

                        continue;
                    }

                    buffer.flip();

                    while (buffer.hasRemaining()) {
                        System.out.print((char) buffer.get());
                    }
                    buffer.clear();
                }
            }

        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (ssc != null) {
                try {
                    ssc.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

3.4 Selector

        好东西放到最后,Selector闪亮登场。NIO能够可以用单个线程处理多个Channel,Selector功不可没。每个Channel都注册到Selector上,通过遍历注册到Selector上的Channel的准备事件,进行相应的处理,实现单条线程管理多个Channel。

        这样避免线程之间切换,每个Channel启用一个线程而带来的系统开销。

3.4.1 Selector创建

        selector创建很简单,是一个静态工厂方法,直接调用Selector.open()方法即可创建。

Selector selector = Selector.open()

3.4.2 向Selector注册通道

        为了将Channel和Selector配置使用,需要将Channel注册到Selector上,使用方法如下:

//必须开启Channel的非阻塞模式,注意FileChannel开启不了非阻塞模式,所以使用不了Selector
channel.configureBlocking(false);
SelectionKey key = channel.register(selector,SelectionKey.OP_READ)

       Channel和Selector一起使用时,Channel必须处于非阻塞模式,很好理解,如果处于阻塞模式,其他的Channel基本很难被轮询到。

        注意register()第二个参数,这是一个"interest集合",意味着Selector监听Channel时,对什么事件感兴趣,可以监听四类事件:

  • Connect
  • Accept
  • Read
  • Write

        通道触发了一个事件,意思是该事件已经就绪。所以

        某个Channel成功连接到另一个服务器为"连接就绪";一个server socket channel准备好接收新进入的连接,成为"接收就绪";一个数据可读的通道,可以说是"读就绪";等待写数据的通道是"写就绪"。

        四种事件用四个常量来标识,即SelectionKey.OP_CONNECT、SelectionKey.OP_ACCEPT、SelectionKey.OP_READ、SelectionKey.OP_WRITE。

        解释下什么是"interest集合",如上四个事件,按顺序,分别对应着 8、16、1、4(可看源码位移运算) ,如果想关注connet和accept,直接对应的两个数值和即可,即24。或者通过位或计算得到,如下:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

3.4.2 SelectionKey

        SelectionKey,是Channel注册到Selector选择器上后返回的对象,记录了Channel的基本信息。存储的信息包括:

  • interest集合,记录了注册时设置的监听事件
  • ready集合,可记录Channel已经准备好的事件
  • Channel
  • Selector
  • 附加信息,一般附加缓冲区,实现客户端和服务端通道对接双向通信。

        (1)interest集合

        注册时,关注事件的集合,通过selectionKey.interestOps()方法可以获取,具体关注的事件,可以通过与运算来判断。

int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept  = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = (interestSet & SelectionKey.OP_CONNECT) == SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = (interestSet & SelectionKey.OP_READ) == SelectionKey.OP_READ;
boolean isInterestedInWrite   = (interestSet & SelectionKey.OP_WRITE) == SelectionKey.OP_WRITE;

        (2)ready集合

        ready集合,记录了Channel已经准备就绪操作的集合。selector.selectedKeys()方法可以获取SelectionKey的Set集合,可以遍历每个SelectionKey。

//服务端Channel准备好接收连接
selectionKey.isAcceptable();
//Channel成功连接到服务器
selectionKey.isConnectable();
//Channel数据已写入,可以读取到buffer
selectionKey.isReadable();
//Channel已准备好,可以将buffer数据写入
selectionKey.isWritable();

3.4.3 Selector获取注册Channel方式

        Selector两类方式获取通道,select()方法和selectedKeys()方法。

        (1)select()

        select方法返回是注册时选择监听事件准备好的通道,比如注册时,选择的是Read准备好事件,select()方法只会返回Read准备好的Channel数量

/**
* 阻塞到有监听事件准备好的Channel
*/
select()

/**
* 设定阻塞时长
*/
select(long timeout)

/**
* 立即返回,不阻塞
*/
selectNow()

        (2)selectionKeys()

        常用方法,遍历所有注册到selector上的SelectionKey,通过selectionKey对象自身判断事件是否准备好(参考3.4.2小节),来执行响应的处理。

        (3)wakeUp()     

        某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。

        如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。

        (4)close()

        调用Selector的close()方法,会是注册到Selector上的所有SelectionKey对象都无效,Channel不会关闭。

3.4.4 代码示例使用Selector

        重新实现3.3节中示例服务端功能,使用ServerSocketChannel+Selector方式。

        需要的注意点:

  • while(true)循环中判断是否需要继续走业务流程时,不需要遍历SelectionKey,使用select()方法,关注自己感兴趣的准备好事件,是否有,即可。
  • 遍历的末尾处,需要调用itr.remove()方法,因为Selector不会自己移除之前查询到的SelectionKeys。
public class ServerBySelector {

    public static void main(String[] args){

        Selector selector = null;

        ServerSocketChannel ssc = null;

        try {
            selector = Selector.open();

            ssc = ServerSocketChannel.open();
            ssc.configureBlocking(false);

            ssc.socket().bind(new InetSocketAddress(8080));

            ssc.register(selector, SelectionKey.OP_READ);

            while (true){

                if (selector.select(3000) == 0){
                    //读准备好的Channel数量
                    System.out.println("还没有Channel写入数据,等等");
                    continue;
                }

                Iterator<SelectionKey> itr = selector.selectedKeys().iterator();

                while (itr.hasNext()){
                    SelectionKey key = itr.next();

                    if (key.isAcceptable()){
                        handleAccept(key);
                    }

                    if (key.isReadable()){
                        handleRead(key);
                    }

                    //key.isValid(),防止客户端连接暴力断开,这边还操作,造成整个程序不可用
                    if (key.isValid() && key.isWritable()){
                        handleWrite(key);
                    }

                    if (key.isValid() && key.isConnectable()){
                        System.out.println("Connectable true");
                    }

                    //必须每次移除已经遍历过的SelectionKey示例
                    itr.remove();
                }
            }
        } catch (IOException e){
            e.printStackTrace();
        }
    }

    public static void handleAccept(SelectionKey key) throws IOException {

        ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();

        SocketChannel sc = ssChannel.accept();

        sc.configureBlocking(false);

        sc.register(key.selector(),SelectionKey.OP_READ, ByteBuffer.allocateDirect(1024));

    }

    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();
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值