Java I/O API之性能分析 (下)(轉)
四、注冊與處理過程詳解
接下來我們要分析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%的優勢;如果文件較小,優勢不明顯。[@more@]