Java高吞吐量系統設計優化建議
原文出處: IBM - 周明耀
高吞吐量系統
舉一個例子,我們做項目需要安排計劃,每一個模塊可以由多人同時并行做多項任務,也可以一個人或者多個人串行工作,但始終會有一條關鍵路徑,這條路徑就是項目的工期。系統一次調用的響應時間跟項目計劃一樣,也有一條關鍵路徑,這個關鍵路徑是就是系統影響時間。關鍵路徑由 CPU 運算、IO、外部系統響應等等組成。
對于一個系統的用戶來說,從用戶點擊一個按鈕、鏈接或發出一條指令開始,到系統把結果以用戶希望的形式展現出來為終止,整個過程所消耗的時間是用戶對這個軟件性能的直觀印象,也就是我們所說的響應時間。當響應時間較短時,用戶體驗是很好的,當然用戶體驗的響應時間包括個人主觀因素和客觀響應時間。在設計軟件時,我們就需要考慮到如何更好地結合這兩部分達到用戶最佳的體驗。如:用戶在大數據量查詢時,我們可以將先提取出來的數據展示給用戶,在用戶看的過程中繼續進行數據檢索,這時用戶并不知道我們后臺在做什么,用戶關注的是用戶操作的響應時間。
我們經常說的一個系統吞吐量,通常由 QPS(TPS)、并發數兩個因素決定,每套系統這兩個值都有一個相對極限值,在應用場景訪問壓力下,只要某一項達到系統最高值,系統的吞吐量就上不去了,如果壓力繼續增大,系統的吞吐量反而會下降,原因是系統超負荷工作,上下文切換、內存等等其它消耗導致系統性能下降,決定系統響應時間要素。
緩沖 (Buffer)
緩沖區是一塊特定的內存區域,開辟緩沖區的目的是通過緩解應用程序上下層之間的性能差異,提高系統的性能。在日常生活中,緩沖的一個典型應用是漏斗。緩沖可以協調上層組件和下層組件的性能差,當上層組件性能優于下層組件時,可以有效減少上層組件對下層組件的等待時間。基于這樣的結構,上層應用組件不需要等待下層組件真實地接受全部數據,即可返回操作,加快了上層組件的處理速度,從而提升系統整體性能。
使用 BufferedWriter 進行緩沖
BufferedWriter 就是一個緩沖區用法,一般來說,緩沖區不宜過小,過小的緩沖區無法起到真正的緩沖作用,緩沖區也不宜過大,過大的緩沖區會浪費系統內存,增加 GC 負擔。盡量在 I/O 組件內加入緩沖區,可以提高性能。一個緩沖區例子代碼如清單 1 所示。
清單 1. 加上緩沖區之前示例代碼
import java.awt.Color; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Image;import javax.swing.JApplet;
public class NoBufferMovingCircle extends JApplet implements Runnable{ Image screenImage = null; Thread thread; int x = 5; int move = 1;
public void init(){ screenImage = createImage(230,160); }
public void start(){ if(thread == null){ thread = new Thread(this); thread.start(); } }
@Override public void run() { // TODO Auto-generated method stub try{ System.out.println(x); while(true){ x+=move; System.out.println(x); if((x>105)||(x<5)){ move*=-1; } repaint(); Thread.sleep(10); } }catch(Exception e){
} }
public void drawCircle(Graphics gc){ Graphics2D g = (Graphics2D) gc; g.setColor(Color.GREEN); g.fillRect(0, 0, 200, 100); g.setColor(Color.red); g.fillOval(x, 5, 90, 90); }
public void paint(Graphics g){ g.setColor(Color.white); g.fillRect(0, 0, 200, 100); drawCircle(g); }
}</pre>
程序可以完成紅球的左右平移,但是效果較差,因為每次的界面刷新都涉及圖片的重新繪制,這較為費時,因此,畫面的抖動和白光效果明顯。為了得到更優質的顯示效果,可以為它加上緩沖區。代碼如清單 2 所示。
清單 2. 加上緩沖區之后示例代碼
import java.awt.Color; import java.awt.Graphics;public class BufferMovingCircle extends NoBufferMovingCircle{ Graphics doubleBuffer = null;//緩沖區
public void init(){ super.init(); doubleBuffer = screenImage.getGraphics(); }
public void paint(Graphics g){//使用緩沖區,優化原有的 paint 方法 doubleBuffer.setColor(Color.white);//先在內存中畫圖 doubleBuffer.fillRect(0, 0, 200, 100); drawCircle(doubleBuffer); g.drawImage(screenImage, 0, 0, this); } }</pre>
使用 Buffer 進行 I/O 操作
除 NIO 外,使用 Java 進行 I/O 操作有兩種基本方式:
- 使用基于 InputStream 和 OutputStream 的方式;
- 使用 Writer 和 Reader。 </ul>
無論使用哪種方式進行文件 I/O,如果能合理地使用緩沖,就能有效地提高 I/O 的性能。
下面顯示了可與 InputStream、OutputStream、Writer 和 Reader 配套使用的緩沖組件。
OutputStream-FileOutputStream-BufferedOutputStream
InputStream-FileInputStream-BufferedInputStream
Writer-FileWriter-BufferedWriter
Reader-FileReader-BufferedReader
使用緩沖組件對文件 I/O 進行包裝,可以有效提高文件 I/O 的性能。
清單 3. 示例代碼
import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException;public class StreamVSBuffer { public static void streamMethod() throws IOException{ try { long start = System.currentTimeMillis(); //請替換成自己的文件 DataOutputStream dos = new DataOutputStream( new FileOutputStream("C:\StreamVSBuffertest.txt")); for(int i=0;i<10000;i++){ dos.writeBytes(String.valueOf(i)+"\r\n");//循環 1 萬次寫入數據 } dos.close(); DataInputStream dis = new DataInputStream(new FileInputStream("C:\StreamVSBuffertest.txt")); while(dis.readLine() != null){
} dis.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); }
}
public static void bufferMethod() throws IOException{ try { long start = System.currentTimeMillis(); //請替換成自己的文件 DataOutputStream dos = new DataOutputStream(new BufferedOutputStream( new FileOutputStream("C:\StreamVSBuffertest.txt"))); for(int i=0;i<10000;i++){ dos.writeBytes(String.valueOf(i)+"\r\n");//循環 1 萬次寫入數據 } dos.close(); DataInputStream dis = new DataInputStream(new BufferedInputStream( new FileInputStream("C:\StreamVSBuffertest.txt"))); while(dis.readLine() != null){
} dis.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } }
public static void main(String[] args){ try { StreamVSBuffer.streamMethod(); StreamVSBuffer.bufferMethod(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }</pre>
運行結果如清單 4 所示。
清單 4. 運行輸出
889 31很明顯使用緩沖的代碼性能比沒有使用緩沖的快了很多倍。清單 5 所示代碼對 FileWriter 和 FileReader 進行了相似的測試。
清單 5.FileWriter 和 FileReader 代碼
import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileNotFoundException; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException;public class WriterVSBuffer { public static void streamMethod() throws IOException{ try { long start = System.currentTimeMillis(); FileWriter fw = new FileWriter("C:\StreamVSBuffertest.txt");//請替換成自己的文件 for(int i=0;i<10000;i++){ fw.write(String.valueOf(i)+"\r\n");//循環 1 萬次寫入數據 } fw.close(); FileReader fr = new FileReader("C:\StreamVSBuffertest.txt"); while(fr.ready() != false){
} fr.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); }
}
public static void bufferMethod() throws IOException{ try { long start = System.currentTimeMillis(); BufferedWriter fw = new BufferedWriter(new FileWriter("C:\StreamVSBuffertest.txt"));//請替換成自己的文件 for(int i=0;i<10000;i++){ fw.write(String.valueOf(i)+"\r\n");//循環 1 萬次寫入數據 } fw.close(); BufferedReader fr = new BufferedReader(new FileReader("C:\StreamVSBuffertest.txt")); while(fr.ready() != false){
} fr.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } }
public static void main(String[] args){ try { StreamVSBuffer.streamMethod(); StreamVSBuffer.bufferMethod(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }</pre>
運行輸出如清單 6 所示。
清單 6. 運行輸出
1295 31從上面例子可以看出,無論對于讀取還是寫入文件,適當地使用緩沖,可以有效地提升系統的文件讀寫性能,為用戶減少響應時間。
緩存
緩存也是一塊為提升系統性能而開辟的內存空間。緩存的主要作用是暫存數據處理結果,并提供下次訪問使用。在很多場合,數據的處理或者數據獲取可能會非常費時,當對這個數據的請求量很大時,頻繁的數據處理會耗盡 CPU 資源。緩存的作用就是將這些來之不易的數據處理結果暫存起來,當有其他線程或者客戶端需要查詢相同的數據資源時,可以省略對這些數據的處理流程,而直接從緩存中獲取處理結果,并立即返回給請求組件,以此提高系統的響應時間。
目前有很多基于 Java 的緩存框架,比如 EHCache、OSCache 和 JBossCache 等。EHCache 緩存出自 Hibernate,是其默認的數據緩存解決方案;OSCache 緩存是有 OpenSymphony 設計的,它可以用于緩存任何對象,甚至是緩存部分 JSP 頁面或者 HTTP 請求;JBossCache 是由 JBoss 開發、可用于 JBoss 集群間數據共享的緩存框架。
以 EHCache 為例,EhCache 的主要特性有:
- 快速;
- 簡單;
- 多種緩存策略;
- 緩存數據有兩級:內存和磁盤,因此無需擔心容量問題;
- 緩存數據會在虛擬機重啟的過程中寫入磁盤;
- 可以通過 RMI、可插入 API 等方式進行分布式緩存;
- 具有緩存和緩存管理器的偵聽接口;
- 支持多緩存管理器實例,以及一個實例的多個緩存區域;
- 提供 Hibernate 的緩存實現。
</ol>由于 EhCache 是進程中的緩存系統,一旦將應用部署在集群環境中,每一個節點維護各自的緩存數據,當某個節點對緩存數據進行更新,這些更新的數據無法在其它節點中共享,這不僅會降低節點運行的效率,而且會導致數據不同步的情況發生。例如某個網站采用 A、B 兩個節點作為集群部署,當 A 節點的緩存更新后,而 B 節點緩存尚未更新就可能出現用戶在瀏覽頁面的時候,一會是更新后的數據,一會是尚未更新的數據,盡管我們也可以通過 Session Sticky 技術來將用戶鎖定在某個節點上,但對于一些交互性比較強或者是非 Web 方式的系統來說,Session Sticky 顯然不太適合。所以就需要用到 EhCache 的集群解決方案。清單 7 所示是 EHCache 示例代碼。
清單 7.EHCache 示例代碼
import net.sf.ehcache.Cache; import net.sf.ehcache.CacheManager; import net.sf.ehcache.Element; /**
- 第一步:生成 CacheManager 對象
- 第二步:生成 Cache 對象
- 第三步:向 Cache 對象里添加由 key,value 組成的鍵值對的 Element 元素
- @author mahaibo / public class EHCacheDemo{
public static void main(String[] args) { //指定 ehcache.xml 的位置 String fileName="E:\1008\workspace\ehcachetest\ehcache.xml"; CacheManager manager = new CacheManager(fileName); //取出所有的 cacheName String names[] = manager.getCacheNames(); for(int i=0;i<names.length;i++){ System.out.println(names[i]); } //根據 cacheName 生成一個 Cache 對象 //第一種方式: Cache cache=manager.getCache(names[0]);
//第二種方式,ehcache 里必須有 defaultCache 存在,"test"可以換成任何值 // Cache cache = new Cache("test", 1, true, false, 5, 2); // manager.addCache(cache);
//向 Cache 對象里添加 Element 元素,Element 元素有 key,value 鍵值對組成 cache.put(new Element("key1","values1")); Element element = cache.get("key1");
System.out.println(element.getValue()); Object obj = element.getObjectValue(); System.out.println((String)obj); manager.shutdown();
}
}</pre>
對象復用
對象復用池是目前很常用的一種系統優化技術。它的核心思想是,如果一個類被頻繁請求使用,那么不必每次都生成一個實例,可以將這個類的一些實例保存在一個“池”中,待需要使用的時候直接從池中獲取。這個“池”就稱為對象池。在實現細節上,它可能是一個數組,一個鏈表或者任何集合類。對象池的使用非常廣泛,例如線程池和數據庫連接池。線程池中保存著可以被重用的線程對象,當有任務被提交到線程時,系統并不需要新建線程,而是從池中獲得一個可用的線程,執行這個任務。在任務結束后,不需要關閉線程,而將它返回到池中,以便下次繼續使用。由于線程的創建和銷毀是較為費時的工作,因此,在線程頻繁調度的系統中,線程池可以很好地改善性能。數據庫連接池也是一種特殊的對象池,它用于維護數據庫連接的集合。當系統需要訪問數據庫時,不需要重新建立數據庫連接,而可以直接從池中獲取;在數據庫操作完成后,也不關閉數據庫連接,而是將連接返回到連接池中。由于數據庫連接的創建和銷毀是重量級的操作,因此,避免頻繁進行這兩個操作對改善系統的性能也有積極意義。目前應用較為廣泛的數據庫連接池組件有 C3P0 和 Proxool。
以 C3P0 為例,它是一個開源的 JDBC 連接池,它實現了數據源和 JNDI 綁定,支持 JDBC3 規范和 JDBC2 的標準擴展。目前使用它的開源項目有 Hibernate,Spring 等。如果采用 JNDI 方式配置,如清單 8 所示。
清單 8.Tomcat 數據源配置
<Resource name="jdbc/dbsource" type="com.mchange.v2.c3p0.ComboPooledDataSource" maxPoolSize="50" minPoolSize="5" acquireIncrement="2" initialPoolSize="10" maxIdleTime="60" factory="org.apache.naming.factory.BeanFactory" user="xxxx" password="xxxx" driverClass="oracle.jdbc.driver.OracleDriver" jdbcUrl="jdbc:oracle:thin:@192.168.x.x:1521:orcl" idleConnectionTestPeriod="10" />參數說明:
- idleConnectionTestPerio:當數據庫重啟后或者由于某種原因進程被殺掉后,C3P0 不會自動重新初始化數據庫連接池,當新的請求需要訪問數據庫的時候,此時會報錯誤 (因為連接失效),同時刷新數據庫連接池,丟棄掉已經失效的連接,當第二個請求到來時恢復正常。C3P0 目前沒有提供當獲取已建立連接失敗后重試次數的參數,只有獲取新連接失敗后重試次數的參數。
- acquireRetryAttempts:該參數的作用是設置系統自動檢查連接池中連接是否正常的一個頻率參數,時間單位是秒。
- acquireIncremen:當連接池中的的連接耗盡的時候 c3p0 一次同時獲取的連接數,也就是說,如果使用的連接數已經達到了 maxPoolSize,c3p0 會立即建立新的連接。
- maxIdleTim:另外,C3P0 默認不會 close 掉不用的連接池,而是將其回收到可用連接池中,這樣會導致連接數越來越大,所以需要設置 maxIdleTime(默認 0,表示永遠不過期),單位是秒,maxIdleTime 表示 idle 狀態的 connection 能存活的最大時間。
</ol>如果使用 spring,同時項目中不使用 JNDI,又不想配置 Hibernate,可以直接將 C3P0 配置到 dataSource 中即可,如清單 9 所示。
清單 9.Spring 配置
<bean id="dataSource" destroy-method="close"> <property name="driverClass"><value>oracle.jdbc.driver.OracleDriver</value></property> <property name="jdbcUrl"><value>jdbc:oracle:thin:@localhost:1521:Test</value></property> <property name="user"><value>Kay</value></property> <property name="password"><value>root</value></property> <!--連接池中保留的最小連接數。--> <property name="minPoolSize" value="10" /> <!--連接池中保留的最大連接數。Default: 15 --> <property name="maxPoolSize" value="100" /> <!--最大空閑時間,1800 秒內未使用則連接被丟棄。若為 0 則永不丟棄。Default: 0 --> <property name="maxIdleTime" value="1800" /> <!--當連接池中的連接耗盡的時候 c3p0 一次同時獲取的連接數。Default: 3 --> <property name="acquireIncrement" value="3" /> <property name="maxStatements" value="1000" /> <property name="initialPoolSize" value="10" /> <!--每 60 秒檢查所有連接池中的空閑連接。Default: 0 --> <property name="idleConnectionTestPeriod" value="60" /> <!--定義在從數據庫獲取新連接失敗后重復嘗試的次數。Default: 30 --> <property name="acquireRetryAttempts" value="30" /> <property name="breakAfterAcquireFailure" value="true" /> <property name="testConnectionOnCheckout" value="false" /> </bean>類似的做法存在很多種,用戶可以自行上網搜索。
計算方式轉換
計算方式轉換比較出名的是時間換空間方式,它通常用于嵌入式設備,或者內存、硬盤空間不足的情況。通過使用犧牲 CPU 的方式,獲得原本需要更多內存或者硬盤空間才能完成的工作。
一個非常簡單的時間換空間的算法,實現了 a、b 兩個變量的值交換。交換兩個變量最常用的方法是使用一個中間變量,而引入額外的變量意味著要使用更多的空間。采用下面的方法可以免去中間變量,而達到變量交換的目的,其代價是引入了更多的 CPU 運算。
清單 10. 示例代碼
a=a+b; b=a-b; a=a-b;另一個較為有用的例子是對無符號整數的支持。在 Java 語言中,不支持無符號整數,這意味著當需要無符號的 Byte 時,需要使用 Short 代替,這也意味著空間的浪費。下面代碼演示了使用位運算模擬無符號 Byte。雖然在取值和設值過程中需要更多的 CPU 運算,但是可以大大降低對內存空間的需求。
清單 11. 無符號整數運算
public class UnsignedByte { public short getValue(byte i){//將 byte 轉為無符號的數字 short li = (short)(i & 0xff); return li; }public byte toUnsignedByte(short i){ return (byte)(i & 0xff);//將 short 轉為無符號 byte }
public static void main(String[] args){ UnsignedByte ins = new UnsignedByte(); short[] shorts = new short[256];//聲明一個 short 數組 for(int i=0;i<shorts.length;i++){//數組不能超過無符號 byte 的上限 shorts[i]=(short)i; } byte[] bytes = new byte[256];//使用 byte 數組替代 short 數組 for(int i=0;i<bytes.length;i++){ bytes[i]=ins.toUnsignedByte(shorts[i]);//short 數組的數據存到 byte 數組中 } for(int i=0;i<bytes.length;i++){ System.out.println(ins.getValue(bytes[i])+" ");//從 byte 數組中取出無符號的 byte } } }</pre>
運行輸出如清單 12 所示,篇幅所限,只顯示到 10 為止。
清單 12. 運行輸出
0 1 2 3 4 5 6 7 8 9 10如果 CPU 的能力較弱,可以采用犧牲空間的方式提高計算能力,實例代碼如清單 13 所示。
清單 13. 提高計算能力
import java.util.Arrays; import java.util.HashMap; import java.util.Map;public class SpaceSort { public static int arrayLen = 1000000;
public static void main(String[] args){ int[] a = new int[arrayLen]; int[] old = new int[arrayLen]; Map<Integer,Object> map = new HashMap<Integer,Object>(); int count = 0; while(count < a.length){ //初始化數組 int value = (int)(Math.random()arrayLen10)+1; if(map.get(value)==null){ map.put(value, value); a[count] = value; count++; } } System.arraycopy(a, 0, old, 0, a.length);//從 a 數組拷貝所有數據到 old 數組 long start = System.currentTimeMillis(); Arrays.sort(a); System.out.println("Arrays.sort spend:"+(System.currentTimeMillis() - start)+"ms"); System.arraycopy(old, 0, a, 0, old.length);//恢復 原有數據 start = System.currentTimeMillis(); spaceTotime(a); System.out.println("spaceTotime spend:"+(System.currentTimeMillis() - start)+"ms"); }
public static void spaceTotime(int[] array){ int i = 0; int max = array[0]; int l = array.length; for(i=1;i<l;i++){ if(array[i]>max){ max = array[i]; } } int[] temp = new int[max+1]; for(i=0;i<l;i++){ temp[array[i]] = array[i]; } int j = 0; int max1 = max + 1; for(i=0;i<max1;i++){ if(temp[i] > 0){ array[j++] = temp[i]; } } } }</pre>
函數 spaceToTime() 實現了數組的排序,它不計空間成本,以數組的索引下標來表示數據大小,因此避免了數字間的相互比較,這是一種典型的以空間換時間的思路。
結束語
應對、處理高吞吐量系統有很多方面可以入手,作者將以系列的方式逐步介紹覆蓋所有領域。本文主要介紹了緩沖區、緩存操作、對象復用池、計算方式轉換等優化及建議,從實際代碼演示入手,對優化建議及方案進行了驗證。作者始終堅信,沒有什么優化方案是百分百有效的,需要讀者根據實際情況進行選擇、實踐。
</div>本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!