Java 性能測試的四項原則

  • 2019 年 12 月 12 日
  • 筆記

來源:http://www.ltesting.net

引言

電腦軟體作為人類智慧的結晶,幫助我們在這個日新月異的社會中完成了大量工作。我們的日常生活中已經離不開軟體,玲琅滿目的軟體已經滲透到了我們生活的各個角落,令我們目不暇接。我們都希望軟體變得更好,運行處理的速度更快,在當今硬體性能突飛猛進的變革中,軟體性能的提升也是一個永不落伍的話題。軟體性能測試的實質,是從哲學的角度看問題,找出其內在聯繫,因果關係,形式內容關係,重疊關係等等。假如這些關係我們在分析過程中理清了,那麼性能測試問題就會變得迎刃而解。

在軟體開發過程中,性能測試往往在開發前期容易被忽略。直到有一天問題暴露後,開發人員被迫的直面這個問題,大多數情況下,這是令開發人員感覺到非常痛苦事情。所以在軟體開發前期以及開發過程中性能測試的考量是必要的,那麼具備相應理論知識和實踐方法也是一個優秀工程師所應當具備的素養,這裡我們概括有四項原則,這些原則可以幫助開發人員豐富、充實測試理論,系統的開展性能測試工作,從而獲得更有價值的結果。

實際項目中的性能測試才有意義

第一個原則就是性能測試只有在實際項目中實施才是有意義的,這樣才使得測試工作具有針對性,而且目標會更加明確。這個原則中有三個類別的基準可以指導開發人員度量性能測試的結果,但是每一種方法都有它的優點和劣勢,我們將結合實際例子,來總結闡述。

  • 微觀基準,可以理解為在某一個方法或某一個組件中進行的單元性能測試。比如檢測一個執行緒同步和一個非執行緒同步的方法運行時所需要的時間。或者對比創建一個單獨執行緒和使用一個執行緒池的性能開銷。或者對比執行一個演算法中的某一個迭代過程所需要的時間。當我們遇到這些情況時,我們常常會選擇做一個方法層面的性能測試。這些情況的性能測試,都可以嘗試使用微觀基準的方法進行性能測試。微觀基準看似編寫起來簡單快捷,但是編寫能夠準確反映性能問題的程式碼並非一件易事。接下來通過例子讓我們從程式碼中發現一些問題。這是一個單執行緒的程式片段,通過計算 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%的回歸測試的代價。
  • 任何為解決性能做出的修改都有可能帶來巨大的成本,有時不僅僅是程式碼的修改,更有可能是軟體架構的修改。所以最好在軟體設計之時就充分的考慮到未來可能帶來的性能問題。

儘早測試性能有以下四點可作為指導:

  • 提早準備測試用戶以及測試環境的設計和創建;
  • 性能測試應該考慮盡量用腳本來完成;
  • 通過性能監控工具盡量收集有可能得到的運行資訊,為將來分析提供便利;
  • 一定要在一個能真實模擬多數用戶的機器環境下進行性能測試。

總結

最後,基於我們講過的方法作為基礎,構建一個自動化的測試系統來收集測試過程中產生的各種資訊,能夠很好的幫助我們分析發現性能瓶頸。

星雲測試

http://www.teststars.cc

奇林軟體

http://www.kylinpet.com

聯合通測

http://www.quicktesting.net