絕大數的開發人員在日常工作過程中都會或多或少的遇見過性能問題,本文旨在闡述性能測試的理論,從而為性能分析和開發人員做指導。本文對於那些剛剛接觸性能調優和正在解決問題的開發人員也能提供一些啟發性的思路。
引言
計算機軟件作為人類智慧的結晶,幫助我們在這個日新月異的社會中完成了大量工作。我們的日常生活中已經離不開軟件,玲琅滿目的軟件已經滲透到了我們生活的各個角落,令我們目不暇接。我們都希望軟件變得更好,運行處理的速度更快,在當今硬件性能突飛猛進的變革中,軟件性能的提升也是一個永不落伍的話題。軟件性能測試的實質,是從哲學的角度看問題,找出其內在聯系,因果關系,形式內容關系,重疊關系等等。假如這些關系我們在分析過程中理清了,那么性能測試問題就會變得迎刃而解。
在軟件開發過程中,性能測試往往在開發前期容易被忽略。直到有一天問題暴露后,開發人員被迫的直面這個問題,大多數情況下,這是令開發人員感覺到非常痛苦事情。所以在軟件開發前期以及開發過程中性能測試的考量是必要的,那么具備相應理論知識和實踐方法也是一個優秀工程師所應當具備的素養,這里我們概括有四項原則,這些原則可以幫助開發人員豐富、充實測試理論,系統的開展性能測試工作,從而獲得更有價值的結果。
第一個原則就是性能測試只有在實際項目中實施才是有意義的,這樣才使得測試工作具有針對性,而且目標會更加明確。這個原則中有三個類別的基准可以指導開發人員度量性能測試的結果,但是每一種方法都有它的優點和劣勢,我們將結合實際例子,來總結闡述。
-
微觀基准,可以理解為在某一個方法或某一個組件中進行的單元性能測試。比如檢測一個線程同步和一個非線程同步的方法運行時所需要的時間。或者對比創建一個單獨線程和使用一個線程池的性能開銷。或者對比執行一個算法中的某一個迭代過程所需要的時間。當我們遇到這些情況時,我們常常會選擇做一個方法層面的性能測試。這些情況的性能測試,都可以嘗試使用微觀基准的方法進行性能測試。微觀基准看似編寫起來簡單快捷,但是編寫能夠准確反映性能問題的代碼並非一件易事。接下來通過例子讓我們從代碼中發現一些問題。這是一個單線程的程序片段,通過計算 50 次循環迭代來檢測執行方法所耗費的時間體現性能差異:
public void doTest() { double l; long then = System.currentTimeMillis(); int nLoops = 50; for (int i = 0; i < nLoops; i++) { l = compute(50); } long now = System.currentTimeMillis(); System.out.println("Elapsed time:" + (now - then)); } private double compute(int n){ if (n < 0) throw new IllegalArgumentException("Must be > 0"); if (n == 0) return 0d; if (n == 1) return 1d; double d = compute(n - 2) + compute(n - 1); if (Double.isInfinite(d)) throw new ArithmeticException("Overflow"); return d; }
執行這段代碼我們會發現一個問題,那就是執行時間只有短短的幾秒。難道果真是程序性能很高?答案並非如此,其實在整個執行過程中 compute 計算方法並沒有調用而是被編譯器自動忽略了。那么解決這個問題的辦法是將 double 類型的“l”換成 volatile 實例變量。這樣能夠確保每一個計算后所得到的結果是可以被記錄下來,用 volatile 修飾的變量,線程在每次使用變量的時候,都會讀取變量修改后的最后的值。
要特別值得注意的是,當考慮為多線程寫一個微基准性能測試用例時,假如幾個線程同時執行一小段業務邏輯代碼,這可能會引發潛在的線程同步所帶來的性能開銷和瓶頸。此時微觀微基准測試的結果往往引導開發人員為了保持同步進行不斷的優化,這樣會浪費很多時間,對於解決更緊迫的性能問題,這樣做就顯得得不償失。
我們再試想這樣一個例子,微基准測試兩個線程調用同步方法的情況,因為基准代碼很小,那么測試用例大部分時間將消耗在同步過程中。即使微基准測試在整體的同步過程中只占 50%,那么兩個線程嘗試執行同步方法的幾率也是相當高的。基准運行將會非常緩慢,添加額外的線程會造成更大的性能問題。
基於微觀基准的測試過程中,是不能含有額外的對性能產生影響的操作,我們知道執行 compute(1000) 和 compute(1) 在性能上是有很大差異的,假如我們的目標是對比兩個不同實現方法之間的性能差異,那么就應當考慮一系列的輸入測試值作為前提,傳遞給測試目標,參數就需要多樣化。這里以我們的經驗解決的辦法就是使用隨機值:
for (int i = 0; i < nLoops; i++) { l = compute(random.nextInt()); }
現在,產生隨機數的時間也包含在了整個循環執行過程中,因此測試結果中包含了隨機數生成所需要的時間,這並不能客觀的體現 compute 方法真實的性能。所以在構建微觀基准時,輸入的測試值必須是預先准備好的,且不會對性能測試產生額外的影響。正確的做法如下:
public void doTest() { double l; int nLoops = 10; Random random = new Random(); 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 = compute(input[i]); } catch (IllegalArgumentException iae) { } } long now = System.currentTimeMillis(); System.out.println("Elapsed time:" + (now - then)); }
微觀基准中輸入的測試值必須是符合業務邏輯的。所有的輸入的值並不一定會被代碼用到,實際的業務可能對輸入的數據有特定的要求,不合理的輸入值可能導致代碼在執行過程中就拋出異常而中斷,從而使得我們難以判斷代碼執行的效率。所以在准備測試數據的時候應當考慮到輸入數據的有效性,保證代碼執行的完整性。比如下面的例子輸入的參數如果是大於 1476 ,執行會立即中斷,從而影響了真實性能結果的產生。
public double ImplSlow(int n) { if (n < 0) throw new IllegalArgumentException("Must be > 0"); if (n > 1476) throw new ArithmeticException("Must be < 1476"); return verySlowImpl(n); }
通常情況下,對參與到實際業務計算的值提前檢測對提升性能是有幫助的,但是假如用戶大多數輸入的值是合理的,那么提前檢查數據的有效性就顯得冗余了。所以編寫核心邏輯代碼的時候,我們建議只針對一般情況做處理,保證執行的效率的高效性。假設訪問一個 collection 對象時,每一次能夠節省幾毫秒的話,那么在多次的訪問情況下就會對性能的提升產生重大的意義。
public class Test1 { private volatile double l; private int nLoops; private int[] input; private Test1(int n) { nLoops = n; input = new int[nLoops]; Random random = new Random(); for (int i = 0; i < nLoops; i++) { input[i] = random.nextInt(50); } } public void doTest(boolean isWarmup) { long then = System.currentTimeMillis(); for (int i = 0; i < nLoops; i++) { try { l = compute(input[i]); } catch (IllegalArgumentException iae) { } if (!isWarmup) { long now = System.currentTimeMillis(); System.out.println("Elapsed time:" + (now - then)); } } } private double compute(int n) { if (n < 0) throw new IllegalArgumentException("Must be > 0"); if (n == 0) return 0d; if (n == 1) return 1d; double d = compute(n - 2) + compute(n - 1); if (Double.isInfinite(d)) throw new ArithmeticException("Overflow"); return d; } public static void main(String[] args) { // TODO Auto-generated method stub Test1 test1 = new Test1(Integer.parseInt("10");)); test1.doTest(true); test1.doTest(false); } }
總得說來,微觀基准作用是有限的,在頻繁調用的方法中使用微觀基准的度量方法會幫助我們檢測代碼的性能,如果用在不會被頻繁調用的方法中是不合適的,應當考慮其它方法。
-
宏觀基准,當我們測量應用程序性能時,應當縱覽整個系統,影響應用程序性能的原因可能是多方面的,不能片面的認為性能瓶頸只會在程序本身上。通過下面這個例子我們將探討離開宏觀基准的性能測試是不可能找到影響應用程序性能真正的瓶頸。
上圖數據來自客戶實體,觸發應用程序的核心業務計算方法,該方法從數據庫加載數據,並傳導給核心業務中的計算方法,得到結果保存到數據庫,最終響應客戶的請求。每個圖形中的數字分別代表了這個模塊所能處理客戶請求的數量。核心業務模塊的優化多數情況是受限於業務的要求。假設我們優化這些核心模塊,使其可以處理 200 RPS 時,我們發現加載數據的模塊依然只能處理 100 RPS,也就是說整個系統的吞吐能力其實仍然為 100 RPS,最終對應用程序整體的性能提升是沒有任何幫助的。從這個例子我們得知,我們花費再多的精力在核心業務上的優化意義並不大,我們應當從整體運行情況來看,發現真正影響性能的瓶頸來解決問題,這就是宏觀基准原則的意義。
-
折衷基准,相比微觀基准和宏觀基准,一個單獨功能模塊的性能測試,或者一系列特定操作的性能測試被稱為折衷基准。它是介於微觀基准和宏觀基准之間的折衷方案。基於微觀基准測試的正確性是較難把握的,性能瓶頸的判斷絕不能僅僅依賴於此。如果我們要使用微觀基准作為性能的測量方法,那么不妨在此之前先嘗試基於宏觀基准的測試。它可以幫助我們了解系統以及代碼是如何工作的,從而形成一個系統整體邏輯結構圖。接下來可以考慮基於折衷基准的測試,來真正發現潛在的性能瓶頸。需要明確的是折衷基准的測試方法並不是完整應用程序測試的替代方法,更多情況下我們認為它更適用於一個功能模塊的自動測試。
批量,吞吐量和響應時間的測量方法
性能測試中的第二個重要的原則是引入多樣的測量方法來分析程序的性能。
-
批量執行所用時間的測量方法(耗時法),這是種簡單而快速有效的方法,通過測量完成特定任務所消耗的時間來測量整體性能。但是需要特別注意,假如所測試的應用程序中使用緩存數據技術來為了獲得更好的性能表現時,多次循環使用該方法可能無法完全反應性能問題。那么可以嘗試在初始狀態開始時應用耗時法做一次性能的評估,然后當緩存建立后,再次嘗試此方法。
-
吞吐量的測量方法,在一段時間內考察完成任務的數量的能力,被稱為吞吐量測量方法。在測試客戶服務器的應用程序時,吞吐量的測量意味着客戶端發送請求到服務器是沒有任何延遲的,當客戶端接收到響應后,應當立即發出新的請求,直到最終結束,統計客戶端完成任務的總數。這種相對理想的測試方法通常稱之為“Zero-think-time”。可是通常情況下,客戶端可能會有多個線程做同一件事情,吞吐量則意味着每秒鍾內所有客戶端的操作數,而不是測量的某一個時段內的所有操作總數。這種測量經常稱為每秒事務/(TPS),每秒請求 (RPS),或每秒操作數 (OPS)。
測試所有基於客戶端和服務器端應用程序都存在一種風險,客戶端不能以足夠快的速度發送數據到服務器端,這種情況的發生可能是由於客戶端此時沒有足夠的 CPU 資源去運行需要數量的線程,或者客戶端必須耗用更長的時間來處理當前的請求。這種情況下,實際上測量的是客戶端的性能,而非服務器的性能,與吞吐量測量方法是背道而馳的。其實這種風險是由每個客戶端線程處理任務的數量和硬件配置決定的。“Zero-think-time”在吞吐量測試中可能經常會遇見以上的情況,由於每個客戶端線程都需要處理大量的任務,因此吞吐量測試通常被應用於較少的客戶端線程程序。吞吐量測量方法也同樣適應用於帶有緩存技術的應用程序,尤其是當測試的數據是一個並不固定的情況下。
-
響應時間的測量方法,響應時間的測量方法是指客戶端發出一個請求后直到接收到服務器的響應返回后的時間消耗。響應時間測量方法不同於吞吐量測量方法,在響應時間測試過程中,客戶端線程可能會在操作的過程中某一時刻休眠,這就引出“think- time”這個關鍵詞,當“think- time”被引入到測試過程中,也就是意味着待處理任務量是固定的,測量的是服務器響應請求的速率是怎樣的。大多數情況下,響應時間的測量方法用來模擬用戶真實操作,從而測量應用程序的性能。
多變性
性能測試的第三個原則是理解測試結果如何隨時間改變,即使每一次測試使用同樣的數據,可能獲得的結果也是不同的。一些客觀因素,比如后台運行的進程,網絡的負載情況,這些都可能帶來測試結果的不同,所以在測試過程中存在着一些隨機性的因素。這就產生了一個問題: 當比較兩次運行得到的測試結果時,它們之間的差異是由回歸測試產生的,還是是隨機變化而導致的呢?
我們不能簡單的通過測量多次運行回歸測試的平均結果來評判性能的差異。這時我們可以使用統計分析的方法,假設兩種情況的平均值是一樣的,然后通過概率來判斷這樣的假設是成立的。如果假設不成立,那么就說明有很高的概率證明平均數存在差異。
在回歸測試中原始代碼被視為基線,新增加的代碼稱為樣本。三次運行基線和樣本,產生時間如表 1:
表 1. 三次運行基線和樣本結果
次數 | 基准 | 樣本 |
---|---|---|
1 |
1.0 |
0.5 |
2 |
0.8 |
1.25 |
3 |
1.2 |
0.5 |
平均 |
1 |
0.75 |
看起來樣本的平均值顯示有 25%的提升,可事實證明樣本和基線有相同性能的概率是 43%。也就是說 57%的概率存在性能上的不同。43%是基於 T 檢驗所得到的結果,T 檢驗主要用於樣本含量較小(例如 n<30),總體標准差σ未知的正態分布資料。t 檢驗是用 t 分布理論來推論差異發生的概率,從而比較兩個平均數的差異是否顯著。它與 z 檢驗、卡方檢驗並列。現在的 T 檢驗結果告訴我們這樣一個信息::57%概率顯示樣本和基線存在性能差異,差異最大值是 25%。也可以理解為性能差有 57%的置信度向理想發現發展,結果有 25%的改善。
在考量回歸測試的結果時,離開了統計分析的方法,而只關注平均值來做出判斷,含糊的理解這些數字的含義是不可取的。性能工程師的工作是看數據,理解這些概率,基於所有可用的數據確定在何處花時間。
盡早測試,經常測試
第四個原則就是工程師應該視性能測試是整個開發過程必要的部分,盡早進行性能測試,經常進行性能的測試,是一個好的工程師應該做到的。在代碼提交到代碼庫之前,就應當做性能測試,因為性能問題也會導致回歸測試失敗。所以提早發現問題會提高整個項目的質量,減小交付的風險性。
在一個典型的項目開發周期過程中,項目計划常常是建立一個功能提交的時間表,所有功能的開發必須要在某一個時間點全部提交到代碼庫中,在項目發布之前,所有的精力都致力於解決功能上的 Bug,那么很有可能在這個過程中發現性能問題,這會導致兩個問題產生:
-
開發人員在時間的約束下不得不提交代碼以滿足時間表,一旦發現出嚴重的性能問題他們會非常畏懼,所以開發人員在測試開始的早期解決性能問題能夠產生 1%的回歸測試代價,而如果開發人員一直在等待晚上的凍結功能開發的時候才開始檢查代碼將會導致 20%的回歸測試的代價。
-
任何為解決性能做出的修改都有可能帶來巨大的成本,有時不僅僅是代碼的修改,更有可能是軟件架構的修改。所以最好在軟件設計之時就充分的考慮到未來可能帶來的性能問題。
盡早測試性能有以下四點可作為指導:
-
提早准備測試用戶以及測試環境的設計和創建;
-
性能測試應該考慮盡量用腳本來完成;
-
通過性能監控工具盡量收集有可能得到的運行信息,為將來分析提供便利;
-
一定要在一個能真實模擬多數用戶的機器環境下進行性能測試。
總結
最后,基於我們講過的方法作為基礎,構建一個自動化的測試系統來收集測試過程中產生的各種信息,能夠很好的幫助我們分析發現性能瓶頸。
原文:https://www.ibm.com/developerworks/cn/java/j-lo-java-performance-testing/?cm_mmc=dwchina-_-homepage-_-csdn-_-learn