Java I/O API之性能分析

 IO API的可伸縮性對Web應用有着極其重要的意義。Java 1.4版以前的API中,阻塞I/O令許多人失望。從J2SE 1.4版本開始,Java終於有了可伸縮的I/O API。本文分析並計算了新舊I/O API在可伸縮性方面的差異。
提綱:

一、概述
二、用舊API編寫的HTTP服務器
三、非阻塞的HTTP服務器
四、註冊與處理過程詳解
五、可伸縮性的定量分析和比較

正文:

一、概述

IO API的可伸縮性對Web應用有着極其重要的意義。Java 1.4版以前的API中,阻塞I/O令許多人失望。從J2SE 1.4版本開始,Java終於有了可伸縮的I/O API。本文分析並計算了新舊IO API在可伸縮性方面的差異。Java向Socket寫入數據時必須調用關聯的OutputStream的write()方法。只有當所有的數據全部寫入時,write()方法調用纔會返回。倘若發送緩衝區已滿且連接速度很低,這個調用可能需要一段時間才能完成。如果程序只使用單一的線程,其他連接就必須等待,即使那些連接已經做好了調用write()的準備也一樣。爲了解決這個問題,你必須把每一個Socket和一個線程關聯起來;採用這種方法之後,當一個線程由於I/O相關的任務被阻塞時,另一個線程仍舊能夠運行。

儘管線程的開銷不如進程那麼大,但是,考慮到底層的操作平臺,線程和進程都屬於消耗大量資源的程序結構。每一個線程都要佔用一定數量的內存,而且除此之外,多個線程還意味着線程上下文的切換,而這種切換也需要昂貴的資源開銷。因此,Java需要一個新的API來分離Socket與線程之間過於緊密的聯繫。在新的Java I/O API(java.nio.*)中,這個目標終於實現了。

本文分析和比較了用新、舊兩種I/O API編寫的簡單Web服務器。由於作爲Web協議的HTTP不再象原來那樣只用於一些簡單的目的,因此這裏介紹的例子只包含關鍵的功能,或者說,它們既不考慮安全因素,也不嚴格遵從協議規範。

二、用舊API編寫的HTTP服務器

首先我們來看看用舊式API編寫的HTTP服務器。這個實現只使用了一個類。main()方法首先創建了一個綁定到8080端口的ServerSocket:

public static void main() throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
for (int i=0; i < Integer.parseInt(args[0]); i++) {
new Httpd(serverSocket);
} 
}


接下來,main()方法創建了一系列的Httpd對象,並用共享的ServerSocket初始化它們。在Httpd的構造函數中,我們保證每一個實例都有一個有意義的名字,設置默認協議,然後通過調用其超類Thread的start()方法啓動服務器。此舉導致對run()方法的一次異步調用,而run ()方法包含一個無限循環。

在run()方法的無限循環中,ServerSocket的阻塞性accpet()方法被調用。當客戶程序連接服務器的8080端口,accept()方法將返回一個Socket對象。每一個Socket關聯着一個InputStream和一個 OutputStream,兩者都要在後繼的handleRequest()方法調用中用到。這個方法將讀取客戶程序的請求,經過檢查和處理,然後把合適的應答發送給客戶程序。如果客戶程序的請求合法,通過sendFile()方法返回客戶程序請求的文件;否則,客戶程序將收到相應的錯誤信息(調用 sendError())方法。

while (true) {
...
socket = serverSocket.accept();
...
handleRequest();
...
socket.close();
}


現在我們來分析一下這個實現。它能夠出色地完成任務嗎?答案基本上是肯定的。當然,請求分析過程還可以進一步優化,因爲在性能方面 StringTokenizer的聲譽一直不佳。但這個程序至少已經關閉了TCP延遲(對於短暫的連接來說它很不合適),同時爲外發的文件設置了緩衝。而且更重要的是,所有的線程操作都相互獨立。新的連接請求由哪一個線程處理由本機的(因而也是速度較快的)accept()方法決定。除了 ServerSocket對象之外,各個線程之間不共享可能需要同步的任何其他資源。這個方案速度較快,但令人遺憾的是,它不具有很好的可伸縮性,其原因就在於,很顯然地,線程是一種有限的資源。

三、非阻塞的HTTP服務器

下面我們來看看另一個使用非阻塞的新I/O API的方案。新的方案要比原來的方案稍微複雜一點,而且它需要各個線程的協作。它包含下面四個類:

·NIOHttpd
·Acceptor
·Connection
·ConnectionSelector

NIOHttpd 的主要任務是啓動服務器。就象前面的Httpd一樣,一個服務器Socket被綁定到8080端口。兩者主要的區別在於,新版本的服務器使用 java.nio.channels.ServerSocketChannel而不是ServerSocket。在利用bind()方法顯式地把 Socket綁定到端口之前,必須先打開一個管道(Channel)。然後,main()方法實例化了一個ConnectionSelector和一個 Acceptor。這樣,每一個ConnectionSelector都可以用一個Acceptor註冊;另外,實例化Acceptor時還提供了 ServerSocketChannel。

public static void main() throws IOException {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress(8080));
ConnectionSelector cs = new ConnectionSelector();
new Acceptor(ssc, cs);
}


爲了理解這兩個線程之間的交互過程,首先我們來仔細地分析一下Acceptor。Acceptor的主要任務是接受傳入的連接請求,並通過 ConnectionSelector註冊它們。Acceptor的構造函數調用了超類的start()方法;run()方法包含了必需的無限循環。在這個循環中,一個阻塞性的accept()方法被調用,它最終將返回一個Socket對象——這個過程幾乎與Httpd的處理過程一樣,但這裏使用的是 ServerSocketChannel的accept()方法,而不是ServerSocket的accept()方法。最後,以調用accept() 方法獲得的socketChannel對象爲參數創建一個Connection對象,並通過ConnectionSelector的queue()方法註冊它。

while (true) {
...
socketChannel = serverSocketChannel.accept();
connectionSelector.queue(new Connection(socketChannel));
...
}


總而言之:Acceptor只能在一個無限循環中接受連接請求和通過ConnectionSelector註冊連接。與Acceptor一樣, ConnectionSelector也是一個線程。在構造函數中,它構造了一個隊列,並用Selector.open()方法打開了一個 java.nio.channels.Selector。Selector是整個服務器中最重要的部分之一,它使得程序能夠註冊連接,能夠獲取已經允許讀取和寫入操作的連接的清單。

構造函數調用start()方法之後,run()方法裏面的無限循環開始執行。在這個循環中,程序調用了Selector的select()方法。這個方法一直阻塞,直到已經註冊的連接之一做好了I/O操作的準備,或Selector的wakeup()方法被調用。

while (true) {
...
int i = selector.select();
registerQueuedConnections();
...
// 處理連接...
}


當ConnectionSelector線程執行select()時,沒有一個Acceptor線程能夠用該Selector註冊連接,因爲對應的方法是同步方法,理解這一點是很重要的。因此這裏使用了隊列,必要時Acceptor線程向隊列加入連接。

public void queue(Connection connection) {
synchronized (queue) {
queue.add(connection);
}
selector.wakeup();
}


緊接着把連接放入隊列的操作,Acceptor調用Selector的wakeup()方法。這個調用導致ConnectionSelector線程繼續執行,從正在被阻塞的select()調用返回。由於Selector不再被阻塞,ConnectionSelector現在能夠從隊列註冊連接。在 registerQueuedConnections()方法中,其實施過程如下:

if (!queue.isEmpty()) {
synchronized (queue) {
while (!queue.isEmpty()) {
Connection connection =
(Connection)queue.remove(queue.size()-1);
connection.register(selector);
 }
 } 
}


四、註冊與處理過程詳解

接下來我們要分析Connection的register()方法。前面我們總是說用Selector註冊的連接,其實這是一種簡化的說法。實際上,用 Selector註冊的是一個java.nio.channels.SocketChannel對象,但只針對特定的I/O操作。註冊之後,有一個 java.nio.channels.SelectionKey被返回。這個選擇鍵可以通過attach()方法關聯到任意對象。爲了通過鍵獲得連接,這裏把Connection對象關聯到鍵。這樣,我們就可以從Selector間接地獲得一個Connection。

public void register(Selector selector)
throws IOException {
key = socketChannel.register(selector, SelectionKey.OP_READ);
key.attach(this);
}


回過頭來看ConnectionSelector。select()方法的返回值表示有多少連接已經做好了I/O操作的準備。如果返回值是0,則返回;否則,調用selectedKeys()獲得鍵的集合(Set),從這些鍵獲得以前關聯的Connection對象,然後調用其readRequest() 或writeResponse()方法,具體調用哪一個方法由連接被註冊爲讀取操作還是寫入操作決定。

現在再來看 Connection類。Connection類代表着連接,處理所有協議有關的細節。在構造函數中,通過參數傳入的SocketChannel被設置成非阻塞模式,這對於服務器來說是很重要的。另外,構造函數還設置了一些默認值,分配了緩衝區requestLineBuffer。由於分配直接緩衝區代價稍高,且這裏的每一個連接都用一個新的緩衝區,因此這裏使用java.nio.ByteBuffer.allocate()而不是 ByteBuffer.allocateDirect()。如果重用緩衝區,直接緩衝區可能具有更高的效率。

public Connection(SocketChannel socketChannel)
throws IOException {
this.socketChannel = socketChannel;
...
socketChannel.configureBlocking(false);
requestLineBuffer = ByteBuffer.allocate(512);
...
}


完成所有初始化工作且SocketChannel做好了讀取準備之後,ConnectionSelector調用了readRequest()方法,利用 socketChannel.read(requestLineBuffer)方法把所有可用的數據讀入緩衝區。如果不能讀取完整的行,則返回發出調用的 ConnectionSelector,允許另一個連接進入處理過程;反之,如果成功地讀取了整個行,接下來應該做的是象在Httpd中一樣解析請求。如果當前的請求合法,程序爲請求目標文件創建一個java.nio.Channels.FileChannel,並調用 prepareForResponse()方法。

private void prepareForResponse() throws IOException {
StringBuffer responseLine = new StringBuffer(128);
...
responseLineBuffer = ByteBuffer.wrap(
responseLine.toString().getBytes("ASCII")
);
key.interestOps(SelectionKey.OP_WRITE);
key.selector().wakeup();
}


prepareForResponse ()方法構造出緩衝區responseLine以及(如果必要的話)應答頭或錯誤信息,並把這些數據寫入responseLineBuffer。這個 ByteBuffer是一個byte數組的簡單的封裝器。生成待輸出的數據之後,我們還要通知ConnectionSelector:從現在開始不再讀取數據,而是要寫入數據了。這個通知通過調用選擇鍵的interestedOps(SelectionKey.OP_WRITE)方法完成。爲了保證選擇器能夠迅速認識到連接操作狀態的變化,接着還要調用wakeup()方法。接下來ConnectionSelector調用連接的 writeResponse()方法。首先,responseLineBuffer被寫入到Socket管道。如果緩衝區的內容全部被寫入,而且還有被請求的文件需要發送,接着調用前面打開的FileChannel的transferTo()方法。transferTo()方法通常能夠高效地把數據從文件傳輸到管道,但實際的傳輸效率依賴於底層的操作系統。任何時候,被傳輸的數據量至多相當於在無阻塞的情況下可寫入目標管道的數據量。爲安全和確保各個連接之間的公平起見,這裏把上限設置成64 KB。

如果所有數據都已經傳輸完畢,close()執行清理工作。取消Connection的註冊是這裏的主要任務,具體通過調用鍵的cancel()方法完成。

public void close() {
...
if (key != null) key.cancel();
...
}


這個新的方案性能如何呢?答案是肯定的。從原理上看,一個Acceptor和一個ConnectionSelector足以支持任意數量的打開的連接。因此,新的實現方案在可伸縮性方面佔有優勢。但是,由於兩個線程必須通過同步的queue()方法通信,它們可能互相阻塞對方。解決這個問題有兩種途徑:

·改進實現隊列的方法
·採用多個Acceptor/ConnectionSelector對

與Httpd相比,NIOHttpd的一個缺點是,對於每一個請求,就有一個新的帶緩衝的Connection對象被創建。這就導致了垃圾收集器產生的額外的CPU佔用,這部分附加代價的具體程度又與VM的類型有關。然而,Sun不厭其煩地強調說,有了Hotspot,短期生存的對象不再成爲問題。

五、可伸縮性的定量分析和比較

在可伸縮性方面,NIOHttpd到底比Httpd好多少?下面我們來看看具體的數字。首先要聲明的是,這裏的數字具有大量的推測成分,一些重要的環境因素,例如線程同步、上下文切換、換頁、硬盤速度和緩衝等,都沒有考慮到。首先評估處理r個併發的請求需要多少時間,假設被請求的文件大小是s字節,客戶端的帶寬是b字節/秒。對於Httpd,這個時間顯然直接依賴於線程的數量t,因爲同一時刻只能處理t個請求。所以Httpd的處理時間可以從公式一得到,其中c是執行請求分析之類操作的開銷常量,這個值對於每一個請求來說都是一樣的。另外,這裏假定從磁盤讀取數據的速度總是快於寫入Socket的速度,服務器帶寬總是大於客戶機帶寬之和,且CPU未滿載。因此,服務器端的帶寬、緩衝和硬盤速度等因素都不必在該公式中考慮。



圖一:公式一


然而,NIOHttpd的處理時間不再依賴於t。對於NIOHttpd,傳輸時間l在很大程度上依賴於客戶端的帶寬b、文件大小s以及前面提到的常數c。由此可以得出公式二,從該公式可以得到NIOHttpd的最小傳輸時間。



圖二:公式二


注意公式三的比值d,它度量了NIOHttpd和Httpd的性能對比關係。



圖三:公式三


進一步的分析表明,如果s、b、t和c是常數,r 趨向無窮時d的增長趨向於一個極限,從公式四可以方便地計算出這個極限。



圖四:公式四


因此,除了線程的數量和常量性的開銷,連接的時長s/b對d具有極端重要的影響。連接持續的時間越長,d值越小,NIOHttpd對比Httpd的優勢也就越高。表一顯示出,當c=10ms,t=100,s=1mb,b=8kb/s時,NIOHttpd要比Httpd快126倍。如果連接持續了很長一段時間,NIOHttpd表現出巨大的優勢。當連接時間較短時,例如在100 Mb的局域網內,如果文件較大,NIOHttpd表現出10%的優勢;如果文件較小,優勢不明顯。



上述計算假定NIOHttpd和Httpd的常量性開銷大致相同,且服務器的不同實現方式也沒有帶來新的開銷。如前所述,這個比較是一個理想條件下的比較。然而,對於形成哪一種實現方式佔有更多優勢這一概念來說,上述比較已經足夠了。值得指出的是,大多數Web文件的體積都較小,但HTTP 1.1客戶端會試圖讓連接持續儘可能長的時間(打開Keep-Alive選項)。很多時候,許多不再傳輸任何數據的連接會保持打開狀態。假設服務器上每一個線程對應着一個連接,這可能導致難以置信的資源浪費。因此,特別是對於HTTP服務器來說,利用新的Java I/O API能夠戲劇性地提高可伸縮性。

結束語: Java新的I/O API能夠有效地提高服務器的可伸縮性。與舊的API相比,新的API要複雜一些,需要更深入地瞭解多線程和同步。然而,一旦你跨越了這些障礙,就會發現新的I/O API是對Java 2平臺的必要的、有用的改進。
發佈了1 篇原創文章 · 獲贊 0 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章