nio

基礎概念
• 緩衝區操作
緩衝區及操作是所有I/O的基礎,進程執行I/O操作,歸結起來就是向操作系統發出請求,讓它要麼把緩衝區裏的數據排幹(寫),要麼把緩衝區填滿(讀)。如下圖
15184210_Xonl
• 內核空間、用戶空間 
上圖簡單描述了數據從磁盤到用戶進程的內存區域移動的過程,其間涉及到了內核空間與用戶空間。這兩個空間有什麼區別呢? 
用戶空間就是常規進程(如JVM)所在區域,用戶空間是非特權區域,如不能直接訪問硬件設備。內核空間是操作系統所在區域,那肯定是有特權啦,如能與設備控制器通訊,控制用戶區域的進程運行狀態。進程執行I/O操作時,它執行一個系統調用把控制權交由內核。 
• 虛擬內存 
• 內存頁面調度 
5種I/O模型
說起I/O模型,網絡上有一個錯誤的概念,異步非阻塞/阻塞模型,其實異步根本就沒有阻不阻塞之說,異步模型就是異步模型。讓我們來看一看Richard Stevens在其UNIX網絡編程卷1中提出的5個I/O模型吧。
• 阻塞式I/O 
15184212_GdVp
• 非阻塞式I/O 
15184217_zZm1
• I/O複用(Java NIO就是這種模型) 
15184220_G7XH
• 信號驅動式I/O 
• 異步I/O 
15184226_0FqM
由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就是基於它的。
緩衝區 
緩衝區實質上就是一個數組,但它不僅僅是一個數組,緩衝區還提供了對數據的結構化訪問,而且還可以跟蹤系統的讀/寫進程。爲什麼這麼說呢?下面來看看緩衝區的細節。 
講緩衝區細節之前,我們先來看一下緩衝區“家譜”:
15184235_1a1r
• 內部細節 
緩衝區對象有四個基本屬性: 
o 容量Capacity:緩衝區能容納的數據元素的最大數量,在緩衝區創建時設定,無法更改 
o 上界Limit:緩衝區的第一個不能被讀或寫的元素的索引 
o 位置Position:下一個要被讀或寫的元素的索引 
o 標記Mark:備忘位置,調用mark()來設定mark=position,調用reset()設定position=mark 
這四個屬性總是遵循這樣的關係:0<=mark<=position<=limit<=capacity。下圖是新創建的容量爲10的緩衝區邏輯視圖:
15184237_ffbM
buffer.put((byte)'H').put((byte)'e').put((byte)'l').put((byte)'l').put((byte)'o');
五次調用put後的緩衝區:
buffer.put(0,(byte)'M').put((byte)'w');
15184241_NUYi
調用絕對版本的put不影響position: 
15184246_0SbU
現在緩衝區滿了,我們必須將其清空。我們想把這個緩衝區傳遞給一個通道,以使內容能被全部寫出,但現在執行get()無疑會取出未定義的數據。我們必須將 posistion設爲0,然後通道就會從正確的位置開始讀了,但讀到哪算讀完了呢?這正是limit引入的原因,它指明緩衝區有效內容的未端。這個操作 在緩衝區中叫做翻轉:buffer.flip()。 
15184249_V47C
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,如下圖: 
15184253_UYu7
另一個方法,slice與duplicate相似,但slice方法創建一個從原始緩衝區的當前位置開始的新緩衝區,而且容量是原始緩衝區的剩餘元素數量(limit-position),見下圖。
15184256_zghT
• 字節緩衝區 
o 字節序 
爲什麼會有字節序?比如有1個int類型數字0x036fc5d9,它佔4個字節 ,那麼在內存中存儲時,有可能其最高字節03位於低位地址(大端字節順序),也有可能最低字節d9位於低位地址(小端字節順序)。 
在IP協議中規定了使用大端的網絡字節順序,所以我們必須先在本地主機字節順序和通用的網絡字節順序之間進行轉換。java.nio中,字節順序由ByteOrder類封裝。 
在ByteBuffer中默認字節序爲ByteBuffer.BIG_ENDIAN,不過byte爲什麼還需要字節序呢?ByteBuffer和其他基本 數據類型一樣,具有大量便利的方法用於獲取和存放緩衝區內容,這些方法對字節進行編碼或解碼的方式取決於ByteBuffer當前字節序。 
o 直接緩衝區 
直接緩衝區是通過調用ByteBuffer.allocateDirect方法創建的。通常直接緩衝區是I/O操作的最好選擇,因爲它避免了一些複製過程;但可能也比間接緩衝區要花費更高的成本;它的內存是通過調用本地操作系統方面的代碼分配的。 
o 視圖緩衝區 
視圖緩衝區和緩衝區複製很像,不同的只是數據類型,所以字節對應關係也略有不同。比如ByteBuffer.asCharBuffer,那麼轉換後的緩衝區通過get操作獲得的元素對應備份存儲中的2個字節。 
o 如何存取無符號整數? 
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));
}

通道
通道用於在緩衝區和位於通道另一側的實體(文件、套接字)之間有效的傳輸數據。相對於緩衝區,通道的“家譜”略顯複雜:
15184300_0xJS
• 使用通道 
打開通道比較簡單,除了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()方法返回的對象的鎖。 
o 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();
        }
       }

o SocketChannel 
相對於ServerSocketChannel,它扮演客戶端,發起到監聽服務器的連接,連接成功後,開始接收數據。 
要注意的是,調用它的open()方法僅僅是打開但並未連接,要建立連接需要緊接着調用connect()方法;也可以兩步合爲一步,調用open(SocketAddress remote)方法。 
你會發現connect()方法並未提供timout參數,作爲替代方案,你可以用isConnected()、isConnectPending()或finishConnect()方法來檢查連接狀態。 
o DatagramChannel 
不同於前面兩個通道對象,它是無連接的,它既可以作爲服務器,也可以作爲客戶端。 
選擇器
選擇器提供選擇執行已經就緒的任務的能力,這使得多元I/O成爲可能。就緒選擇和多元執行使得單線程能夠有效率地同時管理多個I/O通道。選擇器可謂NIO中的重頭戲,I/O複用的核心,下面我們來看看這個神奇的東東。
• 基礎概念 
我們先來看下選擇器相關類的關係圖:
15184306_013A
由圖中可以看出,選擇器類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)進行修改,但不影響此次選擇過程(在下一次選擇過程中生效)。 
• 使用選擇器 
o 選擇過程 
類圖中可以看出,選擇器類中維護着兩個鍵的集合:已註冊的鍵的集合keys()和已選擇的鍵的集合selectedKeys(),已選擇的鍵的集合是已注 冊的鍵的集合的子集。已選擇的鍵的集合中的每個成員都被選擇器(在前一個選擇操作中)判斷爲已經準備好(所關心的操作集合中至少一個操作)。 除此之外,其實選擇器內部還維護着一個已取消的鍵的集合,這個集合包含了cancel()方法被調用過的鍵。 
選擇器類的核心是選擇過程,基本上來說是對select()、poll()等系統調用的一個包裝。那麼,選擇過程的具體細節或步驟是怎樣的呢? 
當選擇器類的選擇操作select()被調用時,下面的步驟將被執行: 
1.已被取消的鍵的集合被檢查。如果非空,那麼該集合中的鍵將從另外兩個集合中移除,並且相關通道將被註銷。這個步驟結束後,已取消的鍵的集合將爲空。 
2.已註冊的鍵的集合中的鍵的interest集合將被檢查。在這個步驟執行過後,對interset集合的改動不會影響剩餘的檢查過程。一旦就緒條件被 確定下來,操作系統將會進行查詢,以確定每個通道所關心的操作的真實就緒狀態。這可能會阻塞一段時間,最終每個通道的就緒狀態將確定下來。那些還沒有準備 好的通道將不會執行任何操作;而對於那些操作系統指示至少已經準備好interest集合中的一個操作的通道,將執行以下兩種操作中的一種: 
a.如果通道的鍵還沒有在已選擇的鍵的集合中,那麼鍵的ready集合將被清空,然後表示操作系統發現的當前通道已經準備好的操作的比特掩碼將被設置。 
b.如果通道的鍵已處於已選擇的鍵的集合中,鍵的ready集合將被表示操作系統發現的當前通道已經準備好的操作的比特掩碼所更新,所有之前的已經不再是就緒狀態的操作不會被清除。 
3.步驟2可能會花費很長時間,特別是調用的線程處於休眠狀態。同時,與選擇器相關的鍵可能會被取消。當步驟2結束時,步驟1將重新執行,以完成任意一個在選擇過程中,鍵已經被取消的通道的註銷。 
4.select操作返回的值是ready集合在步驟2中被修改的鍵的數量,而不是已選擇鍵的集合中的通道總數。返回值不是已經準備好的通道的總數,而是 從上一個select調用之後進入就緒狀態的通道的數量。之前調用中就緒的,並且在本次調用中仍然就緒的通道不會被計入。 
o 停止選擇過程
選擇器類提供了方法wakeup(),可以使線程從被阻塞的select()方法中優雅的退出,它將選擇器上的第一個還沒有返回的選擇操作立即返回。 
調用選擇器類的close()方法,那麼任何一個阻塞在選擇過程中的線程將被喚醒,與選擇器相關的通道將被註銷,而鍵將被取消。 
另外,選擇器類也能捕獲InterruptedException異常並調用wakeup()方法。 
o 併發性 
• 選擇過程的可擴展性 
在單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 
o 某個事件處理者宣稱它對某個socket上的讀事件很感興趣; 
o 事件分離者等着這個事件的發生; 
o 當事件發生了,事件分離器被喚醒,這負責通知先前那個事件處理者; 
o 事件處理者收到消息,於是去那個socket上讀數據了. 如果需要,它再次宣稱對這個socket上的讀事件感興趣,一直重複上面的步驟; 
• Proactor 
o 事件處理者直接投遞發一個寫操作(當然,操作系統必須支持這個異步操作). 這個時候,事件處理者根本不關心讀事件,它只管發這麼個請求,它魂牽夢縈的是這個寫操作的完成事件。這個處理者很拽,發個命令就不管具體的事情了,只等着別人(系統)幫他搞定的時候給他回個話。 
o 事件分離者等着這個讀事件的完成(比較下與Reactor的不同); 
o 當事件分離者默默等待完成事情到來的同時,操作系統已經在一邊開始幹活了,它從目標讀取數據,放入用戶提供的緩存區中,最後通知事件分離者,這個事情我搞完了; 
o 事件分享者通知之前的事件處理者: 你吩咐的事情搞定了; 
o 事件處理者這時會發現想要讀的數據已經乖乖地放在他提供的緩存區中,想怎麼處理都行了。如果有需要,事件處理者還像之前一樣發起另外一個寫操作,和上面的幾個步驟一樣。 
異步的proactor固然不錯,但它侷限於操作系統(要支持異步操作),爲了開發真正獨立平臺的通用接口,我們可以通過reactor模擬來實現proactor。
• Proactor(模擬) 
o 等待事件 (Proactor 的工作) 
o 讀數據(看,這裏變成成了讓 Proactor 做這個事情) 
o 把數據已經準備好的消息給用戶處理函數,即事件處理者(Proactor 要做的) 
o 處理數據 (用戶代碼要做的) 
總結
本文介紹了 I/O的一些基礎概念及5種I/O模型,NIO是5種模型中的I/O複用模型;接着進入主題Java NIO,分別講了NIO中三個最重要的概念:緩衝區、通道、選擇器;我們也明白了NIO是如何實現I/O複用模型的。最後討論了I/O多路複用模式中的兩 種模式:reactor和proactor,以及如何用reactor模擬proactor。

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