一、Java IO 和 系統 IO 不匹配
在大多數情況下,Java 應用程序並非真的受着 I/O 的束縛。操作系統並非不能快速傳送數據,讓 Java 有事可做;相反,是 JVM 自身在 I/O 方面效率欠佳。操作系統與 Java 基於流的 I/O模型有些不匹配。操作系統要移動的是大塊數據(緩沖區),這往往是在硬件直接存儲器存取(DMA)的協助下完成的。而 JVM 的 I/O 操作類喜歡操作小塊數據——單個字節、幾行文本。結果,操作系統送來整緩沖區的數據,java.io 包的流數據類再花大量時間把它們拆成小塊,往往拷貝一個小塊就要往返於幾層對象。操作系統喜歡整卡車地運來數據,java.io 類則喜歡一鏟子一鏟子地加工數據。有了 NIO,就可以輕松地把一卡車數據備份到您能直接使用的地方(ByteBuffer 對象)。
這並不是說使用傳統的 I/O 模型無法移動大量數據——當然可以(現在依然可以)。具體地說,RandomAccessFile類在這方面的效率就不低,只要堅持使用基於數組的read()和write()方法。
這些方法與底層操作系統調用相當接近,盡管必須保留至少一份緩沖區拷貝。
為了解決這一問題,java.nio 軟件包提供了新的抽象。具體地說,就是 Channel和Selector類。
二、 緩沖區
(一)緩沖區操作
緩沖區,以及緩沖區如何工作,是所有 I/O 的基礎。所謂“輸入/輸出”講的無非就是把數據移進或移出緩沖區。
進程執行 I/O 操作,歸結起來,也就是向操作系統發出請求,讓它要么把緩沖區里的數據排干(寫),要么用數據把緩沖區填滿(讀)。進程使用這一機制處理所有數據進出操作。操作系統內部處理這一任務的機制,其復雜程度可能超乎想像,但就概念而言,卻非常直白易懂。圖 1-1 簡單描述了數據從外部磁盤向運行中的進程的內存區域移動的過程。進程使用read()系統調用,要求其緩沖區被填滿。內核隨即向磁盤控制硬件發出命令,要求其從磁盤讀取數據。磁盤控制器把數據直接寫入內核內存緩沖區,這一步通過 DMA 完成,無需主CPU協助。一旦磁盤控制器把緩沖區裝滿,內核即把數據從內核空間的臨時緩沖區拷貝到進程執行read()調用時指定的緩沖區。
圖 1-1. I/O 緩沖區操作簡圖
JVM 就是常規進程,駐守於用戶空間。用最重要的是,所有 I/O 都直接或間接通過內核空間。當進程請求 I/O 操作的時候,它執行一個系統調用(有時稱為陷阱)將控制權移交給內核。C/C++程序員所熟知的底層函數open()、read()、write()和close()要做的無非就是建立和執行適當的系統調用。當內核以這種方式被調用,它隨即采取任何必要步驟,找到進程所需數據,並把數據傳送到用戶空間內的指定緩沖區。內核試圖對數據進行高速緩存或預讀取,因此進程所需數據可能已經在內核空間里了。如果是這樣,該數據只需簡單地拷貝出來即可。如果數據不在內核空間,則進程被掛起,內核着手把數據讀進內存。
為什么不直接讓磁盤控制器把數據送到用戶空間的緩沖區呢?這樣做有幾個問題。首先,硬件通常不能直接訪問用戶空間。其次,像磁盤這樣基於塊存儲的硬件設備操作的是固定大小的數據塊,而用戶進程請求的可能是任意大小的或非對齊的數據塊。在數據往來於用戶空間與存儲設備的過程中,內核負責數據的分解、再組合工作,因此充當着中間人的角色。
(二) 發散/匯聚
許多操作系統能把組裝/分解過程進行得更加高效。根據發散/匯聚的概念,進程只需一個系統調用,就能把一連串緩沖區地址傳遞給操作系統。然后,內核就可以順序填充或排干多個緩沖區,讀的時候就把數據發散到多個用戶空間緩沖區,寫的時候再從多個緩沖區把數據匯聚起來(圖1-2)。
圖 1-2. 三個緩沖區的發散讀操作
這樣用戶進程就不必多次執行系統調用(那樣做可能代價不菲),內核也可以優化數據的處理過程,因為它已掌握待傳輸數據的全部信息。如果系統配有多個 CPU,甚至可以同時填充或排干多個緩沖區。
三、 流I/O
並非所有 I/O 都像前幾節講的是面向塊的,也有流 I/O,其原理模仿了通道。I/O 字節流必須順序存取,常見的例子有TTY(控制台)設備、打印機端口和網絡連接。
流的傳輸一般(也不必然如此)比塊設備慢,經常用於間歇性輸入。多數操作系統允許把流置於非塊模式,這樣,進程可以查看流上是否有輸入,即便當時沒有也不影響它干別的。這樣一種能力使得進程可以在有輸入的時候進行處理,輸入流閑置的時候執行其他功能。
比非塊模式再進一步,就是就緒性選擇。就緒性選擇與非塊模式類似(常常就是建立在非塊模式之上),但是把查看流是否就緒的任務交給了操作系統。操作系統受命查看一系列流,並提醒進程哪些流已經就緒。這樣,僅僅憑借操作系統返回的就緒信息,進程就可以使用相同代碼和單一線程,實現多活動流的多路傳輸。這一技術廣泛用於網絡服務器領域,用來處理數量龐大的網絡連接。就緒性選擇在大容量縮放方面是必不可少的。
四、Socket通道
DatagramChannel和SocketChannel實現定義讀和寫功能的接口而ServerSocketChannel不實現。ServerSocketChannel負責監聽傳入的連接和創建新的SocketChannel對象,它本身從不傳輸數據。
socket和socket通道之間的關系。之前的章節中有寫道,通道是一個連接I/O服務導管並提供與該服務交互的方法。就某個socket而言,它不會再次實現與之對應的socket通道類中的socket協議 API,而java.net中已經存在的socket通道都可以被大多數協議操作重復使用。
全部socket通道類(DatagramChannel、SocketChannel和ServerSocketChannel)在被實例化時都會創建一個對等socket對象。這些是我們所熟悉的來自java.net的類(Socket、ServerSocket和DatagramSocket),它們已經被更新以識別通道。對等socket可以通過調用socket()方法從一個通道上獲取。此外,這三個java.net類現在都有getChannel()方法。
雖然每個socket通道(在java.nio.channels包中)都有一個關聯的java.net socket對象,卻並非所有的socket都有一個關聯的通道。如果您用傳統方式(直接實例化)創建了一個Socket對象,它就不會有關聯的SocketChannel並且它的getChannel()方法將總是返回null。
五、ServerSocketChannel
讓我們從最簡單的ServerSocketChannel來開始對socket通道類的討論。以下是ServerSocketChannel的完整 API:
public abstract class ServerSocketChannel extends AbstractSelectableChannel { public static ServerSocketChannel open() throws IOException public abstract ServerSocket socket(); public abstract ServerSocket accept() throws IOException; public final int validOps() }
ServerSocketChannel是一個基於通道的socket監聽器。它同我們所熟悉的java.net.ServerSocket執行相同的基本任務,不過它增加了通道語義,因此能夠在非阻塞模式下運行。
用靜態的open()工廠方法創建一個新的ServerSocketChannel對象,將會返回同一個未綁定的java.net.ServerSocket關聯的通道。該對等ServerSocket可以通過在返回的ServerSocketChannel上調用socket()方法來獲取。作為ServerSocketChannel的對等體被創建的ServerSocket對象依賴通道實現。這些socket關聯的SocketImpl能識別通道。通道不能被封裝在隨意的socket對象外面。
由於ServerSocketChannel沒有bind()方法,因此有必要取出對等的socket並使用它來綁定到一個端口以開始監聽連接。我們也是使用對等ServerSocket的API來根據需要設置其他的socket選項。
ServerSocketChannel ssc = ServerSocketChannel.open(); ServerSocket serverSocket = ssc.socket(); // 監聽端口1234 serverSocket.bind (new InetSocketAddress(1234));
同它的對等體java.net.ServerSocket一樣,ServerSocketChannel也有accept()方法。一旦您創建了一個ServerSocketChannel並用對等socket綁定了它,然后您就可以在其中一個上調用accept()。如果您選擇在ServerSocket上調用accept()方法,那么它會同任何其他的ServerSocket表現一樣的行為:總是阻塞並返回一個java.net.Socket對象。如果您選擇在ServerSocketChannel上調用accept()方法則會返回SocketChannel類型的對象,返回的對象能夠在非阻塞模式下運行。假設系統已經有一個安全管理器(security manager),兩種形式的方法調用都執行相同的安全檢查。
如果以非阻塞模式被調用,當沒有傳入連接在等待時,ServerSocketChannel.accept()會立即返回null。正是這種檢查連接而不阻塞的能力實現了可伸縮性並降低了復雜性。可選擇性也因此得到實現。我們可以使用一個選擇器實例來注冊一個ServerSocketChannel 對象以實現新連接到達時自動通知的功能。例 3-7 演示了如何使用一個非阻塞的accept()方法:
/* *例 3-7 使用ServerSocketChannel的非阻塞accept()方法 */ package com.ronsoft.books.nio.channels; import java.nio.ByteBuffer; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.net.InetSocketAddress; /** * Test nonblocking accept() using ServerSocketChannel. * Start this program, then "telnet localhost 1234" to * connect to it. * * @author Ron Hitchens (ron@ronsoft.com) */ public class ChannelAccept { public static final String GREETING = "Hello I must be going.\r\n"; public static void main (String [] argv) throws Exception { int port = 1234; //默認端口 if (argv.length > 0) { port = Integer.parseInt(argv[0]); } ByteBuffer buffer = ByteBuffer.wrap (GREETING.getBytes()); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.socket().bind (new InetSocketAddress(port)); ssc.configureBlocking(false); while(true) { System.out.println ("Waiting for connections"); SocketChannel sc = ssc.accept(); if (sc == null) { // no connections, snooze a while Thread.sleep (2000); } else { System.out.println ("Incoming connection from: " + sc.socket().getRemoteSocketAddress()); buffer.rewind(); sc.write(buffer); sc.close(); } } } }
前面列出的最后一個方法validOps()是同選擇器一起使用的。
Reference:
Java nio入門教程詳解(三)
