理解Java NIO

jopen 11年前發布 | 59K 次閱讀 Java NIO Java開發

基礎概念

  • 緩沖區操作 
    緩沖區及操作是所有I/O的基礎,進程執行I/O操作,歸結起來就是向操作系統發出請求,讓它要么把緩沖區里的數據排干(寫),要么把緩沖區填滿(讀)。如下圖理解Java NIO
  • 內核空間、用戶空間
    上圖簡單描述了數據從磁盤到用戶進程的內存區域移動的過程,其間涉及到了內核空間與用戶空間。這兩個空間有什么區別呢?
    用戶空間就是常規進程(如JVM)所在區域,用戶空間是非特權區域,如不能直接訪問硬件設備。內核空間是操作系統所在區域,那肯定是有特權啦,如能與設備控制器通訊,控制用戶區域的進程運行狀態。進程執行I/O操作時,它執行一個系統調用把控制權交由內核。
  • 虛擬內存
  • 內存頁面調度

5種I/O模型

       說起I/O模型,網絡上有一個錯誤的概念,異步非阻塞/阻塞模型,其實異步根本就沒有阻不阻塞之說,異步模型就是異步模型。讓我們來看一看Richard Stevens在其UNIX網絡編程卷1中提出的5個I/O模型吧。

  • 阻塞式I/O

理解Java NIO

  • 非阻塞式I/O

理解Java NIO

  • I/O復用(Java NIO就是這種模型)

理解Java NIO

  • 信號驅動式I/O
  • 異步I/O

理解Java NIO

       由POSIX術語定義,同步I/O操作導致請求進程阻塞,直到I/O操作完成;異步I/O操作不導致請求進程阻塞。5種模型中的前4種都屬于同步I/O模型。

Why NIO?

       開始講NIO之前,了解為什么會有NIO,相比傳統流I/O的優勢在哪,它可以用來做什么等等的問題,還是很有必要的。

      傳統流I/O是基于字節的,所有I/O都被視為單個字節的移動;而NIO是基于塊的,大家可能猜到了,NIO的性能肯定優于流I/O。沒錯!其性能的提高 要得益于其使用的結構更接近操作系統執行I/O的方式:通道和緩沖器。我們可以把它想象成一個煤礦,通道是一個包含煤層(數據)的礦藏,而緩沖器則是派送 到礦藏的卡車。卡車載滿煤炭而歸,我們再從卡車上獲得煤炭。也就是說,我們并沒有直接和通道交互;我們只是和緩沖器交互,并把緩沖器派送到通道。通道要么 從緩沖器獲得數據,要么向緩沖器發送數據。(這段比喻出自Java編程思想)

      NIO的主要應用在高性能、高容量服務端應用程序,典型的有Apache Mina就是基于它的。

緩沖區
         
緩沖區實質上就是一個數組,但它不僅僅是一個數組,緩沖區還提供了對數據的結構化訪問,而且還可以跟蹤系統的讀/寫進程。為什么這么說呢?下面來看看緩沖區的細節。
          講緩沖區細節之前,我們先來看一下緩沖區“家譜”:

理解Java NIO

  • 內部細節
        緩沖區對象有四個基本屬性:
    • 容量Capacity:緩沖區能容納的數據元素的最大數量,在緩沖區創建時設定,無法更改
    • 上界Limit:緩沖區的第一個不能被讀或寫的元素的索引
    • 位置Position:下一個要被讀或寫的元素的索引
    • 標記Mark:備忘位置,調用mark()來設定mark=position,調用reset()設定position=mark

      這四個屬性總是遵循這樣的關系:0<=mark<=position<=limit<=capacity。下圖是新創建的容量為10的緩沖區邏輯視圖:
                  理解Java NIO

     

buffer.put((byte)'H').put((byte)'e').put((byte)'l').put((byte)'l').put((byte)'o');

      五次調用put后的緩沖區:
                  理解Java NIO
     

      

buffer.put(0,(byte)'M').put((byte)'w');

      調用絕對版本的put不影響position:
                  理解Java NIO
      現在緩沖區滿了,我們必須將其清空。我們想把這個緩沖區傳遞給一個通道,以使內容能被全部寫出,但現在執行get()無疑會取出未定義的數據。我們必須將 posistion設為0,然后通道就會從正確的位置開始讀了,但讀到哪算讀完了呢?這正是limit引入的原因,它指明緩沖區有效內容的未端。這個操作 在緩沖區中叫做翻轉:buffer.flip()。
                   理解Java NIO
       rewind操作與flip相似,但不影響limit。


       將數據從輸入通道copy到輸出通道的過程應該是這樣的:

               
while (true) {
     buffer.clear();  // 重設緩沖區以便接收更多字節
     int r = fcin.read( buffer );

     if (r==-1) {
       break;
     }

     buffer.flip(); // 準備讀取緩沖區數據到通道
     fcout.write( buffer );
}
  • 創建緩沖區
           一般,新分配一個緩沖區是通過allocate方法的。如果你想提供自己的數組用做緩沖區的備份存儲器,請調用wrap方法。
           上面兩種方式創建的緩沖區都是間接的,間接的緩沖區使用備份數組(相關的方法有hasArray()、array()、arrayOffset())。
  • 復制緩沖區
           duplicate方法創建一個與原始緩沖區類似的緩沖區,兩個緩沖區共享數據元素,不過它們擁有各自的position、limit、mark,如下圖:
           理解Java NIO
           另一個方法,slice與duplicate相似,但slice方法創建一個從原始緩沖區的當前位置開始的新緩沖區,而且容量是原始緩沖區的剩余元素數量(limit-position),見下圖。
           理解Java NIO
  • 字節緩沖區
    • 字節序
             為什么會有字節序?比如有1個int類型數字0x036fc5d9,它占4個字節 ,那么在內存中存儲時,有可能其最高字節03位于低位地址(大端字節順序),也有可能最低字節d9位于低位地址(小端字節順序)。
             在IP協議中規定了使用大端的網絡字節順序,所以我們必須先在本地主機字節順序和通用的網絡字節順序之間進行轉換。java.nio中,字節順序由ByteOrder類封裝。
             在ByteBuffer中默認字節序為ByteBuffer.BIG_ENDIAN,不過byte為什么還需要字節序呢?ByteBuffer和其他基本 數據類型一樣,具有大量便利的方法用于獲取和存放緩沖區內容,這些方法對字節進行編碼或解碼的方式取決于ByteBuffer當前字節序。
    • 直接緩沖區
             直接緩沖區是通過調用ByteBuffer.allocateDirect方法創建的。通常直接緩沖區是I/O操作的最好選擇,因為它避免了一些復制過程;但可能也比間接緩沖區要花費更高的成本;它的內存是通過調用本地操作系統方面的代碼分配的。
    • 視圖緩沖區
             視圖緩沖區和緩沖區復制很像,不同的只是數據類型,所以字節對應關系也略有不同。比如ByteBuffer.asCharBuffer,那么轉換后的緩沖區通過get操作獲得的元素對應備份存儲中的2個字節。
    • 如何存取無符號整數?
             Java中并沒有直接提供無符號數值的支持,每個從緩沖區讀出的無符號值被升到比它大的下一個數據類型中。 
            
      public static short getUnsignedByte(ByteBuffer bb) {
          return ((short) (bb.get() & 0xff));
      }
      
      public static void putUnsignedByte(ByteBuffer bb, int value) {
          bb.put((byte) (value & 0xff));
      }

通道

        通道用于在緩沖區和位于通道另一側的實體(文件、套接字)之間有效的傳輸數據。相對于緩沖區,通道的“家譜”略顯復雜:

理解Java NIO 

  • 使用通道
           打開通道比較簡單,除了FileChannel,都用open方法打開。
           我們知道,通道是和緩沖區交互的,從緩沖區獲取數據進行傳輸,或將數據傳輸給緩沖區。從類繼承層次結構可以看出,通道一般都是雙向的(除FileChannel)。
           下面來看一下通道間數據傳輸的代碼: 
          
    private static void channelCopy(ReadableByteChannel src,
                                     WritableByteChannel dest)
            throws IOException {
        ByteBuffer buffer = ByteBuffer.allocateDirect(16 * 1024);
        while (src.read(buffer) != -1) {
            // Prepare the buffer to be drained
            buffer.flip();
            // Write to the channel; may block
            dest.write(buffer);
            // If partial transfer, shift remainder down
            // If buffer is empty, same as doing clear( )
            buffer.compact();
        }
        // EOF will leave buffer in fill state
        buffer.flip();
        // Make sure that the buffer is fully drained
        while (buffer.hasRemaining()) {
            dest.write(buffer);
        }
    }
  • 關閉通道
           通道不能被重復使用,這點與緩沖區不同;關閉通道后,通道將不再連接任何東西,任何的讀或寫操作都會導致ClosedChannelException。
           調用通道的close()方法時,可能會導致線程暫時阻塞,就算通道處于非阻塞模式也不例外。如果通道實現了InterruptibleChannel接 口,那么阻塞在該通道上的一個線程被中斷時,該通道將被關閉,被阻塞線程也會拋出ClosedByInterruptException異常。當一個通道 關閉時,休眠在該通道上的所有線程都將被喚醒并收到一個AsynchronousCloseException異常。
  • 發散、聚集
           發散、聚集,又被稱為矢量I/O,簡單而強大的概念,它是指在多個緩沖區上實現一個簡單的I/O操作。它減少或避免了緩沖區的拷貝和系統調用,它應該使用直接緩沖區以從本地I/O獲取最大性能優勢。
  • 文件通道
  • Socket通道
           Socket通道有三個,分別是ServerSocketChannel、SocketChannel和DatagramChannel,而它們又分別對 應java.net包中的Socket對象ServerSocket、Socket和DatagramSocket;Socket通道被實例化時,都會創 建一個對等的Socket對象。
           Socket通道可以運行非阻塞模式并且是可選擇的,非阻塞I/O與可選擇性是緊密相連的,這也正是管理阻塞的API要在 SelectableChannel中定義的原因。設置非阻塞非常簡單,只要調用configureBlocking(false)方法即可。如果需要中 途更改阻塞模式,那么必須首先獲得blockingLock()方法返回的對象的鎖。
    • ServerSocketChannel
             ServerSocketChannel是一個基于通道的socket監聽器。但它沒有bind()方法,因此需要取出對等的Socket對象并使用它來 綁定到某一端口以開始監聽連接。在非阻塞模式下,當沒有傳入連接在等待時,其accept()方法會立即返回null。正是這種檢查連接而不阻塞的能力實 現了可伸縮性并降低了復雜性,選擇性也因此得以實現。 
            
      ByteBuffer buffer = ByteBuffer.wrap("Hello World".getBytes());
      ServerSocketChannel ssc = ServerSocketChannel.open();
      ssc.socket().bind(new InetSocketAddress(12345));
      ssc.configureBlocking(false);
      
      for (;;) {
          System.out.println("Waiting for connections");
          SocketChannel sc = ssc.accept();
          if (sc == null)
              TimeUnit.SECONDS.sleep(2000);
          else {
              System.out.println("Incoming connection from:" + sc.socket().getRemoteSocketAddress());
              buffer.rewind();
              sc.write(buffer);
              sc.close();
          }
      }
    • SocketChannel
             相對于ServerSocketChannel,它扮演客戶端,發起到監聽服務器的連接,連接成功后,開始接收數據。
             要注意的是,調用它的open()方法僅僅是打開但并未連接,要建立連接需要緊接著調用connect()方法;也可以兩步合為一步,調用open(SocketAddress remote)方法。
             你會發現connect()方法并未提供timout參數,作為替代方案,你可以用isConnected()、isConnectPending()或finishConnect()方法來檢查連接狀態。
    • DatagramChannel
             不同于前面兩個通道對象,它是無連接的,它既可以作為服務器,也可以作為客戶端。

選擇器

       選擇器提供選擇執行已經就緒的任務的能力,這使得多元I/O成為可能。就緒選擇和多元執行使得單線程能夠有效率地同時管理多個I/O通道。選擇器可謂NIO中的重頭戲,I/O復用的核心,下面我們來看看這個神奇的東東。

  • 基礎概念
           我們先來看下選擇器相關類的關系圖:
           理解Java NIO 
           由圖中可以看出,選擇器類Selector并沒有和通道有直接的關系,而是通過叫選擇鍵的對象SelectionKey來聯系的。選擇鍵代表了通道與選擇 器之間的一種注冊關系,channel()和selector()方法分別返回注冊的通道與選擇器。由類圖也可以看出,一個通道可以注冊到多個選擇器;注 冊方法register()是放在通道類里,而我感覺放在選擇器類里合適點。
           非阻塞特性與多元執行的關系非常密切,如果在阻塞模式下注冊一個通道,系統會拋出IllegalBlockingModeException異常。
           那么,通道注冊到選擇器后,選擇器又是如何實現就緒選擇的呢?真正的就緒操作是由操作系統來做的,操作系統處理I/O請求并通知各個線程它們的數據已經準備好了,而選擇器類提供了這種抽象。
           選擇鍵作為通道與選擇器的注冊關系,需要維護這個注冊關系所關心的通道操作interestOps()以及通道已經準備好的操作readyOps(),這 兩個方法的返回值都是比特掩碼,另外ready集合是interest集合的子集。選擇鍵類中定義了4種可選擇操作:read、write、 connect和accept。類圖中你可以看到每個可選擇通道都有一個validOps()的抽象方法,每個具體通道各自有不同的有效的可選擇操作集 合,比如ServerSocketChannel的有效操作集合是accept,而SocketChannel的有效操作集合是read、write和 connect。
           回過頭來再看下注冊方法,其第二個參數是一個比特掩碼,這個參數就是上面講的這個注冊關系所關心的通道操作。在選擇過程中,所關心的通道操作可以由方法 interestOps(int operations)進行修改,但不影響此次選擇過程(在下一次選擇過程中生效)。
  • 使用選擇器
    • 選擇過程
             類圖中可以看出,選擇器類中維護著兩個鍵的集合:已注冊的鍵的集合keys()和已選擇的鍵的集合selectedKeys(),已選擇的鍵的集合是已注 冊的鍵的集合的子集。已選擇的鍵的集合中的每個成員都被選擇器(在前一個選擇操作中)判斷為已經準備好(所關心的操作集合中至少一個操作)。 除此之外,其實選擇器內部還維護著一個已取消的鍵的集合,這個集合包含了cancel()方法被調用過的鍵。
             選擇器類的核心是選擇過程,基本上來說是對select()、poll()等系統調用的一個包裝。那么,選擇過程的具體細節或步驟是怎樣的呢?
             當選擇器類的選擇操作select()被調用時,下面的步驟將被執行:
             1.已被取消的鍵的集合被檢查。如果非空,那么該集合中的鍵將從另外兩個集合中移除,并且相關通道將被注銷。這個步驟結束后,已取消的鍵的集合將為空。
             2.已注冊的鍵的集合中的鍵的interest集合將被檢查。在這個步驟執行過后,對interset集合的改動不會影響剩余的檢查過程。一旦就緒條件被 確定下來,操作系統將會進行查詢,以確定每個通道所關心的操作的真實就緒狀態。這可能會阻塞一段時間,最終每個通道的就緒狀態將確定下來。那些還沒有準備 好的通道將不會執行任何操作;而對于那些操作系統指示至少已經準備好interest集合中的一個操作的通道,將執行以下兩種操作中的一種:
             a.如果通道的鍵還沒有在已選擇的鍵的集合中,那么鍵的ready集合將被清空,然后表示操作系統發現的當前通道已經準備好的操作的比特掩碼將被設置。
             b.如果通道的鍵已處于已選擇的鍵的集合中,鍵的ready集合將被表示操作系統發現的當前通道已經準備好的操作的比特掩碼所更新,所有之前的已經不再是就緒狀態的操作不會被清除。
             3.步驟2可能會花費很長時間,特別是調用的線程處于休眠狀態。同時,與選擇器相關的鍵可能會被取消。當步驟2結束時,步驟1將重新執行,以完成任意一個在選擇過程中,鍵已經被取消的通道的注銷。
             4.select操作返回的值是ready集合在步驟2中被修改的鍵的數量,而不是已選擇鍵的集合中的通道總數。返回值不是已經準備好的通道的總數,而是 從上一個select調用之后進入就緒狀態的通道的數量。之前調用中就緒的,并且在本次調用中仍然就緒的通道不會被計入。
    • 停止選擇過程 
             選擇器類提供了方法wakeup(),可以使線程從被阻塞的select()方法中優雅的退出,它將選擇器上的第一個還沒有返回的選擇操作立即返回。
             調用選擇器類的close()方法,那么任何一個阻塞在選擇過程中的線程將被喚醒,與選擇器相關的通道將被注銷,而鍵將被取消。
             另外,選擇器類也能捕獲InterruptedException異常并調用wakeup()方法。
    • 并發性
  • 選擇過程的可擴展性
           在單cpu中使用一個線程為多個通道提供服務可能是個好主意,但對于多cpu的系統,單線程必然比多線程在性能上要差很多。
           一個比較不錯的多線程策略是,以所有的通道使用一個選擇器(或多個選擇器,視情況),并將以就緒通道的服務委托給其他線程。用一個線程監控通道的就緒狀態,并使用一個工作線程池來處理接收到的數據。

       講了這么多,下面來看一段用NIO寫的簡單服務器代碼:
      

private void run(int port) throws IOException {
    // Allocate buffer
    ByteBuffer echoBuffer = ByteBuffer.allocate(1024);
    // Create a new selector
    Selector selector = Selector.open();

    // Open a listener on the port, and register with the selector
    ServerSocketChannel ssc = ServerSocketChannel.open();
    ssc.configureBlocking(false);
    ServerSocket ss = ssc.socket();
    InetSocketAddress address = new InetSocketAddress(port);
    ss.bind(address);

    SelectionKey key = ssc.register(selector, SelectionKey.OP_ACCEPT);
    System.out.println("Going to listen on " + port);

    for (;;){
        int num = selector.select();

        Set selectedKeys = selector.selectedKeys();
        Iterator it = selectedKeys.iterator();

        while (it.hasNext()) {
            SelectionKey selectionKey = (SelectionKey) it.next();

            if ((selectionKey.readyOps() & SelectionKey.OP_ACCEPT)
                    == SelectionKey.OP_ACCEPT) {
                // Accept the new connection
                ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
                SocketChannel sc = serverSocketChannel.accept();
                sc.configureBlocking(false);

                // Add the new connection to the selector
                SelectionKey newKey = sc.register(selector, SelectionKey.OP_READ);
                it.remove();

                System.out.println("Got connection from " + sc);
            } else if ((selectionKey.readyOps() & SelectionKey.OP_READ)
                    == SelectionKey.OP_READ) {
                // Read the data
                SocketChannel sc = (SocketChannel) selectionKey.channel();

                // Echo data
                int bytesEchoed = 0;
                while (true) {
                    echoBuffer.clear();
                    int r = sc.read(echoBuffer);
                    if (r <= 0) {
                        break;
                    }
                    echoBuffer.flip();
                    sc.write(echoBuffer);
                    bytesEchoed += r;
                }
                System.out.println("Echoed " + bytesEchoed + " from " + sc);
                it.remove();
            }
        }
    }
}
     

I/O多路復用模式

       I/O多路復用有兩種經典模式:基于同步I/O的reactor和基于異步I/O的proactor。 

  • Reactor
    • 某個事件處理者宣稱它對某個socket上的讀事件很感興趣;
    • 事件分離者等著這個事件的發生;
    • 當事件發生了,事件分離器被喚醒,這負責通知先前那個事件處理者;
    • 事件處理者收到消息,于是去那個socket上讀數據了. 如果需要,它再次宣稱對這個socket上的讀事件感興趣,一直重復上面的步驟;
  • Proactor
    • 事件處理者直接投遞發一個寫操作(當然,操作系統必須支持這個異步操作). 這個時候,事件處理者根本不關心讀事件,它只管發這么個請求,它魂牽夢縈的是這個寫操作的完成事件。這個處理者很拽,發個命令就不管具體的事情了,只等著別人(系統)幫他搞定的時候給他回個話。
    • 事件分離者等著這個讀事件的完成(比較下與Reactor的不同);
    • 當事件分離者默默等待完成事情到來的同時,操作系統已經在一邊開始干活了,它從目標讀取數據,放入用戶提供的緩存區中,最后通知事件分離者,這個事情我搞完了;
    • 事件分享者通知之前的事件處理者: 你吩咐的事情搞定了;
    • 事件處理者這時會發現想要讀的數據已經乖乖地放在他提供的緩存區中,想怎么處理都行了。如果有需要,事件處理者還像之前一樣發起另外一個寫操作,和上面的幾個步驟一樣。

       異步的proactor固然不錯,但它局限于操作系統(要支持異步操作),為了開發真正獨立平臺的通用接口,我們可以通過reactor模擬來實現proactor。

  • Proactor(模擬)
    • 等待事件 (Proactor 的工作)
    • 讀數據(看,這里變成成了讓 Proactor 做這個事情)
    • 把數據已經準備好的消息給用戶處理函數,即事件處理者(Proactor 要做的)
    • 處理數據 (用戶代碼要做的)

總結

       本文介紹了 I/O的一些基礎概念及5種I/O模型,NIO是5種模型中的I/O復用模型;接著進入主題Java NIO,分別講了NIO中三個最重要的概念:緩沖區、通道、選擇器;我們也明白了NIO是如何實現I/O復用模型的。最后討論了I/O多路復用模式中的兩 種模式:reactor和proactor,以及如何用reactor模擬proactor。

參考資料

O'Reilly Java NIO
Richard Stevens《UNIX網絡編程 卷1:套接字聯網API》
兩種高性能I/O設計模式(Reactor/Proactor)的比較
Understanding Network I/O
Understanding Disk I/O - when should you be worried?

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!