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可讓您只使用一個(或幾個)單線程管理多個通道(網絡連接或文件),但付出的代價是解析數據可能會比從一個阻塞流中讀取數據更復雜。

 

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