轉:構建可擴展的Java EE應用

原文地址:

http://tech.ddvip.com/2008-12/122930969999962.html

http://tech.ddvip.com/2008-12/122930987199964.html

http://www.theserverside.com/news/1320914/Scaling-Your-Java-EE-Applications-Part-2

 

    對於一個具備使用價值的應用而言,其使用者有可能會在一段時間內瘋狂的增長。隨着越來越多的關鍵性質的應用在Java EE上運行,很多的Java開發者也開始關注可擴展性的問題了。但目前來說,大部分的web 2.0站點是基於script語言編寫的,對於Java應用可擴展能力,很多人都抱着質疑的態度。在這篇文章中,Wang Yu基於他本身在實驗室項目的經驗來展示如何構建可擴展的java應用,同時,基於一些在可擴展性上做的比較失敗的項目給讀者帶來構建可擴展java應用的實踐、理論、算法、框架和經驗。

  我一直爲一家互聯網性質的實驗室工作,這個實驗室採用我們公司最新的大型服務器環境爲合作伙伴的產品和解決方案免費做性能測試,我工作的部分就是幫助他們在強大的CMT和SMP服務器上進行性能調優。

  這些年來,我已經爲不同的解決方案測試了數十種java應用。許多的產品都是爲了解決同樣的領域問題,因此這些產品的功能基本都是類似的,但在可擴展性上表現的卻非常不同,其中有些不能擴展到64 CPU的服務器上運行,但可以擴展到20臺服務器做集羣運行,有些則只能運行在不超過2 CPU的機器上。

  造成這些差別的原因在於設計產品時的架構願景,所有的具備良好擴展性的java應用從需求需求階段、系統設計階段以及實現階段都爲可擴展性做了考慮,所以,你所編寫的java應用的可擴展能力完全取決於你的願景。

  可擴展性作爲系統的屬性之一,是個很難定義的名詞,經常會與性能混淆。當然,可擴展性和性能是有關係的,它的目的是爲了達到高性能。但是衡量可擴展性和性能的方法是不一樣的,在這篇文章中,我們採用wikipedia中的定義:

  可擴展性是系統、網絡或進程的可選屬性之一,它表達的含義是可以以一種優雅的方式來處理不斷增長的工作,或者以一種很明白的方式進行擴充。例如:它可以用來表示系統具備隨着資源(典型的有硬件)的增加提升吞吐量的能力。

  垂直擴展的意思是給系統中的單節點增加資源,典型的是給機器增加CPU或內存,垂直擴展爲操作系統和應用模塊提供了更多可共用的資源,因此它使得虛擬化的技術(應該是指在一臺機器上運行多個虛擬機)能夠運行的更加有效。

  水平擴展的意思是指給系統增加更多的節點,例如爲一個分佈式的軟件系統增加新的機器,一個更清晰的例子是將一臺web服務器增加爲三臺。隨着計算機價格的不斷降低以及性能的不斷提升,以往需要依靠超級計算機來進行的高性能計算的應用(例如:地震分析、生物計算等)現在可以採用這種多個低成本的應用來完成。由上百臺普通機器構成的集羣可以達到傳統的基於RISC處理器的科學計算機所具備的計算能力。

  這篇文章的第一部分來討論下垂直擴展Java應用。

  如何讓Java EE應用垂直擴展

  很多的軟件設計人員和開發人員都認爲功能是產品中最重要的因素,而性能和可擴展性是附加的特性和功能完成後才做的工作。他們中大部分人認爲可以藉助昂貴的硬件來縮小性能問題。

  但有時候他們是錯的,上個月,我們實驗室中有一個緊急的項目,合作伙伴提供的產品在他們客戶提供的CPU的機器上測試未達到性能的要求,因此合作伙伴希望在更多CPU(8 CPU)的機器上測試他們的產品,但結果卻是在8 CPU的機器上性能反而比4 CPU的機器更差。

  爲什麼會這樣呢?首先,如果你的系統是多進程或多線程的,並且已經用盡了CPU的資源,那麼在這種情況下增加CPU通常能讓應用很好的得到擴展。

  基於java技術的應用可以很簡單的使用線程,Java語言不僅可以用來支持編寫多線程的應用,同時JVM本身在對java應用的執行管理和內存管理上採用的也是多線程的方式,因此通常來說Java應用在多CPU的機器上可以運行的更好,例如Bea weblogic、IBM Websphere、開源的Glassfish和Tomcat等應用服務器,運行在Java EE應用服務器中的應用可以立刻從CMT和SMP技術中獲取到好處。

  但在我的實驗室中,我發現很多的產品並不能充分的使用CPU,有些應用在8 CPU的服務器上只能使用到不到20%的CPU,像這類應用即使增加CPU也提升不了多少的。

  熱鎖(Hot Lock)是可擴展性的關鍵障礙

  在Java程序中,用來協調線程的最重要的工具就是 synchronized這個關鍵字了。由於java所採用的規則,包括緩存刷新和失效,Java語言中的synchronized塊通常都會其他平臺提供的類似的機制更加的昂貴。即使程序只是一個運行在單處理器上的單線程程序,一個synchronized的方法調用也會比非同步的方法調用慢。

  要檢查問題是否爲採用synchronized關鍵字造成的,只需要像JVM進程發送一個QUIT指令(譯者注:在linux上也可以用kill -3 PID的方式)來獲取線程堆棧信息。如果你看到類似下面線程堆棧的信息,那麼就意味着你的系統出現了熱鎖的問題:

..
"Thread-0"prio=10tid=0x08222eb0nid=0x9waitingformonitorentry[0xf927b000..0xf927bdb8]
attestthread.WaitThread.run(WaitThread.java:39)
-waitingtolock<0xef63bf08>(ajava.lang.Object)
-locked<0xef63beb8>(ajava.util.ArrayList)
atjava.lang.Thread.run(Thread.java:595)

  synchronized 關鍵字強制執行器串行的執行synchronized中的動作。如果很多線程競爭同樣的同步對象,那麼只有一個線程能夠執行同步塊,而其他的線程就只能進入blocked狀態了,如果此時沒有其他需要執行的線程,那麼處理器就進入空閒狀態了,在這種情況下,增加CPU也帶來不了多少性能提升。

  熱鎖可能會導致更多線程的切換和系統的調用。當多個線程競爭同一個monitor時,JVM必須維護一個競爭此monitor的線程隊列(同樣,這個隊列也必須同步),這也就意味着更多的時間需要花費在JVM或OS的代碼執行上,而更少的時間是用在你的程序上的。

  要避免熱鎖現象,以下的建議能帶來一些幫助:

  儘可能的縮短同步塊

  當你將線程中持有鎖的時間儘量縮短後,其他線程競爭鎖的時間也就變得更短。因此當你需要採用同步塊來操作共享的變量時,應該將線程安全的代碼放在同步塊的外面,來看以下代碼的例子:

  Code list 1:

publicbooleanupdateSchema(HashMapnodeTree){
synchronized(schema){
  StringnodeName=(String)nodeTree.get("nodeName");
  StringnodeAttributes=(List)nodeTree.get("attributes");
  if(nodeName==null)
    returnfalse;
  else
    returnschema.update(nodeName,nodeAttributes);
}
}

  上面的代碼片段是爲了當更新"schema"變量時保護這個共享的變量。但獲取attribute值部分的代碼是線程安全的。因此我們可以將這部分移至同步塊的外面,讓同步塊變得更短一些:

  Code list 2:

publicbooleanupdateSchema(HashMapnodeTree){
  StringnodeName=(String)nodeTree.get("nodeName");
  StringnodeAttributes=(List)nodeTree.get("attributes");
  synchronized(schema){
    if(nodeName==null)
      returnfalse;
    else
      returnschema.update(nodeName,nodeAttributes);
  }
}

  

  減小鎖的粒度

  當你使用"synchronized"時,有兩種粒度可選擇:"方法鎖"或"塊鎖"。如果你將"synchronized"放在方法上,那麼也就意味着鎖定了"this"對象。

  Code list 3:

publicclassSchemaManager{
  privateHashMapschema;
  privateHashMaptreeNodes;
  .
  publicbooleansynchronizedupdateSchema(HashMapnodeTree){
    StringnodeName=(String)nodeTree.get("nodeName");
    StringnodeAttributes=(List)nodeTree.get("attributes");
    if(nodeName==null)returnfalse;
    elsereturnschema.update(nodeName,nodeAttributes);
  }
  publicbooleansynchronizedupdateTreeNodes(){
       }
}

  

 

對比Code list 2中的代碼,這段代碼就顯得更糟糕些了,因爲當調用"updateSchema"方法時,它鎖定了整個

 

對象,爲了獲得更好的粒度控制,應該僅僅鎖定"schema"變量來替代鎖定整個對象,這樣其他不同的方法就可

 

以保持並行執行了。

 

避免在static方法上加鎖

 

最糟糕的狀況是在static方法上加"synchronized",這樣會造成鎖定這個class的所有實例對象。

 

--------------------------------

 

atsun.awt.font.NativeFontWrapper.initializeFont(NativeMethod)
-waitingtolock<0xeae43af0>(ajava.lang.Class)
atjava.awt.Font.initializeFont(Font.java:316)
atjava.awt.Font.readObject(Font.java:1185)
atsun.reflect.GeneratedMethodAccessor147.invoke(UnknownSource)
atsun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
atjava.lang.reflect.Method.invoke(Method.java:324)
atjava.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:838)
atjava.io.ObjectInputStream.readSerialData(ObjectInputStream.java:1736)
atjava.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1646)
atjava.io.ObjectInputStream.readObject0(ObjectInputStream.java:1274)
atjava.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:1835)
atjava.io.ObjectInputStream.readSerialData(ObjectInputStream.java:1759)
atjava.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1646)
atjava.io.ObjectInputStream.readObject0(ObjectInputStream.java:1274)
atjava.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:1835)
atjava.io.ObjectInputStream.defaultReadObject(ObjectInputStream.java:452)
atcom.fr.report.CellElement.readObject(UnknownSource)

 

  當使用Java 2D來爲報表生成字體對象時,開發人員放了一個native的static鎖在"initialize"方法上,不過這是sun JDK 1.4中才會出現的,在JDK 5.0中,這個static lock就消失了。

  在Java SE 5.0中使用lock free的數據結構

  在Java中,"synchronized"關鍵字是一個較簡單、並且相對來說比較好用的協作機制,不過同時對於管理一個簡單的操作(例如增加統計值或更新一個值)來說就顯得比較重量級了,就像以下的代碼:

  Code list 4:

publicclassOnlineNumber{
  privateinttotalNumber;
  publicsynchronizedintgetTotalNumber(){returntotalNumber;}
  publicsynchronizedintincrement(){return++totalNumber;}
  publicsynchronizedintdecrement(){return--totalNumber;}
}

  以上的代碼只是用來鎖定非常簡單的操作,"synchronized"塊也是非常的短。但是鎖是非常重量級(當鎖被其他線程持有時,線程會去頻繁嘗試獲取鎖)的,吞吐量會下降,並且同步鎖的競爭也是很昂貴的。

  幸運的是,在Java SE 5.0或以上版本,你可以在不使用native代碼的情況下使用硬件級同步語義的wait-free、lock-free的算法。幾乎所有現代的處理器都具有檢測和防止其他處理器併發修改變量的基礎設施。這些基礎設施稱爲比較並交換,或CAS。

  一個CAS操作包含三個參數 -- 一個內存地址,期待的舊的值以及新的值。 如果內存地址上的值和所期待的舊的值是同一個的話,處理器將此地址的值更新爲新的值;否則它就什麼都不做,同時它會返回CAS操作前內存地址上的值。一個使用CAS來實現同步的例子如下:

  Code list 5:

publicintincrement(){
  intoldValue=value.getValue();
  intnewValue=oldValue+1;
  while(value.compareAndSwap(oldValue,newValue)!=oldValue)
   oldValue=value.getValue();
  returnoldValue+1;
}

  首先,我們從地址上讀取一個值,然後執行幾步操作來產生新的值(例子中只是做加1的操作),最後使用CAS方式來將地址中的舊值改變爲新值。如果在時間片段內地址上的值未改變,那麼CAS操作將成功。如果另外的線程同時修改了地址上的值,那麼CAS操作將失敗,但會檢測到這個操作失敗,並在while循環中進行重試。CAS最好的原因在於它是硬件級別的實現並且非常輕量級,如果100個線程同時執行這個increment()方法,最糟糕的情況是在 increment方法執行完畢前每個線程最多嘗試99次。

  在Java SE 5.0和以上版本的java.util.concurrent.atomic包中提供了在單個變量上lock-free和線程安全操作支持的類。這些原子變量的類都提供了比較和交換的原語,它基於各種平臺上可用的最後的native的方式實現,這個包內提供了九種原子變量,包括:AtomicInteger;AtomicLong;AtomicReference;AtomicBoolean;array forms of atomic integer、long、reference;和atomic marked reference和stamped reference類。

  使用atomic包非常容易,重寫上面code list 5的代碼片段:

  Code list 6:

importjava.util.concurrent.atomic.*;
.
privateAtomicIntegervalue=newAtomicInteger(0);
publicintincrement(){
  returnvalue.getAndIncrement();
}

  

 

幾乎java.util.concurrent包中所有的類都直接或間接的採用了原子變量來替代synchronized。像

 

ConcurrentLinkedQueue採用了原子變量來直接實現wait-free算法,而像ConcurrentHashMap則採用

 

ReentrantLock來實現必要的鎖,而ReentrantLock則是採用原子變量來維護所有等待鎖的線程隊列。

  在我們實驗室中一個最成功的關於lock free算法的案例發生在一個金融系統中,當將"Vector"數據結構替換爲"ConcurrentHashMap"後,在我們的CMT機器(8核)性能提升了超過3倍。

  競爭條件也會導致可擴展性出現問題

  太多的"synchronized"關鍵字會導致可擴展性出現問題。但在某些場合,缺少"synchronized"也會導致系統無法垂直擴展。缺少"synchronized"會產生競爭場景,在這種場景下允許兩個線程同時修改共享的資源,這有可能會造成破壞共享數據,爲什麼我說它會導致可擴展性出現問題呢?

  來看一個實際的例子。這是一個製作業的ERP系統,當在我們最新的一臺CMT服務器(2CPU、16核、128芯)上進行性能測試時,我們發現CPU的使用率超過90%,這非常讓人驚訝,因爲很少有應用能夠在這款機器上擴展的這麼好。但我們僅僅興奮了5分鐘,之後我們發現平均響應時間非常的慢,同時吞吐量也降到不可思議的低。那麼這些CPU都在幹嘛呢?它們不是在忙嗎,那麼它們到底在忙些什麼呢?通過OS的跟蹤工具,我們發現幾乎所有的CPU都在幹同一件事-- "HashMap.get()",看起來所有的CPU都進入了死循環,之後我們在不同數量的CPU的服務器上再測試了這個應用,結果表明,服務器擁有越多CPU,那麼產生死循環的概率就會越高。

  產生這個死循環的根源在於對一個未保護的共享變量 -- 一個"HashMap"數據結構的操作。當在所有操作的方法上加了"synchronized"後,一切恢復了正常。檢查"HashMap"(Java SE 5.0)的源碼,我們發現有潛在的破壞其內部結構最終造成死循環的可能。在下面的代碼中,如果我們使得HashMap中的entries進入循環,那麼"e.next()"永遠都不會爲null。

  Code list 7:

publicVget(Objectkey){
  if(key==null)returngetForNullKey();
  inthash=hash(key.hashCode());
  for(Entry<K,V>e=table[indexFor(hash,table.length)];
    e!=null;
    e=e.next){
    Objectk;
    if(e.hash==hash&&((k=e.key)==key||key.equals(k)))
      returne.value;
  }
  returnnull;
}

  不僅get()方法會這樣,put()以及其他對外暴露的方法都會有這個風險,這算jvm的bug嗎?應該說不是的,這個現象很早以前就報告出來了(詳細見:http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6423457)。Sun的工程師並不認爲這是bug,而是建議在這樣的場景下應採用"ConcurrentHashMap",在構建可擴展的系統時應將這點納入規範中。

  非阻塞 IO vs. 阻塞IO

  Java 1.4中引入的java.nio包,允許開發人員在進行數據處理時獲取更好的性能並提供更好的擴展性。NIO提供的非阻塞IO操作允許java應用像其他底層語言(例如c)一樣操作IO。目前已經有很多NIO的框架(例如Apache的Mina、Sun的Grizzly)了被廣泛的使用在很多的項目和產品中。

  在最近的5個月內,我們實驗室有兩個Java EE項目測試對比了基於傳統的阻塞I/O構建的服務器和非阻塞I/O構建的服務器上的性能。他們選擇了Tomcat 5作爲基於阻塞I/O的服務器,Glassfish作爲基於非阻塞I/O的服務器。

  首先,他們測試了一些簡單的JSP頁面和servlets,得到如下結果:(在一臺4 CPU的服務器上)

  

  Concurrent Users

  Average Response Time (ms)

Tomcat Glassfish
5 30 138
15 35 142
30 37 142
50 41 151
100 65 155

  從測試結果來看,Glassfish的性能遠低於Tomcat。客戶對非阻塞I/O能夠帶來的提升表示懷疑,但爲什麼那麼多的文章以及技術報告都告訴大家NIO具備更好的性能和可擴展性呢?

  當在更多的場景進行測試後,隨着NIO的能力逐步的展現出來,他們改變了觀點,他們做了以下的測試:

  1、比簡單的JSP、servlet更爲複雜的場景,包括EJB、數據庫、文件IO、JMS和事務;

  2、模擬更多的併發用戶,從1000到10000;

  3、在不同的硬件環境上進行測試,從2 CPU、4 CPU到16 CPU。

  以下的圖爲在4 CPU服務器上的測試結果:

  

  Figure 1: Throughput in a 4CPU server

  傳統的阻塞I/O爲每個請求分配一個工作線程,這個工作線程負責請求的整個過程的處理,包括從網絡讀取請求數據、解析參數、計算或調用其他的業務邏輯、編碼結果並將其返回給請求者,然後這個線程將返回到線程池中供其他線程複用。Tomcat 5採用的這種方式在應對完美的網絡環境、簡單的邏輯以及小量的併發用戶時是非常高效的。

  但如果請求包括了複雜的邏輯、或需要和外部的系統(例如文件系統、數據庫或消息服務器)進行交互時,工作線程在其處理的大部分時間都會處於等待同步的調用或網絡傳輸返回的狀態中,這個阻塞的線程會被請求持有直到請求處理完畢,但操作系統需要暫停線程來保證CPU能夠處理其他的請求,如果客戶端和服務器端的網絡狀況不太好的話,網絡的延時會導致線程被阻塞更長時間,在更糟的狀況下,當需要keep-alive的話,當前的工作線程會在請求處理完畢後阻塞很長一段時間,在這樣的情況下,爲了更好的使用CPU,就必須增加更多的工作線程了。

  Tomcat採用了一個線程池,每個請求都會被線程池中一個空閒的線程進行處理。"maxThreads"表示Tomcat 能創建的處理請求的最大線程數。如果我們把"maxThreads"設置的太小的話,就不能充分的使用CPU了,更爲重要的是,隨着併發用戶的增長,會有很多請求被服務器拋棄和拒絕。在此次測試中,我們將"maxThreads"設置爲了1000(這對於Tomcat來說有些太大了),在這樣的設置下,當併發用戶增長到較高數量時,Tomcat會創建很多的線程。大量的Java線程會導致JVM和OS忙於執行和維護這些線程,而不是執行業務邏輯處理,同時,太多的線程也會消耗更多的JVM heap內存(每個線程堆棧需要佔用一些內存),並且會導致更爲頻繁的gc。

  Glassfish不需要這麼多的線程,在非阻塞IO中,一個工作線程並不會綁定到一個特定的請求上,如果請求被某些原因所阻塞,那麼這個線程將被其他的請求複用。在這樣的方式下,Glassfish可以用幾十個工作線程來處理幾千的併發用戶。通過限制線程資源,非阻塞IO擁有了更好的可擴展性,這也是Tomcat 6採用非阻塞IO的原因了。

  

  Figure 2: scalability test result

  單線程任務問題

  幾個月前我們實驗室測試了一個基於Java EE的ERP系統,它其中的一個測試場景是爲了產生非常複雜的分析報告,我們在不同的服務器上測試了這個應用場景,發現竟然是在最便宜的AMD PC服務器上擁有最好的性能。這臺AMD的服務器只有兩個2.8HZ的CPU以及4G的內存,但它的性能竟然超過了昂貴的擁有8 CPU和32G內存的SPARC服務器。

  原因就在於這個場景是個單線程的任務,它同時只能被一個用戶運行(併發的多用戶執行在這個案例中毫無意義),因此當運行時它只使用了一個CPU,這樣的任務是沒法擴展到多個處理器的,在大多數時候,這種場景下的性能僅取決於CPU的運行速度。

  並行是解決這個問題的方案。爲了讓一個單線程的任務並行執行,你需要按順序找出這個操作的過程中從某種程度上來講不依賴的操作,然後採用多線程從而實現並行。在上面的案例中,客戶重新定義了"分析報告產生"的任務,改爲先生成月度報告,之後基於產生的這些12個月的月度報告來生成分析報告,由於最終用戶並不需要“月度報告”,因此這些“月度報告”只是臨時產生的結果,但"月度報告"是可以並行生成的,然後用於快速的產生最後的分析報告,在這樣的方式下,這個應用場景可以很好的擴展到4 CPU的SPARC服務器上運行,並且在性能上比在AMD Server高80%多。

  重新調整架構和重寫代碼的解決方案是一個耗時並且容易出現錯誤的工作。在我們實驗室中的一個項目中採用了JOMP來爲其單線程的任務獲得並行性。JOMP是一個基於線程的SMP並行編程的Java API。就像OpenMP,JOMP也是根據編譯指示來插入並行運行的代碼片段到常規的程序中。在Java程序中,JOMP 通過//omp這樣的指示方式來表示需要並行運行的部分。JOMP程序通過運行一個預編譯器來處理這些//omp的指示並生成最終的java代碼,這些 java代碼再被正常的編譯和執行。JOMP支持OpenMP的大部分特性,包括共享的並行循環和並行片段,共享變量,thread local變量以及reduction變量。以下的代碼爲JOMP程序的示例:

  Code list 8:

LinkedListc=newLinkedList();
c.add("this");
c.add("is");
c.add("a");
c.add("demo");
//#ompparalleliterator
for(Strings:c)
  System.out.println("s");

  就像大部分的並行編譯器,JOMP也是關注於loop-level和集合的並行運算,研究如何同時執行不同的迭代。爲了並行化,兩個迭代之間不能產生任何的數據依賴,這也就是說,不能依賴於其他任何一個執行後產生的計算結果。要編寫一個JOMP程序並不是容易的事。首先,你必須熟練使用OpenMP的指示,同時還得熟悉JVM對於這些指示的內存模型映射,最後你需要知道在你的業務邏輯代碼的正確的地方放置正確的指示。

  另外一個選擇是採用Parallel Java。Parallel Java,就像JOMP一樣,也支持OpenMP的大部分特性;但又不同於JOMP,PJ的並行結構部分是通過在代碼中調用PJ的類來實現,而不是通過插入預編譯的指示,因此,"Parallel Java"不需要另外的預編譯過程。Parallel Java不僅對於在多CPU上並行有效,對於多節點的擴展能力上也同樣有效。以下的代碼是"Parallel Java"程序的示例:

  Code list 9:

staticdouble[][]d;
newParallelTeam().execute(newParallelRegion()
  {
  publicvoidrun()throwsException
    {
    for(intii=0;ii<n;++ii)
      {
      finalinti=ii;
      execute(0,n-1,newIntegerForLoop()
        {
          publicvoidrun(intfirst,intlast)
            {
            for(intr=first;r<=last;++r)
             {
             for(intc=0;c<n;++c)
                {
                d[r][c]=Math.min(d[r][c],
                d[r][i]+d[i][c]);
                }
              }
            }
          });
        }
      }
    });

  擴展使用更多的內存

  內存是應用的重要資源。足夠的內存對於任何應用而言都是關鍵的,尤其是數據庫系統和其他I/O操作頻繁的系統。更多的內存意味着更大的共享內存空間以及更大的數據緩衝,這也就使得應用能夠更多的從內存中讀取數據而不是緩慢的磁盤中讀取。

  Java gc將程序員從繁瑣的內存分配和回收中解脫了出來,從而使得程序員能夠更加高效的編寫代碼。但gc不好的地方在於當gc運行時,幾乎所有工作的線程都會被掛起。另外,在gc環境下,程序員缺少調度CPU來回收那些不再使用的對象的控制能力。對於那些幾乎實時的系統而言,例如電信系統和股票交易系統,這種延遲和缺少控制的現象是很大的風險。

  回到Java應用在給予更多的內存時是否可以擴展的問題上,答案是有些時候是的。太小的內存會導致gc頻繁的執行,足夠的內存則保證JVM花費更多的時間來執行業務邏輯,而不是進行gc。

  但它並不一定是這樣的,在我們實驗室中出現的真實例子是一個構建在64位JVM上的電信系統。使用64位JVM,應用可以突破32位JVM中4GB內存的限制,測試時使用的是一臺4 CPU/16G內存的服務器,其中12GB的內存分配給了java應用使用,爲了提高性能,他們在初始化時就緩存了超過3,000,000個的對象到內存中,以免在運行時創建如此多的對象。這個產品在第一個小時的測試中運行的非常快,但突然,系統差不多停止運行了30多分鐘,經過檢測,發現是因爲gc導致了系統停止了半個小時。

  gc是從那些不再被引用的對象回收內存的過程。不被引用的對象是指應用中不再使用的對象,因爲所有對於這些對象的引用都已經不在應用的範圍中了。如果一堆巨大的活動的對象存在在內存中(就像3,000,000個緩存的對象),gc需要花費很長的時間來檢查這些對象,這就是爲什麼系統停止瞭如此長乃至不可接受的時間。

  在我們實驗室中測試過的以內存爲中心的Java應用中,我們發現具備有如下特徵:

  1、每個請求的處理過程需要大量和複雜的對象;

  2、在每個會話的HttpSession對象中保存了太多的對象;

  3、HttpSession的timeout時間設置的太長,並且HttpSession沒有顯示的invalidated;

  4、線程池、EJB池或其他對象池設置的太大;

  5、對象的緩存設置的太大。

  這樣的應用是不好做擴展的,當併發的用戶數增長時,這些應用所使用的內存也會大幅度的增長。如果大量的活動對象無法被及時的回收,JVM將會在gc上消耗很長的時間,另外,如果給予了太大的內存(在64位JVM上),在運行了相對較長的時間後,jvm會花費相當長的一段時間在 gc上,因此結論是如果給jvm分配了太多的內存的話,java應用將不可擴展。在大部分場合下,給jvm分配3G內存(通過"-Xmx"屬性)是足夠 (在windows和linux中,32位的系統最多隻能分配2G的內存)的。如果你擁有更多的內存,請將這些內存分配給其他的應用,或者就將它留給OS 使用,許多OS都會使用空閒的內存來作爲數據的緩衝和緩存來提升IO性能。實時JVM(JSR001)可以讓開發人員來控制內存的回收,應用基於此特性可以告訴JVM:“這個巨大的內存空間是我的緩存,我將自己來管理它,請不要自動對它進行回收”,這個功能特性使得Java應用也能夠擴展來支持大量的內存資源,希望JVM的提供者們能將這個特性在不久的將來帶入到免費的JVM版本中。

  爲了擴展這些以內存爲中心的java應用,你需要多個jvm實例或者多臺機器節點。

  其他垂直擴展的問題

  有些Java EE應用的擴展性問題並不在於其本身,有些時候外部系統的限制會成爲系統擴展能力的瓶頸,這些瓶頸可能包括:

  數據庫系統:這在企業應用和web 2.0應用中是最常見的瓶頸,因爲數據庫通常是jvm線程中共享的資源。因此數據庫執行的效率、數據庫事務隔離的級別將會很明顯的影響系統的擴展能力。我 們看到很多的項目將大部分的業務邏輯以存儲過程的方式放在數據庫中,而web層則非常的輕量,只是用來執行下數據的過濾等,這樣的架構在隨着請求數的增長 後會出現很多的擴展性問題。

  磁盤IO和網絡IO。

  操作系統:有些時候系統擴展能力的瓶頸可能會出現在操作系統的限制上,例如,在同一個目錄下放了太多的文件,導致文件系統在創建和查找文件時變得非常的慢;

  同步logging:這是一個可擴展性的常見問題。在有些案例中,可以通過採用Apache log4j來解決,或者採用jms消息來將同步的logging轉爲異步執行。

  這些不僅僅是Java EE應用的問題,對於所有平臺的所有系統而言同樣如此。爲了解決這些問題,需要從系統的各個層面來從數據庫管理員、系統工程師和網絡分析人員處得到幫助。

 

當併發用戶數明顯的開始增長,你可能會不滿意一臺機器所能提供的性能,或者由於單個JVM實例gc的限制,你沒法擴展你的java應用,在這樣的情況下你可以做的另外的選擇是在多個JVM實例或多臺服務器上運行你的系統,我們把這種方法稱爲水平擴展。

請注意,我們相信能夠在一臺機器的多個JVM上運行系統的擴展方式是水平擴展方式,而非垂直擴展方式。JVM實例之間的IPC機制是有限的,兩個JVM實例之間無法通過管道、共享內存、信號量或指令來進行通訊,不同的JVM進程之間最有效的通訊方式是socket。簡而言之,如果Java EE應用如果擴展到多個JVM實例中運行,那麼大多數情況下它也可以擴展到多臺服務器上運行。


隨着計算機越來越便宜,性能越來越高,通過將低成本的機器羣組裝爲集羣可以獲得超過那些昂貴的超級計算機所具備的計算能力。不過,大量的計算機也意味着增加了管理的複雜性以及更爲複雜的編程模型,就像服務器節點之間的吞吐量和延時等問題。

Java EE集羣是一種成熟的技術,我在TSS上寫了一篇名爲“Uncover the Hood of J2EE Clustering”的文章來描述它的內部機制。


從失敗的項目中吸取的教訓


採用無共享的集羣架構(SNA)

 

Figure 3: share nothing cluster

最具備擴展性的架構當屬無共享的集羣架構。在這樣的集羣中,每個節點具備完全相同的功能,並且不需要知道其他節點存在與否。負載均衡器(Load Balancer)來完成如何將請求分發給這些後臺的服務器實例。由於負載均衡器只是做一些簡單的工作,例如分派請求、健康檢查和保持session,因此負載均衡器很少會成爲瓶頸。如果後端的數據庫系統或其他的信息系統足夠的強大,那麼通過增加更多的節點,集羣的計算能力可以得到線性的增長。


幾乎所有的Java EE提供商在他們的集羣產品中都實現了HttpSession的failover功能,這樣即使在某些服務器節點不可用的情況下也仍然能夠保證客戶端的請求中的session信息不丟失,但這點其實是打破了無共享原則的。爲了實現failover,同樣的session數據將會被兩個或多個節點共享,在我之前的文章中,我曾經推薦除非是萬不得已,不要使用session failover。就像我文章中提到的,當失敗發生時,session failover功能並不能完全避免錯誤,而且同時還會對性能和可擴展性帶來損失。


使用可擴展的session複製機制


爲了讓用戶獲得更友好的體驗,有些時候可能必須使用session failover功能,這裏最重要的在於選擇可擴展的複製型產品或機制。不同的廠商會提供不同的複製方案 - 有些採用數據庫持久,有些採用中央集中的狀態服務器,而有些則採用節點間內存複製的方式。最具可擴展性的是成對節點的複製(paired node replication),這也是現在大部分廠商採用的方案,包括BEA Weblogic、JBoss和IBM Websphere,Sun在Glassfish V2以及以上版本也實現了成對節點的複製。最不可取的方案是數據庫持久session的方式。在我們實驗室中曾經測試過一個採用數據庫持久來實現 session複製的項目,測試結果表明如果session對象頻繁更新的話,節點在三到四個時就會導致數據庫崩潰。


採用collocated部署方式來取代分佈式


Java EE技術,尤其是EJB,天生就是用來做分佈式計算的。解耦業務功能和重用遠程的組件使得多層的應用模型得以流行。但對於可擴展性而言,減少分佈式的層次可能是一個好的選擇。


在我們實驗室曾經以一個政府的項目測試過這兩種方式在同樣的服務器數量上的部署 - 一種是分佈式的,一種是collocated方式的,如下圖所示:

Figure 4: distributed structure 


Figure 5: collocated structure


結果表明collocated式的部署方式比分佈式的方式更具備可擴展性。假設你應用中的一個方法調用了一堆的EJB,如果每個EJB的調用都需要load balance,那麼有可能會因爲需要分散到不同的服務器上進行調用導致你的應用崩潰,這樣的結果就是,你可能做了很多次無謂的跨服務器的調用。來看更糟糕的情況,如果你的方法是需要事務的,那麼這個事務就必須跨越多個服務器,而這對於性能是會產生很大的損害的。


共享資源和服務


對於用於支撐併發請求的Java EE集羣系統而言,其擴展後的性能取決於對於那些不支持線性擴展的共享資源的操作。數據庫服務器、JNDI樹、LDAP服務器以及外部的文件系統都有可能被集羣中的節點共享。


儘管Java EE規範中並不推薦,但爲了實現各種目標,通常都會採用外部的I/O操作。例如,在我們實驗室測試的應用中有用文件系統來保存用戶上傳的文件的應用,或動態的創建xml配置文件的應用。在集羣內,應用服務器節點必須想辦法來複制這些文件到其他的節點,但這樣做是不利於擴展的。隨着越來越多節點的加入,節點間的文件複製會佔用所有的網絡帶寬和消耗大量的CPU資源。在集羣中要達到這樣的目標,可以採用數據庫來替代外部文件,或採用SAN作爲文件的集中存儲,另外一個可選的方案是採用高效的分佈式文件系統,例如Hadoop DFS(http://wiki.apache.org/hadoop/)。


在集羣環境中共享服務很常見,這些服務不會部署到集羣的每個節點,而是部署在專門的服務器節點上,例如分佈式的日誌服務或時間服務。分佈式鎖管理器 (DLM)來管理集羣中的應用對這些共享服務的同步訪問,即使在網絡延時和系統處理失敗的情況下,鎖管理器也必須正常操作。舉例來說,在我們的實驗室中測試的一個ERP系統就碰到了這樣的問題,他們寫了自己的DLM系統,最終發現當集羣中持有鎖的節點失敗時,他們的lock system將會永遠的持有鎖。


分佈式緩存


我所碰到過的幾乎所有的Java EE項目都採用了對象緩存來提升性能,同樣所有流行的應用服務器也都提供了不同級別的緩存來加速應用。但有些緩存是爲單一運行的環境而設計的,並且只能在單JVM實例中正常的運行。由於有些對象的創建需要耗費大量的資源,我們需要緩存,因此我們維護對象池來緩存對象的實例。如果獲取維護緩存較之創建對象而言更划算,那麼我們就提升了系統的性能。在集羣環境中,每個jvm實例維護着自己的緩存,爲了保持集羣中所有服務器狀態的一致,這些緩存對象需要進行同步。有些時候這樣的同步機制有可能會比不採用緩存的性能還差,對於整個集羣的擴展能力而言,一個可擴展的分佈式緩存系統是非常重要的。


如今很多分佈式緩存相關的開源java產品已經非常流行,在我們實驗室中有如下的一些測試:

1個基於JBoss Cache的項目的測試;
3個基於Terracotta的項目的測試;
9個基於memcached的項目的測試;
測試結果表明Terracotta可以很好的擴展到10個節點,並且在不超過5個節點時擁有很高的性能,但memcached則在超過20個服務器節點時會擴展的非常好。


Memcached
Memcached是一個高性能的分佈式對象緩存系統,經常被用於降低數據庫load,同時提升動態web應用的速度。Memcached的奇妙之處在於它的兩階段hash的方法,它通過一個巨大的hash表來查找key = value對,給它一個key,就可以set或get數據了。當進行一次memcached查詢時,首先客戶端將會根據整個服務器的列表來對key進行 hash,在找到一臺服務器後,客戶端就發送請求,服務器端在接收到請求後通過對key再做一次內部的hash,從而查找到實際的數據項。當處理巨大的系統時,最大的好處就是memcached所具備的良好的水平擴展能力。由於客戶端做了一層hashing,這使得增加N多的節點到集羣變得非常的容易,並不會因爲節點的互連造成負載的增高,也不會因爲多播協議而造成網絡的洪水效應。


實際上Memcached並不是一款java產品,但它提供了Java client API,這也就意味着如果你需要在Java EE應用中使用memcached的話,並不需要做多大的改動就可以從cache中通過get獲取值,或通過put將值放入cache中。使用 memcached是非常簡單的,不過同時也得注意一些事情避免對擴展性和性能造成損失:


不要緩存寫頻繁的對象。Memcached是用來減少對數據庫的讀操作的,而非寫操作,在使用Memcached前,應先關注對象的讀/寫比率,如果這個比率比較高,那麼採用緩存纔有意義。
儘量避免讓運行的memcached的節點互相調用,對於memcached而言這是災難性的。
儘量避免行方式的緩存,在這樣的情況下可採用複雜的對象來進行緩存,這對於memcached來說會更爲有效。
選 擇合適的hashing算法。在默認的算法下,增加或減少服務器會導致所有的cache全部失效。由於服務器的列表hash值被改變,可能會造成大部分的 key都要hash到和之前不同的服務器上去,這種情況下,可以考慮採用持續的hashing算法(http://weblogs.java.net /blog/tomwhite/archive/2007/11/consistent_hash.html) 來增加和減少服務器,這樣做可以保證你大部分緩存的對象仍然是有效的。

Terracotta
Terracotta(http://www.terracottatech.com/)是一個企業級的、開源的、JVM級別的集羣解決方案。JVM級的集羣方案意味着可以支撐將企業級的Java應用部署部署到多JVM上,而且就像是運行在同一個JVM中。 Terracotta擴展了JVM的內存模型,各虛擬機上的線程通過集羣來與其他虛擬機上的線程進行交互(Terracotta extends the Java Memory Model of a single JVM to include a cluster of virtual machines such that threads on one virtual machine can interact with threads on another virtual machine as if they were all on the same virtual machine with an unlimited amount of heap.)。

 


Figure 6: Terracotta JVM clustering

 

採用Terracotta來實現集羣應用的編程方式和編寫單機應用基本沒有什麼差別,Terrocotta並沒有特別的提供開發者的API,Terracotta採用字節碼織入的方式(很多AOP軟件開發框架中採用的技術,例如AspectJ和AspectWerkz)來將集羣方式的代碼插入到已有的java語言中。


我猜想Terrocotta是通過某種互連的方式或多播協議的方式來實現服務器和客戶端JVM實例的通訊的,可能是這個原因導致了在我們實驗室測試時的效果:當超過20個節點時Terracotta擴展的並不是很好。(注:這個測試結果僅爲在我們實驗室的測試結果,你的結果可能會不同。)


並行處理

我之前說過,單線程的任務會成爲系統可擴展性的瓶頸。但有些單線程的工作(例如處理或生成巨大的數據集)不僅需要多線程或多進程的運行,還會有擴展到多節點運行的需求。例如,在我們實驗室測試的一個Java EE項目有一個場景是這樣的:根據他們站點的日誌文件分析URL的訪問規則,每週產生的這些日誌文件通常會超過120GB,當採用單線程的Java應用去分析時需要耗費四個小時,客戶改爲採用Hadoop Map-Reduce使其能夠水平擴展從而解決了這個問題,如今這個分析URL訪問規則的程序不僅運行在多進程模式下,同時還並行的在超過10個節點上運行,而完成所有的工作也只需要7分鐘了。


有很多的框架和工具可以幫助Java EE開發人員來讓應用支持水平擴展。除了Hadoop,很多MPI的Java實現也可以用來將單線程的任務水平的擴展到多個節點上並行運行。


MapReduce
MapReduce由Google的Jeffrey Dean和Sanjay Ghemawat提出,是一種用於在大型集羣環境下處理巨量數據的分佈式編程模型。MapReduce由兩個步驟來實現 - Map:對集合中所有的對象進行操作並基於處理返回一系列的結果,Reduce:通過多線程、進程或獨立系統並行的從兩個或多個Map中整理和獲取結果。Map()和Reduce()都是可以並行運行的,不過通常來說沒必要在同樣的系統同樣的時間這麼來做。


Hadoop是一個開源的、點對點的、純Java實現的MapReduce。它是一個用於將分佈式應用部署到大型廉價集羣上運行的Lucene-derived框架,得到了全世界範圍開源人士的支持以及廣泛的應用,Yahoo的Search Webmap、Amazon EC2/S3服務以及Sun的網格引擎都可運行在Hadoop上。


簡單來說,通過使用“Hadoop Map-Reduce”,"URL訪問規則分析"程序可以首先將日誌文件分解爲多個128M的小文件,然後由Hadoop將這些小文件分配到不同的Map()上去執行。Map()會分析分配給它的小文件併產生臨時的結果,Map()產生的所有的臨時結果會被排序並分配給不同的Reduce(),Reduce()合併所有的臨時結果產生最終的結果,這些Map和Reduce操作都可以由Hadoop框架控制來並行的運行在集羣中所有的節點上。


MapReduce對於很多應用而言都是非常有用的,包括分佈式檢索、分佈式排序、web link-graph reversal、term-vector per host、web訪問日誌分析、索引重建、文檔集羣、機器智能學習、statistical machine translation和其他領域。

MPI


MPI是一種語言無關、用於實現並行運行計算機間交互的通訊協議,目前已經有很多Java版本的MPI標準的實現,mpiJava和MPJ是其中的典型。mpiJava 基於JNI綁定native的MPI庫來實現,MPJ是100%純java的MPI標準的實現。mpiJava和MPJ和MPI Fortran和C版本提供的API都基本一致,例如它們都對外提供了具備同樣方法名和參數的Comm class來實現MPI的信息傳遞。


CCJ是一個類似MPI通訊操作的java庫。CCJ提供了barrier、broadcast、scatter、 gather、all-gather、reduce和all-reduce操作的支持(但不提供點對點的操作,例如send、receive和send- receive)。在底層的通訊協議方面,CCJ並沒有自己實現,而是採用了Java RMI,這也就使得CCJ可以用來傳遞複雜的序列化對象,而不僅僅是MPI中的原始數據類型。進一步看,CCJ還可以從一組並行的processes中獲取到複雜的集合對象,例如實現了CCJ的DividableDataObject接口的集合。


採用不同的方法來獲取高擴展能力


有很多的書會教我們如何以OO的方式來設計靈活架構的系統,如何來使服務透明的被客戶端使用以便維護,如何採用正常的模式來設計數據庫schema以便集成。但有些時候爲了獲取高擴展性,需要採用一些不同的方法。


Google設計了自己的高可擴展的分佈式文件系統(GFS),它並不是基於POSIX API來實現的,不過GFS對於用戶來說並不完全透明。爲了使用GFS,你必須採用GFS的API包。Google也設計了自己的高可擴展的分佈式數據庫系統(Bigtable),但它並不遵循ANSI SQL標準,而且其中的概念和結構和傳統的關係數據庫幾乎完全不同,但最重要的是GFS和Bigtable能夠滿足Google的存儲要求、良好的擴展性要求,並且已經被Google的廣泛的作爲其存儲平臺而使用。


傳統方式下,我們通過使用更大型的、更快和更貴的機器或企業級的集羣數據庫(例如RAC)來將數據庫擴展到多節點運行,但我有一個我們實驗室中測試的social networking的網站採用了不同的方式,這個應用允許用戶在網站上創建profiles、blogs,和朋友共享照片和音樂,此應用基於Java EE編寫,運行在Tomcat和Mysql上,但不同於我們實驗室中測試的其他應用,它只是希望在20多臺便宜的PC Server上進行測試,其數據模型結構如下:

Figure 7: Users data partitions

 

這裏比較特殊的地方子礙於不同的用戶數據(例如profile、blog)可能會存儲在不同的數據庫實例上,例如,用戶 00001存儲在服務器A上,而用戶20001存儲在服務器C上,分庫的規則以一張元信息的表的方式存儲在專門的數據庫上。當部署在Tomcat的 Java EE應用希望獲取或更新用戶信息時,首先它會從這張元信息的表中獲取到需要去哪臺服務器上獲取這個用戶,然後再連到實際的服務器上去執行查詢或更新操作。

用戶數據分區和這種兩步時的動作方式可以帶來如下的一些好處:

擴展了寫的帶寬:對於這類應用而言,blogging、ranking和BBS將會使得寫帶寬成爲網站的主要瓶頸。分 布式的緩存對於數據庫的寫操作只能帶來很小的提升。採用數據分區的方式,可以並行的進行寫,同樣也就意味着提升了寫的吞吐量。要支持更多的註冊用戶,只需 要通過增加更多的數據庫節點,然後修改元信息表來匹配到新的服務器上。
高可用性:如果一臺數據庫服務器down了,那麼只會有部分用戶被影響,而其他大部分的用戶可以仍然正常使用;
同時也會帶來一些缺點:

由於數據庫節點可以動態的增加,這對於在Tomcat中的Java EE應用而言要使用數據庫連接池就比較難了;
由於操作用戶的數據是兩步式的,這也就意味着很難使用ORMapping的工具去實現;
當要執行一個複雜的搜索或合併數據時,需要從多臺數據庫服務器上獲取很多不同的數據。

這個系統的架構師這麼說:“我們已經知道這些缺點,並且準備好了應對它,我們甚至準備好了應對當元信息表的服務器成爲瓶頸的狀況,如果出現那樣的狀況我們將會把元信息表再次劃分,並創建出一個更高級別的元信息表來指向衆多的二級元信息表服務器實例。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章