Java語言特别強調準确性,但可靠的行為要以性能作為代價。這一特點反映在自動收集垃圾、嚴格的運作期檢查、完整的位元組碼檢查以及保守的運作期同步等等方面。對一個解釋型的虛拟機來說,由于目前有大量平台可供挑選,是以進一步阻礙了性能的發揮。
“先做完它,再逐漸完善。幸好需要改進的地方通常不會太多。”
本附錄的宗旨就是指導大家尋找和優化“需要完善的那一部分”。
D.1 基本方法
隻有正确和完整地檢測了程式後,再可着手解決性能方面的問題:
(1) 在現實環境中檢測程式的性能。若符合要求,則目标達到。若不符合,則轉到下一步。
(2) 尋找最緻命的性能瓶頸。這也許要求一定的技巧,但所有努力都不會白費。如簡單地猜測瓶頸所在,并試圖進行優化,那麼可能是白花時間。
(3) 運用本附錄介紹的提速技術,然後傳回步驟1。
為使努力不至白費,瓶頸的定位是至關重要的一環。Donald Knuth[9]曾改進過一個程式,那個程式把50%的時間都花在約4%的代碼量上。在僅一個工作小時裡,他修改了幾行代碼,使程式的執行速度倍增。此時,若将時間繼續投入到剩餘代碼的修改上,那麼隻會得不償失。Knuth在程式設計界有一句名言:“過早的優化是一切麻煩的根源”(Premature optimization is the root of all evil)。最明智的做法是抑制過早優化的沖動,因為那樣做可能遺漏多種有用的程式設計技術,造成代碼更難了解和操控,并需更大的精力進行維護。
D.2 尋找瓶頸
為找出最影響程式性能的瓶頸,可采取下述幾種方法:
D.2.1 安插自己的測試代碼
插入下述“顯式”計時代碼,對程式進行評測:
long start = System.currentTimeMillis();
// 要計時的運算代碼放在這兒
long time = System.currentTimeMillis() - start;
利用System.out.println(),讓一種不常用到的方法将累積時間列印到控制台視窗。由于一旦出錯,編譯器會将其忽略,是以可用一個“靜态最終布爾值”(Static final boolean)打開或關閉計時,使代碼能放心留在最終發行的程式裡,這樣任何時候都可以拿來應急。盡管還可以選用更複雜的評測手段,但若僅僅為了量度一個特定任務的執行時間,這無疑是最簡便的方法。
System.currentTimeMillis()傳回的時間以千分之一秒(1毫秒)為機關。然而,有些系統的時間精度低于1毫秒(如Windows PC),是以需要重複n次,再将總時間除以n,獲得準确的時間。
D.2.2 JDK性能評測[2]
JDK配套提供了一個内建的評測程式,能跟蹤花在每個例程上的時間,并将評測結果寫入一個檔案。不幸的是,JDK評測器并不穩定。它在JDK 1.1.1中能正常工作,但在後續版本中卻非常不穩定。
為運作評測程式,請在調用Java解釋器的未優化版本時加上-prof選項。例如:
java_g -prof myClass
或加上一個程式片(Applet):
java_g -prof sun.applet.AppletViewer applet.html
了解評測程式的輸出資訊并不容易。事實上,在JDK 1.0中,它居然将方法名稱截短為30字元。是以可能無法區分出某些方法。然而,若您用的平台确實能支援-prof選項,那麼可試試Vladimir Bulatov的“HyperPorf”[3]或者Greg White的“ProfileViewer”來解釋一下結果。
D.2.3 特殊工具
如果想随時跟上性能優化工具的潮流,最好的方法就是作一些Web站點的常客。比如由Jonathan Hardwick制作的“Tools for Optimizing Java”(Java優化工具)網站:
<a href="http://www.cs.cmu.edu/~jch/java/tools.html">[url]http://www.cs.cmu.edu/~jch/java/tools.html[/url]</a>
D.2.4 性能評測的技巧
■由于評測時要用到系統時鐘,是以當時不要運作其他任何程序或應用程式,以免影響測試結果。
■如對自己的程式進行了修改,并試圖(至少在開發平台上)改善它的性能,那麼在修改前後應分别測試一下代碼的執行時間。
■盡量在完全一緻的環境中進行每一次時間測試。
■如果可能,應設計一個不依賴任何使用者輸入的測試,避免使用者的不同反應導緻結果出現誤差。
D.3 提速方法
現在,關鍵的性能瓶頸應已隔離出來。接下來,可對其應用兩種類型的優化:正常手段以及依賴Java語言。
D.3.1 正常手段
通常,一個有效的提速方法是用更現實的方式重新定義程式。例如,在《Programming Pearls》(程式設計拾貝)一書中[14],Bentley利用了一段小說資料描寫,它可以生成速度非常快、而且非常精簡的拼寫檢查器,進而介紹了Doug McIlroy對英語語言的表述。除此以外,與其他方法相比,更好的算法也許能帶來更大的性能提升——特别是在資料集的尺寸越來越大的時候。欲了解這些正常手段的詳情,請參考本附錄末尾的“一般書籍”清單。
D.3.2 依賴語言的方法
為進行客觀的分析,最好明确掌握各種運算的執行時間。這樣一來,得到的結果可獨立于目前使用的計算機——通過除以花在本地指派上的時間,最後得到的就是“标準時間”。
運算 示例 标準時間
本地指派 i=n; 1.0
執行個體指派 this.i=n; 1.2
int增值 i++; 1.5
byte增值 b++; 2.0
short增值 s++; 2.0
float增值 f++; 2.0
double增值 d++; 2.0
空循環 while(true) n++; 2.0
三元表達式 (x<0) ?-x : x 2.2
算術調用 Math.abs(x); 2.5
數組指派 a[0] = n; 2.7
long增值 l++; 3.5
方法調用 funct(); 5.9
throw或catch異常 try{ throw e; }或catch(e){} 320
同步方法調用 synchMehod(); 570
建立對象 new Object(); 980
建立數組 new int[10]; 3100
通過自己的系統(如我的Pentium 200 Pro,Netscape 3及JDK 1.1.5),這些相對時間向大家揭示出:建立對象和數組會造成最沉重的開銷,同步會造成比較沉重的開銷,而一次不同步的方法調用會造成适度的開銷。參考資源[5]和[6]為大家總結了測量用程式片的Web位址,可到自己的機器上運作它們。
1. 正常修改
下面是加快Java程式關鍵部分執行速度的一些正常操作建議(注意對比修改前後的測試結果)。
将... 修改成... 理由
接口 抽象類(隻需一個父時) 接口的多個繼承會妨礙性能的優化
非本地或數組循環變量 本地循環變量 根據前表的耗時比較,一次執行個體整數指派的時間是本地整數指派時間的1.2倍,但數組指派的時間是本地整數指派的2.7倍
連結清單(固定尺寸) 儲存丢棄的連結項目,或将清單替換成一個循環數組(大緻知道尺寸) 每建立一個對象,都相當于本地指派980次。參考“重複利用對象”(下一節)、Van Wyk[12] p.87以及Bentley[15] p.81
x/2(或2的任意次幂) X>>2(或2的任意次幂) 使用更快的硬體指令
D.3.3 特殊情況
■字串的開銷:字串連接配接運算符+看似簡單,但實際需要消耗大量系統資源。編譯器可高效地連接配接字串,但變量字串卻要求可觀的處理器時間。例如,假設s和t是字串變量:
System.out.println("heading" + s + "trailer" + t);
上述語句要求建立一個StringBuffer(字串緩沖),追加自變量,然後用toString()将結果轉換回一個字串。是以,無論磁盤空間還是處理器時間,都會受到嚴重消耗。若準備追加多個字串,則可考慮直接使用一個字串緩沖——特别是能在一個循環裡重複利用它的時候。通過在每次循環裡禁止建立一個字串緩沖,可節省980機關的對象建立時間(如前所述)。利用substring()以及其他字串方法,可進一步地改善性能。如果可行,字元數組的速度甚至能夠更快。也要注意由于同步的關系,是以StringTokenizer會造成較大的開銷。
■同步:在JDK解釋器中,調用同步方法通常會比調用不同步方法慢10倍。經JIT編譯器處理後,這一性能上的差距提升到50到100倍(注意前表總結的時間顯示出要慢97倍)。是以要盡可能避免使用同步方法——若不能避免,方法的同步也要比代碼塊的同步稍快一些。
■重複利用對象:要花很長的時間來建立一個對象(根據前表總結的時間,對象的建立時間是指派時間的980倍,而建立一個小數組的時間是指派時間的3100倍)。是以,最明智的做法是儲存和更新老對象的字段,而不是建立一個新對象。例如,不要在自己的paint()方法中建立一個Font對象。相反,應将其聲明成執行個體對象,再初始化一次。在這以後,可在paint()裡需要的時候随時進行更新。參見Bentley編著的《程式設計拾貝》,p.81[15]。
■異常:隻有在不正常的情況下,才應放棄異常處理子產品。什麼才叫“不正常”呢?這通常是指程式遇到了問題,而這一般是不願見到的,是以性能不再成為優先考慮的目标。進行優化時,将小的“try-catch”塊合并到一起。由于這些塊将代碼分割成小的、各自獨立的片斷,是以會妨礙編譯器進行優化。另一方面,若過份熱衷于删除異常處理子產品,也可能造成代碼健壯程度的下降。
■散列處理:首先,Java 1.0和1.1的标準“散清單”(Hashtable)類需要造型以及特别消耗系統資源的同步處理(570機關的指派時間)。其次,早期的JDK庫不能自動決定最佳的表格尺寸。最後,散列函數應針對實際使用項(Key)的特征設計。考慮到所有這些原因,我們可特别設計一個散列類,令其與特定的應用程式配合,進而改善正常散清單的性能。注意Java 1.2集合庫的散列映射(HashMap)具有更大的靈活性,而且不會自動同步。
■方法内嵌:隻有在方法屬于final(最終)、private(專用)或static(靜态)的情況下,Java編譯器才能内嵌這個方法。而且某些情況下,還要求它絕對不可以有局部變量。若代碼花大量時間調用一個不含上述任何屬性的方法,那麼請考慮為其編寫一個“final”版本。
■I/O:應盡可能使用緩沖。否則,最終也許就是一次僅輸入/輸出一個位元組的惡果。注意JDK 1.0的I/O類采用了大量同步措施,是以若使用象readFully()這樣的一個“大批量”調用,然後由自己解釋資料,就可獲得更佳的性能。也要注意Java 1.1的“reader”和“writer”類已針對性能進行了優化。
■造型和執行個體:造型會耗去2到200個機關的指派時間。開銷更大的甚至要求上溯繼承(遺傳)結構。其他高代價的操作會損失和恢複更低層結構的能力。
■圖形:利用剪切技術,減少在repaint()中的工作量;倍增緩沖區,提高接收速度;同時利用圖形壓縮技術,縮短下載下傳時間。來自JavaWorld的“Java Applets”以及來自Sun的“Performing Animation”是兩個很好的教程。請記着使用最貼切的指令。例如,為根據一系列點畫一個多邊形,和drawLine()相比,drawPolygon()的速度要快得多。如必須畫一條單像素粗細的直線,drawLine(x,y,x,y)的速度比fillRect(x,y,1,1)快。
■使用API類:盡量使用來自Java API的類,因為它們本身已針對機器的性能進行了優化。這是用Java難于達到的。比如在複制任意長度的一個數組時,arraryCopy()比使用循環的速度快得多。
■替換API類:有些時候,API類提供了比我們希望更多的功能,相應的執行時間也會增加。是以,可定做特别的版本,讓它做更少的事情,但可更快地運作。例如,假定一個應用程式需要一個容器來儲存大量數組。為加快執行速度,可将原來的Vector(矢量)替換成更快的動态對象數組。
1. 其他建議
■将重複的常數計算移至關鍵循環之外——比如計算固定長度緩沖區的buffer.length。
■static final(靜态最終)常數有助于編譯器優化程式。
■實作固定長度的循環。
■使用javac的優化選項:-O。它通過内嵌static,final以及private方法,進而優化編譯過的代碼。注意類的長度可能會增加(隻對JDK 1.1而言——更早的版本也許不能執行位元組查證)。新型的“Just-in-time”編譯器會動态加速代碼。
■盡可能地将計數減至0——這使用了一個特殊的JVM位元組碼。
本文轉自 fish_yy 51CTO部落格,原文連結:http://blog.51cto.com/tester2test/137286,如需轉載請自行聯系原作者