程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Servlet API和NIO: 最終組合在一起

Servlet API和NIO: 最終組合在一起

編輯:關於JAVA

NIO 是帶有 JDK 1.4 的 Java 平台的最有名(如果不是最出色的)的添加部分之一。下面的許多文章闡述了 NIO 的基本知識及如何利用非阻塞通道的好處。但它們所遺漏的一件事正是,沒有充分地展示 NIO 如何可以提高 J2EE Web 層的可伸縮性。對於企業開發人員來說,這些信息特別密切相關,因為實現 NIO 不像把少數幾個 import 語句改變成一個新的 I/O 包那樣簡單。首先,Servlet API 采用阻塞 I/O 語義,因此默認情況下,它不能利用非阻塞 I/O。其次,不像 JDK 1.0 中那樣,線程不再是“資源獨占”(resource hog),因此使用較少的線程不一定表明服務器可以處理更多的客戶機。

在本文中,為了創建基於 Servlet 並實現了 NIO 的 Web 服務器,您將學習如何解決 Servlet API 與非阻塞 I/O 的不配合問題。我們將會看到在多元的 Web 服務器環境中,這個服務器是如何針對標准 I/O 服務器(Tomcat 5.0)進行伸縮的。為符合企業中生存期的事實,我們將重點放在當保持 socket 連接的客戶機數量以指數級增長時,NIO 與標准 I/O 相比較的情況如何。

注意,本文針對某些 Java 開發人員,他們已經熟悉了 Java 平台上 I/O 編程的基礎知識。

線程不再昂貴

大家都知道,線程是比較昂貴的。在 Java 平台的早期(JDK 1.0),線程的開銷是一個很大負擔,因此強制開發人員自定義生成解決方案。一個常見的解決方案是使用 VM 啟動時創建的線程池,而不是按需創建每個新線程。盡管最近在 VM 層上提高了線程的性能,但標准 I/O 仍然要求分配惟一的線程來處理每個新打開的 socket。就短期而言,這工作得相當不錯,但當線程的數量增加超過了 1K,標准 I/O 的不足就表現出來了。由於要在線程間進行上下文切換,因此 CPU 簡直變成了超載。

由於 JDK 1.4 中引入了 NIO,企業開發人員最終有了“單線程”模型的一個內置解決方案:多元 I/O 使得固定數量的線程可以服務不斷增長的用戶數量。

多路復用(Multiplexing)指的是通過一個載波來同時發送多個信號或流。當使用手機時,日常的多路復用例子就發生了。無線頻率是稀有的資源,因此無線頻率提供商使用多路復用技術通過一個頻率發送多個呼叫。在一個例子中,把呼叫分成一些段,然後給這些段很短的持續時間,並在接收端重新裝配。這就叫做 時分多路復用(time-division multiplexing),即 TDM。

在 NIO 中,接收端相當於“選擇器”(參閱 java.nio.channels.Selector )。不是處理呼叫,選擇器是處理多個打開的 socket。就像在 TDM 中那樣,選擇器重新裝配從多個客戶機寫入的數據段。這使得服務器可以用單個線程管理多個客戶機。

Servlet API 和 NIO

對於 NIO,非阻塞讀寫是必要的,但它們並不是完全沒有麻煩。除了不會阻塞之外,非阻塞讀不能給呼叫方任何保證。客戶機或服務器應用程序可能讀取完整信息、部分消息或者根本讀取不到消息。另外,非阻塞讀可能讀取到太多的消息,從而強制為下一個呼叫准備一個額外的緩沖區。最後,不像流那樣,讀取了零字節並不表明已經完全接收了消息。

這些因素使得沒有輪詢就不可能實現甚至是簡單的 readline 方法。所有的 servlet 容器必須在它們的輸入流上提供 readline 方法。因此,許多開發人員放棄了創建基於 Servlet 並實現了 NIO 的 Web 應用程序服務器。不過這裡有一個解決方案,它組合了 Servlet API 和 NIO 的多元 I/O 的能力。

在下面的幾節中,您將學習如何使用 java.io.PipedInput 和 PipedOutputStream 類來把生產者/消費者模型應用到消費者非阻塞 I/O。當讀取非阻塞通道時,把它寫到正由第二個線程消費的管道。注意,這種分解映射線程不同於大多數基於 Java 的客戶機/服務器應用程序。這裡,我們讓一個線程單獨負責處理非阻塞通道(生產者),讓另一個線程單獨負責把數據作為流消費(消費者)。管道也為應用程序服務器解決了非阻塞 I/O 問題,因為 servlet 在消費 I/O 時將采用阻塞語義。

示例服務器

示例服務器展示了 Servlet API 和 NIO 不兼容的生產者/消費者解決方案。該服務器與 Servlet API 非常相似,可以為成熟的基於 NIO 應用程序服務器提供 POC (proof of concept),是專門編寫來衡量 NIO 相對於標准 Java I/O 的性能的。它處理簡單的 HTTP get 請求,並支持來自客戶機的 Keep-Alive 連接。這是重要的,因為多路復用 I/O 只證明在要求服務器處理大量打開的 scoket 連接時是有意的。

該服務器被分成兩個包: org.sse.server 和 org.sse.http 包中有提供主要 服務器 功能的類,比如如下的一些功能:接收新客戶機連接、閱讀消息和生成工作線程以處理請求。http 包支持 HTTP 協議的一個子集。詳細闡述 HTTP 超出了本文的范圍。

現在讓我們來看一下 org.sse.server 包中一些最重要的類。

Server 類

Server 類擁有多路復用循環 —— 任何基於 NIO 服務器的核心。在清單 1 中,在服務器接收新客戶機或檢測到正把可用的字節寫到打開的 socket 前, select() 的調用阻塞了。這與標准 Java I/O 的主要區別是,所有的數據都是在這個循環中讀取的。通常會把從特定 socket 中讀取字節的任務分配給一個新線程。使用 NIO 選擇器事件驅動方法,實際上可以用單個線程處理成千上萬的客戶機,不過,我們還會在後面看到線程仍有一個角色要扮演。

每個 select() 調用返回一組事件,指出新客戶機可用;新數據准備就緒,可以讀取;或者客戶機准備就緒,可以接收響應。server 的 handleKey() 方法只對新客戶機( key.isAcceptable() )和傳入數據 ( key.isReadable() ) 感興趣。到這裡,工作就結束了,轉入 ServerEventHandler 類。

清單 1. Server.java 選擇器循環

public void listen() {
   SelectionKey key = null;
   try {
    while (true) {
      selector.select();
      Iterator it = selector.selectedKeys().iterator();
      while (it.hasNext()) {
       key = (SelectionKey) it.next();
       handleKey(key);
       it.remove();
      }
    }
   } catch (IOException e) {
    key.cancel();
   } catch (NullPointerException e) {
    // NullPointer at sun.nio.ch.WindowsSelectorImpl, Bug: 4729342
    e.printStackTrace();
   }
}

ServerEventHandler 類

ServerEventHandler 類響應服務器事件。當新客戶機變為可用時,它就實例化一個新的 Client 對象,該對象代表了那個客戶機的狀態。數據是以非阻塞方式從通道中讀取的,並被寫到 Client 對象中。ServerEventHandler 對象也維護請求隊列。為了處理(消費)隊列中的請求,生成了不定數量的工作線程。在傳統的生產者/消費者方式下,為了在隊列變為空時線程會阻塞,並在新請求可用時線程會得到通知,需要寫 Queue 。

為了支持等待的線程,在清單 2 中已經重寫了 remove() 方法。如果列表為空,就會增加等待線程的數量,並阻塞當前線程。它實質上提供了非常簡單的線程池。

清單 2. Queue.java

public class Queue extends LinkedList
{
  private int waitingThreads = 0;
  public synchronized void insert(Object obj)
  {
  addLast(obj);
  notify();
  }
  public synchronized Object remove()
  {
  if ( isEmpty() ) {
   try { waitingThreads++; wait();}
   catch (InterruptedException e) {Thread.interrupted();}
   waitingThreads--;
  }
  return removeFirst();
  }
  public boolean isEmpty() {
  return (size() - waitingThreads <= 0);
  }
}

工作線程的數量與 Web 客戶機的數量無關。不是為每個打開的 socket 分配一個線程,相反,我們把所有請求放到一個由一組 RequestHandlerThread 實例所服務的通用隊列中。理想情況下,線程的數量應該根據處理器的數量和請求的長度或持續時間進行調整。如果請求通過資源或處理需求花了很長時間,那麼通過添加更多的線程,可以提高感知到的服務質量。

注意,這不一定提高整體的吞吐量,但確實改善了用戶體驗。即使在超載的情況下,也會給每個線程一個處理時間片。這一原則同樣適用於基於標准 Java I/O 的服務器;不過這些服務器是受到限制的,因為會 要求 它們為每個打開的 socket 連接分配一個線程。NIO 服務器完全不用擔心這一點,因此它們可以擴展到大量用戶。最後的結果是 NIO 服務器仍然需要線程,只是不需要那麼多。

請求處理

Client 類有兩個用途。首先,通過把傳入的非阻塞 I/O 轉換成可由 Servlet API 消費的阻塞 InputStream ,它解決了阻塞/非阻塞問題。其次,它管理特定客戶機的請求狀態。因為當全部讀取消息時,非阻塞通道沒有給出任何提示,所以強制我們在協議層處理這一情況。Client 類在任意指定的時刻都指出了它是否正在參與進行中的請求。如果它准備處理新請求, write() 方法就會為請求處理而將該客戶機排到隊列中。如果它已經參與了請求,它就只是使用 PipedInputStream 和 PipedOutputStream 類把傳入的字節轉換成一個 InputStream 。

圖 1 展示了兩個線程圍繞管道進行交互。主線程把從通道讀取的數據寫到管道中。管道把相同的數據作為 InputStream 提供給消費者。管道的另一個重要特性是:它是進行緩沖處理的。如果沒有進行緩沖處理,主線程在嘗試寫到管道時就會阻塞。因為主線程單獨負責所有客戶機間的多路復用,因此我們不能讓它阻塞。

圖 1. PipedInput/OutputStream

在 Client 自己排隊後,工作線程就可以消費它了。RequestHandlerThread 類承擔了這個角色。至此,我們已經看到主線程是如何連續地循環的,它要麼接受新客戶機,要麼讀取新的 I/O。工作線程循環等待新請求。當客戶機在請求隊列上變為可用時,它就馬上被 remove() 方法中阻塞的第一個等待線程所消費。

清單 3. RequestHandlerThread.java

public void run() {
   while (true) {
    Client client = (Client) myQueue.remove();
    try {
      for (; ; ) {
       HttpRequest req = new HttpRequest(client.clientInputStream,
         myServletContext);
       HttpResponse res = new HttpResponse(client.key);
       defaultServlet.service(req, res);
       if (client.notifyRequestDone())
         break;
      }
    } catch (Exception e) {
      client.key.cancel();
      client.key.selector().wakeup();
    }
   }
}

然後該線程創建新的 HttpRequest 和 HttpResponse 實例,並調用 defaultServlet 的 service 方法。注意, HttpRequest 是用 Client 對象的 clientInputStream 屬性構造的。PipedInputStream 就是負責把非阻塞 I/O 轉換成阻塞流。

從現在開始,請求處理就與您在 J2EE Servlet API 中期望的相似。當對 servlet 的調用返回時,工作線程在返回到池中之前,會檢查是否有來自相同客戶機的另一個請求可用。注意,這裡用到了單詞 池 (pool)。事實上,線程會對隊列嘗試另一個 remove() 調用,並變成阻塞,直到下一個請求可用。

運行示例

示例服務器實現了 HTTP 1.1 協議的一個子集。它處理普通的 HTTP get 請求。它帶有兩個命令行參數。第一個指定端口號,第二個指定 HTML 文件所駐留的目錄。在解壓文件後, 切換到項目目錄,然後執行下面的命令,注意要把下面的 webroot 目錄替換為您自己的目錄:

java -cp bin org.sse.server.Start 8080
"C:\mywebroot"

還請注意,服務器並沒有實現目錄清單,因此必須指定有效的 URL 來指向您的 webroot 目錄下的文件。

性能結果

示例 NIO 服務器是在重負載下與 Tomcat 5.0 進行比較的。選擇 Tomcat 是因為它是基於標准 Java I/O 的純 Java 解決方案。為了提高可伸縮性,一些高級的應用程序服務器是用 JNI 本機代碼優化的,因此它們沒有提供標准 I/O 和 NIO 之間的很好比較。目標是要確定 NIO 是否給出了大量的性能優勢,以及是在什麼條件下給出的。

如下是一些說明:

Tomcat 是用最大的線程數量 2000 來配置的,而示例服務器只允許用 4 個工作線程運行。

每個服務器是針對相同的一組簡單 HTTP get 測試的,這些 HTTP get 基本上由文本內容組成。

把加載工具(Microsoft Web Application Stress Tool)設置為使用“Keep-Alive”會話,導致了大約要為每個用戶分配一個 socket。然後它導致了在 Tomcat 上為每個用戶分配一個線程,而 NIO 服務器用固定數量的線程來處理相同的負載。

圖 2 展示了在不斷增加負載下的“請求/秒”率。在 200 個用戶時,性能是相似的。但當用戶數量超過 600 時,Tomcat 的性能開始急劇下降。這最有可能是由於在這麼多的線程間切換上下文的開銷而導致的。相反,基於 NIO 的服務器的性能則以線性方式下降。記住,Tomcat 必須為每個用戶分配一個線程,而 NIO 服務器只配置有 4 個工作線程。

圖 2. 請求/秒

圖 3 進一步顯示了 NIO 的性能。它展示了操作的 Socket 連接錯誤數/分鐘。同樣,在大約 600 個用戶時,Tomcat 的性能急劇下降,而基於 NIO 的服務器的錯誤率保持相對較低。

圖 3. Socket 連接錯誤數/分鐘

結束語

在本文中您已經學習了,實際上可以使用 NIO 編寫基於 Servlet 的 Web 服務器,甚至可以啟用它的非阻塞特性。對於企業開發人員來說,這是好消息,因為在企業環境中,NIO 比標准 Java I/O 更能夠進行伸縮。不像標准的 Java I/O,NIO 可以用固定數量的線程處理許多客戶機。當基於 Servlet 的 NIO Web 服務器用來處理保持和擁有 socket 連接的客戶機時,會獲得更好的性能。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved