使用NIO的server编程框架
?
作為Java EE Web層面的最前端,HTTP引擎是負(fù)責(zé)接收客戶請求的最開始的部分,這部分的性能在很大程度上決定了整個Java EE產(chǎn)品的性能和可擴(kuò)展性。回顧現(xiàn)有的J2EE產(chǎn)品,大部分的HTTP引擎都不是用純Java編寫的。例如,Sun的JES應(yīng)用服務(wù)器內(nèi)置了一個用本地語言(C/C++)開發(fā)Web服務(wù)器,JBoss的Web Server也不是純Java的,它使用了大量與平臺相關(guān)的運(yùn)行庫,只不過通過Apache的APR項目(http://apr.apache.org)來維護(hù)跨平臺的特性。而那些純Java的J2EE服務(wù)器,在部署的時候也推薦前置一個其他的Web服務(wù)器,例如(Apache、IIS等)。
使用純Java來構(gòu)建具有擴(kuò)展性很好的服務(wù)器軟件,一直是一個比較困難的事情,特別是在單個的Java虛擬機(jī)上(非集群的環(huán)境)。這是由Java的線程模型和網(wǎng)絡(luò)IO的特性所決定的。在JDK 1.4以前,Java的網(wǎng)絡(luò)IO的接口都是阻塞式的,這意味著網(wǎng)絡(luò)的阻塞會引起處理線程的停止,因此每個用戶請求的處理從開始到最后完成,需要單獨(dú)的處理線程。而Java的線程資源的分配和線程的調(diào)度都是有很大開銷的,這使得在大量請求(數(shù)千個甚至上萬個)同時到達(dá)的情況下,單個Java虛擬機(jī)很難滿足大并發(fā)性的需要。為了解決可擴(kuò)展性的問題,一些解決方案使用了多個Java虛擬機(jī)或者多個機(jī)器節(jié)點進(jìn)行集群來滿足大并發(fā)的請求。
JDK 1.4版本(包括之后的版本)最顯著的新特性就是增加了NIO(New IO),能夠以非阻塞的方式處理網(wǎng)絡(luò)的請求,這就使得在Java中只需要少量的線程就能處理大量的并發(fā)請求了。但是使用NIO不是一件簡單的技術(shù),它的一些特點使得編程的模型比原來阻塞的方式更為復(fù)雜。
Grizzly作為GlassFish中非常重要的一個項目,就是用NIO的技術(shù)來實現(xiàn)應(yīng)用服務(wù)器中的高性能純Java的HTTP引擎。Grizzly還是一個獨(dú)立于GlassFish的框架結(jié)構(gòu),可以單獨(dú)用來擴(kuò)展和構(gòu)建自己的服務(wù)器軟件。
本章重點:
l?? NIO的基本特點和編程方式
l?? Grizzly的基本結(jié)構(gòu)
l?? Grizzly對NIO技術(shù)的運(yùn)用手段
l?? Grizzly對性能上的考慮和優(yōu)化
17.1 NIO簡介
理解NIO是學(xué)習(xí)本章的重要前提,因為Grizzly本身就是基于NIO的框架結(jié)構(gòu),所有的技術(shù)問題都是在NIO的技術(shù)上進(jìn)行討論的。如果讀者對NIO不了解的話,建議首先了解NIO的基本概念。對NIO的介紹和學(xué)習(xí)指南很多,本章不會對NIO做詳細(xì)的講解。下面僅對NIO做一個簡單的介紹,并列出與本章內(nèi)容相關(guān)的一些NIO特性。
17.1.1 NIO的基本概念
在JDK 1.4的新特性中,NIO無疑是最顯著和鼓舞人心的。NIO的出現(xiàn)事實上意味著Java虛擬機(jī)的性能比以前的版本有了較大的飛躍。在以前的JVM的版本中,代碼的執(zhí)行效率不高(在最原始的版本中Java是解釋執(zhí)行的語言),用Java編寫的應(yīng)用程序通常所消耗的主要資源就是CPU,也就是說應(yīng)用系統(tǒng)的瓶頸是CPU的計算和運(yùn)行能力。在不斷更新的Java虛擬機(jī)版本中,通過動態(tài)編譯技術(shù)使得Java代碼執(zhí)行的效率得到大幅度提高,幾乎和操作系統(tǒng)的本地語言(例如C/C++)的程序不相上下。在這種情況下,應(yīng)用系統(tǒng)的性能瓶頸就從CPU轉(zhuǎn)移到IO操作了。尤其是服務(wù)器端的應(yīng)用,大量的網(wǎng)絡(luò)IO和磁盤IO的操作,使得IO數(shù)據(jù)等待的延遲成為影響性能的主要因素。NIO的出現(xiàn)使得Java應(yīng)用程序能夠更加緊密地結(jié)合操作系統(tǒng),更加充分地利用操作系統(tǒng)的高級特性,獲得高性能的IO操作。
NIO在磁盤IO處理和文件處理上有很多新的特性來提高性能,本文不作詳細(xì)的解釋,而僅僅介紹NIO在處理網(wǎng)絡(luò)IO方面的新特點,這些特點是理解Grizzly的最基本的概念。
1. 數(shù)據(jù)緩沖(Buffer)處理
數(shù)據(jù)緩沖(Buffer)是IO操作的基本元素。其實從本質(zhì)上來說,無論是磁盤IO還是網(wǎng)絡(luò)IO,應(yīng)用程序所作的所有事情就是把數(shù)據(jù)放到相應(yīng)的數(shù)據(jù)緩沖當(dāng)中去(寫操作),或者從相應(yīng)的數(shù)據(jù)緩沖中提取數(shù)據(jù)(讀操作)。至于數(shù)據(jù)緩沖中的數(shù)據(jù)和IO設(shè)備之間的交互,則是操作系統(tǒng)和硬件驅(qū)動程序所關(guān)心的事情了。因此,數(shù)據(jù)緩沖在IO操作中具有重要的作用,是操作系統(tǒng)與應(yīng)用之間的IO橋梁。在NIO的包中,Buffer類是所有類的基礎(chǔ)。Buffer類當(dāng)中定義數(shù)據(jù)緩沖的基本操作,包括put、get、reset、clear、flip、rewind等,這些基本操作是進(jìn)行數(shù)據(jù)輸入輸出的手段。每一個基本的Java類型(boolean除外)都有相應(yīng)的Buffer類,例如CharBuffer、IntBuffer、DoubleBuffer、ShortBuffer、LongBuffer、FloatBuffer和ByteBuffer。我們所關(guān)心的是ByteBuffer,因為操作系統(tǒng)與應(yīng)用程序之間的數(shù)據(jù)通信最原始的類型就是Byte。
“Direct ByteBuffer”是一個值得關(guān)注的Buffer類型。在創(chuàng)建ByteBuffer的時候可以使用ByteBuffer.allocateDirect()來創(chuàng)建一塊直接(Direct)的ByteBuffer。這一塊數(shù)據(jù)緩沖和一般的緩沖不一樣。第一,它是一塊連續(xù)的空間。第二,它的實現(xiàn)不是純Java的代碼,而是本地代碼,它內(nèi)存的分配不在Java的堆棧中,不受Java內(nèi)存回收的影響。這種直接的ByteBuffer是NIO用來保證性能的重要手段。剛才提到,數(shù)據(jù)緩沖是操作系統(tǒng)和應(yīng)用程序之間的IO接口。應(yīng)用程序?qū)⑿枰皩懗鋈ァ钡臄?shù)據(jù)放到數(shù)據(jù)緩沖中,操作系統(tǒng)從這塊緩沖中獲得數(shù)據(jù)執(zhí)行寫的操作。當(dāng)IO設(shè)備數(shù)據(jù)傳進(jìn)來的時候,操作系統(tǒng)就會將數(shù)據(jù)放到相應(yīng)的數(shù)據(jù)緩沖中,應(yīng)用程序從緩沖中“讀進(jìn)”數(shù)據(jù)進(jìn)行處理。一般的Java對象很難勝任這個直接的數(shù)據(jù)緩沖的工作。因為Java對象所占用的內(nèi)存空間不一定是連續(xù)的,而且經(jīng)常由于內(nèi)存回收而改變地址。而操作系統(tǒng)需要的是一片連續(xù)的不變動的地址空間,才能完成IO操作。在原來的Java版本中需要Java虛擬機(jī)的介入,將數(shù)據(jù)進(jìn)行轉(zhuǎn)換、拷貝才能被操作系統(tǒng)所使用。而通過“Direct ByteBuffer”,應(yīng)用程序能夠直接與操作系統(tǒng)進(jìn)行交流,大大減少了系統(tǒng)調(diào)用的次數(shù),提高了執(zhí)行的效率。
數(shù)據(jù)緩沖的另外一個重要的特點是可以在一個數(shù)據(jù)緩沖上再建立一個或多個視圖(View)緩沖。這個概念有些類似于數(shù)據(jù)庫視圖的概念:在數(shù)據(jù)庫的物理表(Table)結(jié)構(gòu)之上可以建立多個視圖。同樣,在一個數(shù)據(jù)緩沖之上也可以建立多個邏輯的視圖緩沖。視圖緩沖的用處很多,例如可以將Byte類型的緩沖當(dāng)作Int類型的視圖,來進(jìn)行類型轉(zhuǎn)換。視圖緩沖也可以將一個大的緩沖看成是很多小的緩沖視圖。這對提高性能很有幫助,因為創(chuàng)建物理的數(shù)據(jù)緩沖(特別是直接的數(shù)據(jù)緩沖)是非常耗時的操作,而創(chuàng)建視圖卻非常快。在Grizzly中就有這方面的考慮。
2. 異步通道(Channel)
Channel(后文又稱頻道,譯法僅暗示存在多通道可選)是NIO的另外一個比較重要的新特點。Channel并不是對原有Java類的擴(kuò)充和完善,而是完全嶄新的實現(xiàn)。通過Channel,Java應(yīng)用程序能夠更好地與操作系統(tǒng)的IO服務(wù)結(jié)合起來,充分地利用上文提到的ByteBuffer,完成高性能的IO操作。Channel的實現(xiàn)也不是純Java的,而是和操作系統(tǒng)結(jié)合緊密的本地代碼。
Channel的一個重要的特點是在網(wǎng)絡(luò)套接字頻道(SocketChannel)中,可以將其設(shè)置為異步非阻塞的方式。
【例17.1】非阻塞方式的頻道使用:
SocketChannel sc = SocketChannel.open();
sc.configureBlocking(false); // nonblocking
...
if (!sc.isBlocking()) {
doSomething(cs);
}
通過SocketChannel.configureBlocking(false)就可以將網(wǎng)絡(luò)套接字頻道設(shè)置為異步非阻塞模式。一旦設(shè)置成非阻塞的方式,從Socket中讀和寫就再也不會阻塞。雖然非阻塞只是一個設(shè)置問題,但是對應(yīng)用程序的結(jié)構(gòu)和性能卻產(chǎn)生了天翻地覆的變化。
3. 有條件的選擇(Readiness Selection)
熟悉UNIX的程序員對POSIX的select()或poll()函數(shù)應(yīng)該比較熟悉。在現(xiàn)在大多數(shù)流行的操作系統(tǒng)中,都支持有條件地選擇已經(jīng)準(zhǔn)備好的IO通道,這就使得只需要一個線程就能同時有效地管理多個IO通道。在JDK 1.4以前,Java語言是不具備這個功能的。
NIO通過幾個關(guān)鍵的類來實現(xiàn)這種有條件的選擇的功能:
(1)?? Selector
Selector類維護(hù)了多個注冊的Channel以及它們的狀態(tài)。Channel需要向Selector注冊,Selector負(fù)責(zé)維護(hù)和更新Channel的狀態(tài),以表明哪些Channel是準(zhǔn)備好的。
(2)?? SelectableChannel
SelectableChannel是可以被Selector所管理的Channel。FileChannel不屬于Selectable- Channel,而SocketChannel是屬于這類的Channel。因此在NIO中,只有網(wǎng)絡(luò)的IO操作才有可能被有條件地選擇。
(3)?? SelectionKey
SelectionKey用于維護(hù)Selector和SelectableChannel之間的映射關(guān)系。當(dāng)一個Channel向Selector注冊之后,就會返回一個SelectionKey作為注冊的憑證。SelectionKey中保存了兩類狀態(tài)值,一是這個Channel中哪些操作是被注冊了的,二是有哪些操作是已經(jīng)準(zhǔn)備好的。
17.1.2 NIO之前的Server程序的架構(gòu)
在NIO出現(xiàn)以前(甚至在NIO出現(xiàn)了很長時間的現(xiàn)在),在用Java編寫服務(wù)器端的程序時,服務(wù)請求的接收模塊大多數(shù)都會采用以下的框架(例如在Tomcat中的連接接入點:org.apache.tomcat.util.net.PoolTcpEndpoint就有相類似的結(jié)構(gòu))。
【例17.2】阻塞方式的server編程框架:
class Server implements Runnable {
public void run() {
try {
ServerSocket ss = new ServerSocket(PORT);
while (!Thread.interrupted())
new Thread(new Handler(ss.accept())).start();
} catch (IOException ex) { /* ... */ }
}
static class Handler implements Runnable {
final Socket socket;
Handler(Socket s) { socket = s; }
public void run() {
try {
byte[] input = new byte[MAX_INPUT];
socket.getInputStream().read(input);
byte[] output = process(input);
socket.getOutputStream().write(output);
} catch (IOException ex) { /* ... */ }
}
private byte[] process(byte[] cmd) { /* ... */ }
}
}
上面的結(jié)構(gòu)比較簡單:在主線程的run()方法中,會有ServerSocket的accept()方法,它被循環(huán)地調(diào)用著,直到服務(wù)停止。accept()方法會被阻塞,直到新的連接請求的到來。當(dāng)新的連接請求進(jìn)來以后,系統(tǒng)會使用另外的線程來處理這個請求。處理線程在socket端口進(jìn)行read()調(diào)用,讀取所有的請求數(shù)據(jù)。read()也是一個阻塞的方法,一直到讀取完所有的數(shù)據(jù)才會返回。數(shù)據(jù)經(jīng)過處理以后,在同一個處理線程中將請求結(jié)果返回給客戶端。在實際情況中,會比這個結(jié)構(gòu)復(fù)雜得多,例如,處理線程是從一個線程池中獲取,而不是每次都產(chǎn)生一個新的線程。
這種結(jié)構(gòu)在大多數(shù)情況下都可以獲得很好的性能。例如Tomcat在性能指標(biāo)的測試中獲得了很高的吞吐量測量值。但是在并發(fā)性很大的情況下,這種結(jié)構(gòu)不具有很好的可擴(kuò)展性。例如有2000個客戶請求同時到來,如果想要這2000個請求被同時處理,則需要2000個處理線程。這些線程在大多數(shù)的情況下可能都不在運(yùn)行,而是阻塞在read()或write()的方法上了。在一臺機(jī)器或者一個Java虛擬機(jī)上運(yùn)行上千個線程是個挑戰(zhàn),線程經(jīng)常會阻塞,因此CPU會在這些線程之間來回調(diào)度和切換,這會引起大量的系統(tǒng)調(diào)用和資源競爭,使得整個系統(tǒng)的擴(kuò)展性能不高。
17.1.3 使用NIO來提高系統(tǒng)擴(kuò)展性
NIO使用非阻塞的API,通過實現(xiàn)少量的線程就能服務(wù)于大量的并發(fā)用戶的請求。并且通過操作系統(tǒng)都支持的POSIX標(biāo)準(zhǔn)的select方式,來獲得系統(tǒng)準(zhǔn)備就緒的資源。使用這些手段,NIO就能夠充分利用每個活動的線程來服務(wù)于大量的請求,減少系統(tǒng)資源的浪費(fèi)。通常來說,一個NIO的服務(wù)架構(gòu)會采用以下的結(jié)構(gòu)。
【例17.3】使用NIO的server編程框架:
public class Server {
?? public static void main(String[] argv) throws Exception {
?? ????? ServerSocketChannel serverCh = ServerSocketChannel.open();
?? ????? Selector selector = Selector.open();
?? ????? ServerSocket serverSocket = serverCh.socket();
?? ????? serverSocket.bind(new InetSocketAddress(80));
?? ????? serverCh.configureBlocking(false);
?? ????? serverCh.register(selector,SelectionKey.OP_ACCEPT);
?? ????? while(true){
?? ????????? selector.select();
?? ????????? Iterator it = selector.selectedKeys().iterator();
?? ????????? while (it.hasNext()) {
?? ????????????? SelectionKey key = (SelectionKey)it.next();
?? ????????????? if (key.isAcceptable()) {
ServerSocketChannel server =
(ServerSocketChannel)key.channel();
?? ?????????????? ?? SocketChannel channel = server.accept();
?? ??????????????? ?? channel.configureBlocking(false);
?? ?????????????? ?? channel.register(selector, SelectionKey.OP_READ);
?? ????????????? }
?? ????????????? if (key.isReadable()) {
?? ????????????????? readDataFromSocket(key);
?? ????????????? }
?? ????????????? it.remove();
??????????? }
?? ????? }
}
}
上面的結(jié)構(gòu)比起阻塞式的框架都復(fù)雜一些。具體說明如下:
l?? 通過ServerSocketChannel.open()獲得一個Server的Channel對象。
l?? 通過Selector.open()來獲得一個Selector對象。
l?? 從Server的Channel對象上可以獲得一個Server的Socket,并讓它在80端口監(jiān)聽。
l?? 通過ServerSocketChannel.configureBlocking(false)可以將當(dāng)前的Channel配置成異步非阻塞的方式。如果沒有這一步,那么Channel默認(rèn)的方式跟傳統(tǒng)的一樣,是阻塞式的。
l?? 將當(dāng)前的Channel注冊到Selector對象中去,并告訴Selector當(dāng)前的Channel關(guān)心的操作是OP_ACCEPT,也就是當(dāng)有新的請求的時候,Selector負(fù)責(zé)更新此Channel的狀態(tài)。
l?? 在循環(huán)當(dāng)中調(diào)用selector.select(),如果當(dāng)前沒有任何新的請求過來,并且原來的連接也沒有新的請求數(shù)據(jù)到達(dá),這個方法會阻塞住,一直等到新的請求數(shù)據(jù)過來為止。
l?? 如果當(dāng)前都請求的數(shù)據(jù)到達(dá),那么selector.select()就會立刻退出,這時候可以從selector.selectedKeys()獲得所有在當(dāng)前selector注冊過的并且有數(shù)據(jù)到達(dá)的這些Channel的信息(SelectionKey)。
l?? 遍歷所有的這些SelectionKey來獲得相關(guān)的信息。如果某個SelectionKey的操作是OP_ACCEPT,也就是isAcceptable,那么可以判定這是那個Server Channel,并且是有新的連接請求到達(dá)了。
l?? 當(dāng)有新的請求來的時候,通過accept()方法可以獲得新的channel服務(wù)于這個新來的請求。然后通過configureBlocking(false)可以將當(dāng)前的Channel配置成異步非阻塞的方式。
l?? 接著將這個新的channel也注冊到selector中,并告訴Selector當(dāng)前的Channel關(guān)心的操作是OP_READ,也就是當(dāng)前Channel有新的數(shù)據(jù)到達(dá)的時候,Selector負(fù)責(zé)更新此Channel的狀態(tài)。
l?? 如果在循環(huán)當(dāng)中發(fā)現(xiàn)某個SelectionKey的操作是OP_READ,也就是isReadable,那么可以判定這不是那個Server Channel,而是在循環(huán)內(nèi)部注冊的連接Channel,表明當(dāng)前SelectionKey對應(yīng)的這個Channel有數(shù)據(jù)到達(dá)了。
l?? 有數(shù)據(jù)到達(dá)之后的處理方式是下面要詳細(xì)討論的問題,在這里,我們簡單地用一個方法readDataFromSocket(key)來表示,功能就是從這個Channel中讀取數(shù)據(jù)。
從這個框架結(jié)構(gòu)中可以看到,在一個線程中可以同時服務(wù)于多個連接,包括Server的監(jiān)聽服務(wù)。在同一個時刻,并不是所有的連接都會有數(shù)據(jù)到達(dá),因此為每一個連接分配單獨(dú)的線程沒有必要。使用異步非阻塞方式,可以使用很少的線程,通過Select的方式來服務(wù)于多個連接請求,效率大大提高。
17.1.4 使用NIO來制作HTTP引擎的最大挑戰(zhàn)
程序?qū)嵗?7.3使用了configureBlocking(false)方法來將一個Channel設(shè)置成非阻塞式的。如何使用這個非阻塞的特性,請參看下面的方法調(diào)用:
count = socketChannel.read(byteBuffer)); //非阻塞的方式
阻塞式的方法調(diào)用如下:
count = socket.getInputStream().read(input); //阻塞的方式
阻塞的方式下的read,會一直等到byte[]類型的input被充滿,或者InputStream遇到EOF(socket連接被關(guān)閉)的時候,這個函數(shù)調(diào)用才會被返回。而非阻塞的方式,立刻就返回了,當(dāng)前連接中有多少數(shù)據(jù)就讀多少。正因為有了這種非阻塞的模式,當(dāng)前的線程在讀了某個通道的數(shù)據(jù)之后,可以接著再讀另外一個通道的數(shù)據(jù),線程的利用率大大提高。
雖然線程的利用率提高了,卻帶來了一些其他的挑戰(zhàn)。最大的挑戰(zhàn)就在于:當(dāng)一個請求過來的時候,很難判斷什么時候所有請求的數(shù)據(jù)全部讀進(jìn)來了。因為每次非阻塞方式的read都可能只讀了一部分?jǐn)?shù)據(jù),甚至什么也沒有讀到。例如,一個HTTP請求:
HTTP/1.1 206 Partial content
GET http://www.w3.org/pub/WWW/TheProject.html
所有的請求數(shù)據(jù)都是以文本方式傳輸。在非阻塞的方式下,每一次對Channel進(jìn)行讀取的數(shù)據(jù)量大小不可預(yù)測,也許第一次讀了“HTTP/1.1 206 Partial content”,第二次讀取了“GET http://www.w3.org/pub/WWW”,第三次什么也沒有讀到。到底什么時候能把請求全部讀完很難預(yù)測,在極端的情況下,也許最后幾個字符永遠(yuǎn)也讀不到。在請求沒有完全讀到以前,一般不進(jìn)行請求處理,因為請求還不完整。在阻塞的情況下,讀取的函數(shù)會一直等到請求的數(shù)據(jù)全部到來并且連接關(guān)閉以后才會返回,處理起來比較簡單。但是非阻塞的方式就很復(fù)雜了。因為工作線程從一個連接讀取完準(zhǔn)備好的數(shù)據(jù)之后,又要為另一個連接服務(wù)。下次再轉(zhuǎn)到先前連接的時候,以前讀取的數(shù)據(jù)還需要恢復(fù)。還需要判斷到底所有的請求數(shù)據(jù)是否都讀完,是否可以開始對該請求的處理了。
在本章的后面各節(jié)中,我們會看到Grizzly采用了一個有限狀態(tài)機(jī)來解析HTTP請求的header信息,讀取其中的content-length數(shù)值,以便預(yù)先判斷什么時候到達(dá)請求的末尾。
總結(jié)
以上是生活随笔為你收集整理的使用NIO的server编程框架的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: websocket 聊天机器人
- 下一篇: 数据生命周期管理(Lifecycle M