- Java性能權威指南(第2版)
- (美)斯科特·奧克斯
- 4219字
- 2022-05-09 14:52:34
2.1 測試真實的應用程序
第一個原則,即測試真實的應用程序,是指應該以實際產品的使用方式進行測試。大致而言,進行性能測試的代碼可以分為3種:微基準測試(microbenchmark)、宏基準測試(macrobenchmark)和介基準測試(mesobenchmark)。每一種都各有優(yōu)缺點,只有適合真實應用程序的代碼才能提供最好的測試結果。
2.1.1 微基準測試
微基準測試是指通過測量一小部分代碼的性能來確定多種實現(xiàn)中哪個最好。一些例子包括比較創(chuàng)建線程和使用線程池的開銷,比較某個算術算法和其替代實現(xiàn)的執(zhí)行時間,等等。
微基準測試似乎是測試性能的好辦法,但是諸如即時編譯和垃圾回收這些對開發(fā)人員很有吸引力的Java特性,給正確編寫微基準測試增加了難度。
01. 必須讀取測試的結果
微基準測試程序和常規(guī)程序有很多不同的地方。首先,Java代碼在執(zhí)行的前幾次都是解釋執(zhí)行的,執(zhí)行的時間越長,運行速度越快。出于這個原因,所有的基準測試(不僅是微基準測試)通常都包括一個預熱期,在這期間,JVM可以將代碼編譯到最佳狀態(tài)。
最佳狀態(tài)包含了很多優(yōu)化。舉例來說,下面是一個看似簡單的循環(huán),用于計算第50個斐波那契數(shù)。
public void doTest() { // 主循環(huán) double l; for (int i = 0; i < nWarmups; i++) { l = fibImpl1(50); } long then = System.currentTimeMillis(); for (int i = 0; i < nLoops; i++) { l = fibImpl1(50); } long now = System.currentTimeMillis(); System.out.println("Elapsed time: " + (now - then)); }
這段代碼旨在得出fibImpl1()方法的執(zhí)行時間。它先預熱了編譯器,然后執(zhí)行了編譯后的方法。但這個執(zhí)行時間可能是0(或者更有可能的是,沒有循環(huán)體的for循環(huán)的執(zhí)行時間是0)。因為l的值未被讀取,所以編譯器完全可以跳過它不進行計算。這取決于fibImpl1()方法的內部結構,如果其內部僅僅是算術運算,那全都可以跳過。還有一種可能:方法只有一部分被執(zhí)行,甚至產生了錯誤的l值。因為l值從未被讀取,所以沒人會注意到錯誤。(第4章將詳細講解如何消除循環(huán)。)
解決該問題的方法是:確保每個結果都被讀取,而不只是寫入。在實踐中,將l的定義從局部變量改為實例變量(用volatile關鍵字進行聲明)即可測量這個方法的性能。(第9章將解釋l實例變量必須用volatile聲明的原因。)
多線程微基準測試
在上述例子中,即使微基準測試是單線程的,也需要使用volatile變量。
在編寫多線程微基準測試時要相當警惕。當多個線程同時執(zhí)行一小段代碼時,發(fā)生同步瓶頸(和其他線程問題)的可能性相當大。多線程微基準測試的結果往往會導致需要花費大量時間在優(yōu)化同步瓶頸上,而不是在解決更緊迫的性能需求上,但同步瓶頸很少出現(xiàn)在實際代碼中。
思考這樣一種情況:在微基準測試中,有兩個線程調用一個對象的同步方法。因為基準測試的代碼量少,所以大部分時間在執(zhí)行同步方法。即使同步方法的執(zhí)行時間只占整個微基準測試的50%,即使少到只有兩個線程,同時執(zhí)行同步方法的概率也會很大,所以基準測試會執(zhí)行得很慢。而且隨著線程的增加,資源競爭導致的性能問題會越來越糟糕。最終,測試變成了測量JVM處理多線程資源競爭的方式,這并不是微基準測試的初衷。
02. 必須測試一系列的輸入值
即便讀取了微基準測試結果,隱患依然存在。上述代碼只有一個目的:計算第50個斐波那契數(shù)。足夠智能的編譯器可以分析出這一點,只執(zhí)行一次循環(huán)體就能達成目的,或至少能減少迭代次數(shù),畢竟這些操作是多余的。
此外,fibImpl1(1000)和fibImpl1(1)的性能很可能相差很大。如果測試的目標是比較不同實現(xiàn)的性能,那就必須測試一系列的輸入值。
這些值可以是隨機的,舉例如下:
for (int i = 0; i < nLoops; i++) { l = fibImpl1(random.nextInteger()); }
這可能不是我們想要的結果,因為循環(huán)的執(zhí)行時間包含了計算隨機數(shù)的時間。現(xiàn)在測試檢測的時間是計算nLoops次斐波那契數(shù)列的時間加上生成nLoops個隨機數(shù)的時間。
所以,最好提前算好輸入值。
int[] input = new int[nLoops]; for (int i = 0; i < nLoops; i++) { input[i] = random.nextInt(); } long then = System.currentTimeMillis(); for (int i = 0; i < nLoops; i++) { try { l = fibImpl1(input[i]); } catch (IllegalArgumentException iae) { } } long now = System.currentTimeMillis();
03. 必須測量正確的輸入值
你也許已經注意到,現(xiàn)在測試不得不在調用fibImpl1()方法時捕獲異常:輸入值的范圍可能包括負數(shù)(負數(shù)中沒有斐波那契數(shù)),或者輸入值大于1476(其結果超出了double的表示范圍)。
當代碼用于生產環(huán)境時,這些是可能出現(xiàn)的輸入值嗎?在這個例子里也許不是,但在你自己的基準測試中,情況可能就不一樣了。考慮這樣的情況:假設你測試的代碼有兩種實現(xiàn),第一種實現(xiàn)能夠快速計算出斐波那契數(shù),但不會檢查輸入值的范圍;第二種實現(xiàn)在輸入值超出范圍時立刻拋出異常,然后執(zhí)行一個緩慢的遞歸操作來計算斐波那契數(shù),如下所示。
public double fibImplSlow(int n) { if (n < 0) throw new IllegalArgumentException("Must be > 0"); if (n > 1476) throw new ArithmeticException("Must be < 1476"); return recursiveFib(n); }
在輸入值的范圍很大的情況下,和原來的實現(xiàn)相比,上述這種新的實現(xiàn)更快,這僅僅是因為方法的開頭進行了范圍檢查。
在現(xiàn)實中,如果用戶總是給方法傳入小于100的值,那么比較上述兩種實現(xiàn)就會得出錯誤的結論。一般情況下,fibImpl1()方法會更快。就像第1章解釋的那樣,我們應該針對常見的情況進行優(yōu)化。(這顯然是一個虛構的例子,在原來的實現(xiàn)上添加邊界檢查就能優(yōu)化,生產環(huán)境中一般不太可能有這種情況。)
04. 代碼在生產環(huán)境中可能有不同表現(xiàn)
到目前為止,我們關注的問題可以通過認真編寫微基準測試來解決。代碼被納入到一個更大的系統(tǒng)中后,其他因素也會影響代碼運行的最終結果。編譯器利用代碼的性能分析反饋(profile feedback)決定編譯方法時的最佳優(yōu)化方式。性能分析反饋基于方法被頻繁調用、調用棧的深度、參數(shù)的實際類型(包括子類)等因素,而這些都是由代碼的實際運行環(huán)境決定的。
因此,對于同樣的代碼,編譯器在微基準測試中的優(yōu)化方式常常與大型系統(tǒng)中的優(yōu)化方式不同。
在垃圾回收方面,微基準測試也可能表現(xiàn)出截然不同的行為。考慮微基準測試的兩種實現(xiàn):第一種實現(xiàn)可以快速得出結果,但是會產生大量短期對象(short-lived object);第二種實現(xiàn)慢一點,產生的短期對象也少一點。
當運行一個小程序來測試時,第一種實現(xiàn)可能更快。盡管它會觸發(fā)更多的垃圾回收操作,但新生代垃圾回收器在回收時會快速丟棄短期對象,更短的整體時間有利于這種實現(xiàn)。當在服務器上使用多線程并發(fā)執(zhí)行這段代碼時,GC性能分析看起來會不同:多個線程會很快填滿新生代,許多在微基準測試中被快速丟棄的短期對象在多線程服務器環(huán)境下使用時,會被提升到老年代。這會導致頻繁的(而且代價巨大的)Full GC。在這種情況下,F(xiàn)ull GC花費的大量時間,讓第一種實現(xiàn)不如第二種快,“更慢”的實現(xiàn)產生更少的垃圾。
最后,要確定微基準測試的實際含義。像我們現(xiàn)在討論的基準測試,很多循環(huán)的整體時間差異是以秒為單位的,而單次迭代的時間差異是以納秒為單位的。納秒慢慢累加,“積少成多”就會頻繁造成性能問題。但是要明確,特別是在回歸測試中,納秒級別的程序跟蹤是否有意義。若一個集合被訪問上百萬次,那么每次訪問節(jié)省的幾納秒就很重要(見第12章的例子)。對于不那么頻繁的操作(比如每次REST調用時運行一次),修復微基準測試發(fā)現(xiàn)的納秒級別的回歸問題就會浪費時間,將這些時間用于優(yōu)化其他操作必然更有益。
盡管有很多缺陷,但微基準測試還是很受歡迎,以至于OpenJDK有一個專門用來開發(fā)微基準測試的核心框架:Java微基準測試工具(jmh,Java Microbenchmark Harness)。jmh被JDK開發(fā)人員用來構建針對JDK本身的回歸測試,同時為開發(fā)人員提供了通用的基準測試框架。2.5.1節(jié)將討論jmh的更多細節(jié)。
什么是預熱期?
Java的一個性能特點是,代碼執(zhí)行得越多,性能就越好(這個話題會在第4章講到)。因此,微基準測試必須包含一個預熱期,讓編譯器有機會生成最佳代碼。
本章稍后會深入討論預熱期的優(yōu)點。微基準測試必須有預熱期,否則它測量的就是編譯性能,而不是代碼性能。
2.1.2 宏基準測試
要測量一個應用程序的性能,最好的測量對象就是應用程序本身,外加它使用的任何外部資源。這就是宏基準測試的原則。如果應用程序通過調用目錄服務(比如通過LDAP1)來檢查用戶憑證,那就應該在這種模式下測試應用程序。去掉LDAP調用對于模塊級別的測試有效果,但是應用程序必須在配置完整的情況下進行測試。
1lightweight directory access protocol,輕量目錄訪問協(xié)議。
一個原因是,隨著應用程序規(guī)模的增長,上述準則變得越來越重要,也越來越難實現(xiàn)。復雜系統(tǒng)并不是各個組成部分加在一起那么簡單,當這些部分組合在一起時,它們的行為會截然不同。例如,模擬數(shù)據(jù)庫調用可能意味著你再也不必擔心數(shù)據(jù)庫性能了——嘿,你是寫Java的,為什么非要處理本該由DBA操心的性能問題呢?數(shù)據(jù)庫連接會消耗大量的堆空間用于緩存;網絡會因傳送了太多數(shù)據(jù)而飽和;代碼調用簡單的方法(簡單是相對于JDBC驅動中復雜的代碼而言的)時優(yōu)化方式不同;CPU傳輸和緩存短代碼路徑比長代碼路徑更高效……
需要測試完整應用程序的另一個原因是資源分配。在理想的情況下,有足夠的時間優(yōu)化應用程序中的每一行代碼。在現(xiàn)實世界里,交付日期迫在眉睫,僅僅優(yōu)化復雜運行環(huán)境的一部分可能不會有立竿見影的效果。
思考圖2-1所示的數(shù)據(jù)流。用戶登錄后發(fā)起數(shù)據(jù)請求,然后系統(tǒng)進行數(shù)據(jù)業(yè)務處理,數(shù)據(jù)庫基于此加載數(shù)據(jù),之后經過專有計算,修改后的數(shù)據(jù)存入數(shù)據(jù)庫,最后將結果返回給用戶。每個方框中的數(shù)字表示該模塊在單獨測試時每秒能處理的請求數(shù)(RPS)。

圖2-1:典型的數(shù)據(jù)流
從業(yè)務角度來看,專有計算是最重要的。這是程序存在的理由,也是我們獲取報酬的原因。然而在這個例子中,使專有計算模塊的速度翻倍完全沒有任何好處。任何應用程序(包括獨立運行的JVM)都可以像這樣,建模為一系列的步驟,方框(模塊、子系統(tǒng)等)的效率決定了方框的數(shù)據(jù)輸出速度。前一個方框的數(shù)據(jù)輸出速度決定了下一個子系統(tǒng)的數(shù)據(jù)輸入速度。
假設數(shù)據(jù)業(yè)務處理部分進行了算法改進,可以處理200 RPS,系統(tǒng)的負載也會相應地增加。LDAP系統(tǒng)可以處理增加的負載:到目前為止還好,數(shù)據(jù)會以200 RPS的速率輸入數(shù)據(jù)業(yè)務處理模塊,也會以200 RPS的速率輸出。
但是,數(shù)據(jù)加載模塊的處理速率仍然只有100 RPS。即便有200 RPS輸入數(shù)據(jù)庫,也只有100 RPS會輸出到其他模塊。雖然數(shù)據(jù)業(yè)務處理的效率翻倍了,但是系統(tǒng)的總吞吐量仍只有100 RPS。在花時間改進運行環(huán)境的其他方面之前,進一步改進數(shù)據(jù)業(yè)務處理的算法只會是徒勞。
在這個例子中,優(yōu)化數(shù)據(jù)業(yè)務處理所花費的時間并沒有被完全浪費。一旦優(yōu)化了其他的系統(tǒng)瓶頸,性能收益就會顯現(xiàn)。準確地說,這是優(yōu)先級的問題。在沒測試整個應用程序的時候,不可能知道優(yōu)化哪部分的性能會有效果。
多個JVM上的系統(tǒng)整體測試
在測試完整的應用程序時,一個特別重要的情況是,多個應用程序同時運行在同一硬件上。JVM的很多方面進行了調優(yōu),并假設所有硬件資源都是可用的。如果對這些JVM進行單獨測試,那么它們會表現(xiàn)得很好。如果在其他應用程序(包括但不限于其他JVM)正在運行時測試JVM,那么其表現(xiàn)則會大不相同。
后文將給出這樣的例子。這里簡單提一句:當執(zhí)行一個GC周期時,單個JVM(默認配置下)會使所有處理器的CPU使用率達到100%。如果以程序執(zhí)行時的平均水平來測量CPU,那么CPU使用率可能會達到40%——實際上,CPU使用率有時是30%,有時是100%。這在JVM獨立運行時還好,但是如果JVM和其他的應用程序一同運行,那么在GC時不可能100%利用機器的CPU。這時的性能會和單獨運行時有明顯差距。
這也是微基準測試和模塊級別的基準測試不一定能讓你了解應用程序性能全貌的另一個原因。
2.1.3 介基準測試
介基準測試介于微基準測試和宏基準測試之間。在與開發(fā)人員一起研究Java SE和大型Java程序的性能時,我發(fā)現(xiàn)兩者都有一套被稱為“微基準測試”的測試。對于Java SE工程師,“微基準測試”意味著比2.1.1節(jié)中的例子更小的測試,用來測量很小的部分。Java程序開發(fā)人員則往往會把這個詞用在其他基準測試上,即測量某個性能方面的基準測試,但是仍然要執(zhí)行大量代碼。
應用程序微基準測試的一個例子是,測量服務器返回簡單REST請求的響應有多快。與傳統(tǒng)的微基準測試相比,測試這種請求的代碼是相當多的:管理套接字的代碼、讀取請求的代碼、編寫響應的代碼等。從傳統(tǒng)的角度來看,這不算是微基準測試。
上述測試也不算是宏基準測試。它沒有安全性(比如用戶不用登錄系統(tǒng))、沒有會話管理、沒有大量使用其他應用程序特性。因為這種測試只是實際應用程序的子集,所以它屬于中間地帶——介基準測試。我用這個術語來指代做了一些實際工作但功能不全的基準測試。
介基準測試比微基準測試的缺陷更少,也比宏基準測試更容易操作。介基準測試不會有大量可以被編譯器優(yōu)化的無效代碼(除非無效代碼存在于應用程序中,這時候優(yōu)化是好事)。介基準測試更容易使用多線程。它們比完整的應用程序更有可能遇到同步瓶頸,不過,當實際的應用程序運行在大型硬件系統(tǒng)上且有大量負載時,這些瓶頸是不可避免的。
盡管如此,介基準測試仍不完美。如果使用這種基準測試來比較兩臺應用程序服務器的性能,那么開發(fā)人員很容易誤入歧途。考慮兩臺REST服務器假定的響應時間,如表2-1所示。
表2-1:兩臺REST服務器假定的響應時間

若僅用簡單的REST調用來比較兩臺服務器的性能,那么開發(fā)人員可能意識不到,服務器2自動地給每個請求都進行了鑒權。因此,開發(fā)人員可能得出服務器1的性能更好的結論。然而,如果應用程序一直需要鑒權(這是典型的情況),那么開發(fā)人員得出的結論就是錯誤的,因為服務器1需要花更多的時間進行鑒權。
即便如此,介基準測試也提供了一個測試完整應用程序的選擇。介基準測試的性能特點比微基準測試的更接近實際應用程序。本章稍后會用到一個應用程序,后續(xù)章節(jié)的很多例子會用到它。這個應用程序的服務器模式(REST服務器和Jakarta Enterprise Edition服務器)不使用類似認證一樣的服務器工具。雖然該應用程序可以訪問企業(yè)資源(例如數(shù)據(jù)庫),但在多數(shù)例子中,它只是產生隨機數(shù)據(jù)來替代數(shù)據(jù)庫調用。在批處理模式下,比如在沒有圖形用戶界面或者不與用戶交互的情況下,它會模擬一些實際(但快速)的運算。
用介基準測試進行自動化測試也是很好的方法,特別是在模塊級別的測試中。
快速小結
·沒有一個合適的框架,很難寫出好的微基準測試。
·要了解代碼的實際運行情況,唯一的方法是測試整個應用程序。
·通過介基準測試,可以在模塊級別或者操作級別進行合理的獨立性能測試,但是這無法替代完整應用程序的測試。
- Java應用與實戰(zhàn)
- AngularJS Testing Cookbook
- GeoServer Cookbook
- C#編程入門指南(上下冊)
- Practical Internet of Things Security
- Python從菜鳥到高手(第2版)
- Learning Neo4j 3.x(Second Edition)
- 程序員修煉之道:通向務實的最高境界(第2版)
- MongoDB權威指南(第3版)
- Instant Ext.NET Application Development
- Microsoft Dynamics AX 2012 R3 Financial Management
- Mastering Web Application Development with AngularJS
- C++語言程序設計
- AMP:Building Accelerated Mobile Pages
- Appcelerator Titanium:Patterns and Best Practices