NIO网络编程三大核心理念

什么是NIO?

Java NIO(New IO)是一个可以替代标准Java IO API的IO API(从Java 1.4开始),Java NIO提供了与标准IO不同的IO工作方式。NIO可以理解为非阻塞IO,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用socket.read()时,如果服务器一直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式。

IO和NIO的区别

IO是面向字节流和字符流的,而NIO是面向缓冲区的。
IO是阻塞模式的,NIO是非阻塞模式的
NIO新增了选择器的概念,可以通过选择器监听多个通道。

NIO有三个核心组件,分别是:

Buffer缓冲区

Channel通道

Selector选择器 

Buffer缓冲区

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。相比较直接对数组的操作,Buffer API更加容易操作和管理。

使用Buffer读写数据一般遵循以下四个步骤:

1.写入数据到Buffer
2.调用flip()方法,转换为读取模式
3.从Buffer中读取数据
4.调用buffer.clear()方法或者buffer.compact()方法清除缓冲区

Buffer工作原理

Buffer三个重要属性:

capacity容量:作为一个内存块,Buffer具有一定的固定大小,也成为"容量"。

position位置:写入模式时代表写数据的位置。读取数据时代表读取数据的位置。

limit限制: 写入模式,限制等于buffer的容量。读取模式下,limit等于写入的数据量。

上图展示了写模式和读模式下,以上属性的示意图,

写模式下:limit和capacity是一样的,这表示你能写入的最大容量数据。
                  position 为当前写入的位置,[0,position]为已写入的数据。
读模式下:limit会和position一样,表示你能读到写入的全部数据。 

Buffer(缓冲区)的主要分类有: ByteBuffer,MappedByteBuffer,CharBuff 

                                               DoubleBuffer,FloatBuffer,IntBuffer,LongBuffer,ShortBuffer

下面主要讲一下比较常用的ByteBuffer

ByteBuffer内存类型

ByteBuffer为性能关键型代码提供了直接内存(direct堆外)非直接内存(heap堆)两种实现。

堆外内存获取的方式:ByteBuffer directByteBuffer=ByteBuffer.allocateDirect(noBytes);

相对于非直接内存,用堆外内存有如下的好处:

1.进行网络IO或者文件IO时比heapBuffer少一次拷贝。为什么呢?

这涉及到一个很底层的实现,就我们的一个文件,我们在写数据的时候我们想把这个数据存到文件或者写到网络里面去,需要调用操作系统的API直接去操作文件的写入,在写入的过程中呢,把我们的这个内存地址传递过去。这是一个场景,那现在为什么他会少一次拷贝呢,是因为在java去写入的时候,他会先把数据从堆内存中复制一份数据到堆外内存中去,比如说在一个地方把A复制一份出来,会有一个新的内存地址,自己把这份数据先复制到堆外,然后再进行写入,为什么这么麻烦的去做,因为java中有一个很重要的特性,java里面有一个垃圾回收机制,垃圾回收机制就有一个特性,会移动java的对象内存,如果说我们这个A当前是在一的话,那么很有可能经过一次垃圾回收之后,他的目的地址就是变成二了。所以这个时候如果说我们在写文件或者是写socket的网络的时候,如果直接传递的是java内存的地址,那这个时候呢,会根据给的内存地址去写,结果他在干活的过程中,这个内存地址被挪动了,这个时候我们再去读取这个数据,或者说写这个数据那么就读不到了,因为你的内存地址里面的数据都变了,所以要实现这样的功能,为了避免出现这种情况,jvm会先把数据复制到堆外再进行写入操作。但是如果我们一开始就直接用堆外内存,就少了一次拷贝了,因为要写什么或读什么其内存地址就都不会变了,为什么不会变呢,因为堆外内存不受GC垃圾回收机制管理。

2.GC范围之外,降低了GC压力,但实现了自动管理。DirectByteBuffer中有一个Cleaner对象(PhantomReference),Cleaner被GC前会执行clean方法,触发DirectByteBuffer中的Deallocator

注意:1.性能确实可观的时候才去使用;分配给大型、长寿命(网络传输、文件读写场景)

           2.通过虚拟机参数MaxDirectMemorySize限制大小,防止耗尽整个机器的内存(因为堆外内存不受GC管理) 

Channel(通道)

Java NIO的通道类似流,但又有些不同:既可以从通道中读取数据,又可以写数据到通道。但流的(input或output)读写通常是单向的。 通道可以非阻塞读取和写入通道,通道可以始终读取或写入缓冲区,也支持异步地读写。

Channel的实现

FileChannel: 从文件中读写数据。
DatagramChannel : 能通过UDP读写网络中的数据。
SocketChannel: 能通过TCP读写网络中的数据。
ServerSocketChannel :可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。

编码练习

使用 NIO 读取一个大文件,再将其写入到新的文件

private static void NIOReadFile() throws FileNotFoundException {
     URL url = ReadBigFile.class.getClassLoader().getResource("tokens.txt");
     String path = url.getPath();
 
     FileOutputStream  fos = new FileOutputStream(new File("D:/out.txt"));
     FileChannel outchannel = fos.getChannel();
 
    try {
        RandomAccessFile rdf = new RandomAccessFile(path, "rw");
        FileChannel inChannel=  rdf.getChannel();   //利用channel中的FileChannel来实现文件的读取
        // 初始化一个小的 buff,来模拟读取大文件
        ByteBuffer buf=  ByteBuffer.allocate(10);   //设置缓冲区容量为10

        //从通道中读取数据到缓冲区,返回读取的字节数量(把数据从磁盘中写入缓冲区)
        int byteRead=inChannel.read(buf);
        // 此时 buff 的 position 为10   limit 为10

        //数量为-1表示读取完毕。
        while (byteRead!=-1){
            //切换模式为读模式,其实就是把postion位置设置为0,可以从0开始读取
            buf.flip();

            outchannel.write(buf); // outChannel将缓存区的数据写到文件中后, position 为 10   limit 为 10
           buf.flip(); // 要重新将 postion 设置为0,才能读 buff 的内容
           while (buf.hasRemaining()) {//如果缓冲区还有数据
                System.out.print((char) buf.get()); // 在控制台输出一个字符
            }

            buf.clear();                        //数据读完后清空缓冲区
            byteRead = inChannel.read(buf);     //继续把通道内剩余数据写入缓冲区
        }
        //关闭通道
        rdf.close();
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
       e.printStackTrace();
    }
}

 注:对 buff 的操作,无论是将数据写入buff,或者读取 buff (将 buff 中的数据写到文件中去也是一种读操作) 中的数据,都会buff中的 position 和 limit 的协助,都会引起 position 和 limit 的变化,所以在读写切换,重复多次读,覆盖式写都要 flip() 一下

Selector选择器

Selector是 一个Java NIO组件,可以能够检查一个或多个 NIO 通道,并确定哪些通道已经准备好进行读取或写入。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接,提高效率。

Selector选择器常用方法

方法名 功能
register(Selector sel, int ops) 向选择器注册通道,并且可以选择注册指定的事件,目前事件分为4种;1.Connect,2.Accept,3.Read,4.Write,一个通道可以注册多个事件
select() 阻塞到至少有一个通道在你注册的事件上就绪了
selectNow() 不会阻塞,不管什么通道就绪都立刻返回
select(long timeout) 和select()一样,除了最长会阻塞timeout毫秒(参数)
selectedKeys() 一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道
wakeUp() 可以使调用select()阻塞的对象返回,不阻塞。
close() 用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭

编码练习
编码客户端和服务端,服务端可以接受客户端的请求,并返回一个报文,客户端接受报文并解析输出。

/**
  * 服务端代码
   */
  public class Service {
  
      public static void main(String[] args) throws IOException, InterruptedException {
          common_version();
      }
  
     // 普通版
     private static void common_version() throws IOException, InterruptedException {
         //创建一个服务 socket 并打开
         ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
 
         //监听绑定8090端口
         serverSocketChannel.socket().bind(new InetSocketAddress(8090));
 
         //设置为非阻塞模式
         serverSocketChannel.configureBlocking(false);
 
         while (true){
             //获取请求连接
             SocketChannel socketChannel = serverSocketChannel.accept();
             if( socketChannel!=null ){
                 ByteBuffer buf1 = ByteBuffer.allocate(1024);
                 socketChannel.read(buf1);   // 将客户端的信息读入 buff 中
                 buf1.flip();
                 if(buf1.hasRemaining()) // 打印客户端发来的信息
                     System.out.println(">>>服务端收到数据:"+new String(buf1.array()));
                 buf1.clear();
 
                 //构造返回的报文,分为头部和主体,实际情况可以构造复杂的报文协议,这里只演示,不做特殊设计。
                 ByteBuffer header = ByteBuffer.allocate(6);
                 header.put("[head]".getBytes());
                 ByteBuffer body   = ByteBuffer.allocate(1024);
                 body.put("i am body!".getBytes());
                 header.flip();
                 body.flip();
                 ByteBuffer[] bufferArray = { header, body };
 
                 socketChannel.write(bufferArray);
                 socketChannel.close();
 
             }else{
                 Thread.sleep(1000);
             }
        }
     }
 
     // 选择器版
     private static void selector_version() throws IOException {
         //打开选择器
         Selector selector = Selector.open();
 
         ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //创建一个服务 socket 并打开
         serverSocketChannel.socket().bind(new InetSocketAddress(8090));  //监听绑定8090端口
         serverSocketChannel.configureBlocking(false);   //设置为非阻塞模式
 
         // 向通道注册选择器,并且注册接受事件
         serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
 
         while (true) {
            // 获取已经准备好的通道数量
             int readyChannels = selector.selectNow();
             if (readyChannels == 0)  //如果没准备好,重试
                 continue;
 
             //获取准备好的通道中的事件集合
            Set selectedKeys = selector.selectedKeys();
             Iterator keyIterator = selectedKeys.iterator();
 
             while(keyIterator.hasNext()){
                 SelectionKey key = (SelectionKey)keyIterator.next();
 
                 if (key.isAcceptable()) {
                     //在自己注册的事件中写业务逻辑,
                     //我这里注册的是accept事件,
                     //这部分逻辑和上面非选择器服务端代码一样。
                     ServerSocketChannel serverSocketChannel1 = (ServerSocketChannel) key.channel();
                    SocketChannel socketChannel = serverSocketChannel1.accept();
                    ByteBuffer buf1 = ByteBuffer.allocate(1024);
                     socketChannel.read(buf1);
                     buf1.flip();
                     if (buf1.hasRemaining())
                         System.out.println(">>>服务端收到数据:" + new String(buf1.array()));
                     buf1.clear();
 
                     ByteBuffer header = ByteBuffer.allocate(6);
                     header.put("[head]".getBytes());
                     ByteBuffer body = ByteBuffer.allocate(1024);
                     body.put("i am body!".getBytes());
                     header.flip();
                     body.flip();
                     ByteBuffer[] bufferArray = {header, body};
                     socketChannel.write(bufferArray);
 
                     socketChannel.close();
                 }
             }
        }
    }
}
// 客户端代码
 public class Client {
     public static void main(String[] args) throws IOException {
         //打开socket连接,连接本地8090端口,也就是服务端
         SocketChannel socketChannel = SocketChannel.open();
         socketChannel.connect(new InetSocketAddress("127.0.0.1", 8090));
 
         //请求服务端,发送请求
         ByteBuffer buf1 = ByteBuffer.allocate(1024);
        buf1.put("来着客户端的请求".getBytes());

        buf1.flip();
        if (buf1.hasRemaining())
            socketChannel.write(buf1);

        buf1.clear();

        //接受服务端的返回,构造接受缓冲区,我们定义头6个字节为头部,后续其他字节为主体内容。
        ByteBuffer header = ByteBuffer.allocate(6);
        ByteBuffer body   = ByteBuffer.allocate(1024);
        ByteBuffer[] bufferArray = { header, body };

        socketChannel.read(bufferArray);
        header.flip();
        body.flip();
        if (header.hasRemaining())
            System.out.println(">>>客户端接收头部数据:" + new String(header.array()));
        if (body.hasRemaining())
            System.out.println(">>>客户端接收body数据:" + new String(body.array()));
        header.clear();
        body.clear();

        socketChannel.close();
    }
}

 总结

 Java IO和NIO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。

Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。

 Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。

NIO可让您只使用一个(或几个)单线程管理多个通道(网络连接或文件),但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章