編寫高效能 Java 程式碼的最佳實踐

雁驚寒發表於2018-06-20

摘要:本文首先介紹了負載測試、基於APM工具的應用程式和伺服器監控,隨後介紹了編寫高效能Java程式碼的一些最佳實踐。最後研究了JVM特定的調優技巧、資料庫端的優化和架構方面的調整。以下是譯文。

介紹

在這篇文章中,我們將討論幾個有助於提升Java應用程式效能的方法。我們首先將介紹如何定義可度量的效能指標,然後看看有哪些工具可以用來度量和監控應用程式效能,以及確定效能瓶頸。

我們還將看到一些常見的Java程式碼優化方法以及最佳編碼實踐。最後,我們將看看用於提升Java應用程式效能的JVM調優技巧和架構調整。

請注意,效能優化是一個很寬泛的話題,而本文只是對JVM探索的一個起點。

效能指標

在開始優化應用程式的效能之前,我們需要理解諸如可擴充套件性、效能、可用性等方面的非功能需求。

以下是典型Web應用程式常用的一些效能指標:

  1. 應用程式平均響應時間
  2. 系統必須支援的平均併發使用者數
  3. 在負載高峰期間,預期的每秒請求數

這些指標可以通過使用多種監視工具監測到,它們對分析效能瓶頸和效能調優有著非常大的作用。

示例應用程式

我們將使用一個簡單的Spring Boot Web應用程式作為示例,在這篇文章中有相關的介紹。這個應用程式可用於管理員工列表,並對外公開了新增和檢索員工的REST API

我們將使用這個程式作為參考來執行負載測試,並在接下來的章節中監控各種應用指標。

找出效能瓶頸

負載測試工具和應用程式效能管理(APM)解決方案常用於跟蹤和優化Java應用程式的效能。要找出效能瓶頸,主要就是對各種應用場景進行負載測試,並同時使用APM工具對CPU、IO、堆的使用情況進行監控等等。

Gatling是進行負載測試最好的工具之一,它提供了對HTTP協議的支援,是HTTP伺服器負載測試的絕佳選擇。

Stackify的Retrace是一個成熟的APM解決方案。它的功能很豐富,對確定應用程式的效能基線很有幫助。 Retrace的關鍵元件之一是它的程式碼分析功能,它能夠在不減慢應用程式的情況下收集執行時資訊。

Retrace還提供了監視基於JVM應用程式的記憶體、執行緒和類的小部件。除了應用程式本身的指標之外,它還支援監視託管應用程式的伺服器的CPU和IO使用情況。

因此,像Retrace這樣功能全面的監控工具是解鎖應用程式效能潛力的第一步。而第二步則是在你的系統上重現真實使用場景和負載。

說起來容易,做起來難,而且瞭解應用程式當前的效能也非常重要。這就是我們接下來要關注的問題。

Gatling負載測試

Gatling的模擬測試指令碼是用Scala編寫的,但該工具還附帶了一個非常有用的圖形介面,可用於記錄具體的場景,並生成Scala指令碼。

在執行模擬指令碼之後,Gatling會生成一份非常有用的、可用於分析的HTML報告。

定義場景

在啟動記錄器之前,我們需要定義一個場景,表示使用者在瀏覽Web應用時發生的事情。

在我們的這個例子中,具體的場景將是“啟動200個使用者,每個使用者發出一萬個請求。”

配置記錄器

根據“Gatling的第一步”所述,用下面的程式碼建立一個名為EmployeeSimulation的scala檔案:

class EmployeeSimulation extends Simulation {
    val scn = scenario("FetchEmployees").repeat(10000) {
        exec(
          http("GetEmployees-API")
            .get("http://localhost:8080/employees")
            .check(status.is(200))
        )
    }
    setUp(scn.users(200).ramp(100))
}

執行負載測試

要執行負載測試,請執行以下命令:

$GATLING_HOME/bin/gatling.sh-sbasic.EmployeeSimulation

對應用程式的API進行負載測試有助於發現及其細微的並且難以發現的錯誤,如資料庫連線耗盡、高負載情況下的請求超時、因為記憶體洩漏而導致堆的高使用率等等。

監控應用程式

要使用Retrace進行Java應用程式的開發,首先需要在Stackify上申請免費試用賬號。然後,將我們自己的Spring Boot應用程式配置為Linux服務。我們還需要在託管應用程式的伺服器上安裝Retrace代理,按照這篇文章所述的操作即可。

Retrace代理和要監控的Java應用程式啟動後,我們就可以到Retrace儀表板上單擊AddApp按鈕新增應用了。新增應用完成之後,Retrace將開始監控應用程式了。

找到最慢的那個點

Retrace會自動監控應用程式,並跟蹤數十種常見框架及其依賴關係的使用情況,包括SQL、MongoDB、Redis、Elasticsearch等等。Retrace能幫助我們快速確定應用程式為什麼會出現如下效能問題:

  • 某個SQL語句是否會拖慢系統的速度?
  • Redis突然變慢了嗎?
  • 特定的HTTP Web服務宕了,還是變慢了?

例如,下面的圖形展示了在一段給定的時間內速度最慢的元件。

程式碼級別的優化

負載測試和應用程式監控對於確定應用程式的一些關鍵效能瓶頸非常有用。但同時,我們需要遵循良好的編碼習慣,以避免在對應用程式進行監控的時候出現過多的效能問題。

在下一章節中,我們將來看一些最佳實踐。

使用StringBuilder來連線字串

字串連線是一個非常常見的操作,也是一個低效率的操作。簡單地說,使用+=來追加字串的問題在於每次操作都會分配新的String。

下面這個例子是一個簡化了的但卻很典型的迴圈。前面使用了原始的連線方式,後面使用了構建器:

public String stringAppendLoop() {
    String s = "";
    for (int i = 0; i < 10000; i++) {
        if (s.length() > 0)
            s += ", ";
        s += "bar";
    }
    return s;
}

public String stringAppendBuilderLoop() {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < 10000; i++) {
        if (sb.length() > 0)
            sb.append(", ");
        sb.append("bar");
    }
    return sb.toString();
}

上面程式碼中使用的StringBuilder對效能的提升非常有效。請注意,現代的JVM會在編譯或者執行時對字串操作進行優化

避免遞迴

導致出現StackOverFlowError錯誤的遞迴程式碼邏輯是Java應用程式中另一種常見的問題。如果無法去掉遞迴邏輯,那麼尾遞迴作為替代方案將會更好。

我們來看一個頭遞迴的例子:

public int factorial(int n) {
    if (n == 0) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

現在我們把它重寫為尾遞迴:

private int factorial(int n, int accum) {
    if (n == 0) {
        return accum;
    } else {
        return factorial(n - 1, accum * n);
    }
}
public int factorial(int n) {
    return factorial(n, 1);
}

其他JVM語言(如Scala)已經在編譯器級支援尾遞迴程式碼的優化,當然,對於這種優化目前也存在著一些爭議。

謹慎使用正規表示式

正規表示式在很多場景中都非常有用,但它們往往具有非常高的效能成本。瞭解各種使用正規表示式的JDK字串方法很重要,例如String.replaceAll()String.split()

如果你不得不在計算密集的程式碼段中使用正規表示式,那麼需要快取Pattern的引用而避免重複編譯:

static final Pattern HEAVY_REGEX = Pattern.compile("(((X)*Y)*Z)*");

使用一些流行的庫,比如Apache Commons Lang也是一個很好的選擇,特別是在字串的操作方面。

避免建立和銷燬過多的執行緒

執行緒的建立和處置是JVM出現效能問題的常見原因,因為執行緒物件的建立和銷燬相對較重。

如果應用程式使用了大量的執行緒,那麼使用執行緒池會更加有用,因為執行緒池允許這些昂貴的物件被重用。

為此,Java的ExecutorService是執行緒池的基礎,它提供了一個高階API來定義執行緒池的語義並與之進行互動。

Java 7中的Fork/Join框架也值得提一下,因為它提供了一些工具來嘗試使用所有可用的處理器核心以幫助加速並行處理。為了提高並行執行效率,框架使用了一個名為ForkJoinPool的執行緒池來管理工作執行緒。

JVM調優

堆大小的調優

為生產系統確定合適的JVM堆大小並不是一件簡單的事情。要做的第一步是回答以下問題以預測記憶體需求:

  1. 計劃要把多少個不同的應用程式部署到單個JVM程式中,例如EAR檔案、WAR檔案、jar檔案的數量是多少?
  2. 在執行時可能會載入多少個Java類,包括第三方API的類?
  3. 估計記憶體快取所需的空間,例如,由應用程式(和第三方API)載入的內部快取資料結構,比如從資料庫快取的資料、從檔案中讀取的資料等等。
  4. 估計應用程式將建立的執行緒數。

如果沒有經過真實場景的測試,這些數字很難估計。

要獲得有關應用程式需求的最好最可靠的方法是對應用程式執行實際的負載測試,並在執行時跟蹤效能指標。我們之前討論的基於Gatling的測試就是一個很好的方法。

選擇合適的垃圾收集器

Stop-the-world(STW)垃圾收集的週期是影響大多數面向客戶端應用程式響應和整體Java效能的大問題。但是,目前的垃圾收集器大多解決了這個問題,並且通過適當的優化和大小的調整,能夠消除對收集週期的感知。

分析器、堆轉儲和詳細的GC日誌記錄工具對此有一定的幫助作用。再一次注意,這些都需要在真實場景的負載模式下進行監控。

有關不同垃圾收集器的更多資訊,請檢視這個指南

JDBC效能

關係型資料庫是Java應用程式中另一個常見的效能問題。為了獲得完整請求的響應時間,我們很自然地必須檢視應用程式的每一層,並思考如何讓程式碼與底層SQL DB進行互動。

連線池

讓我們從眾所周知的事實開始,即資料庫連線是昂貴的。 連線池機制是解決這個問題非常重要的第一步。

這裡建議使用HikariCP JDBC,這是一個非常輕量級(大約130Kb)並且速度極快的JDBC連線池框架。

JDBC批處理

持久化處理應儘可能地執行批量操作。 JDBC批處理允許我們在單次資料庫互動中傳送多個SQL語句。

這樣,無論是在驅動端還是在資料庫端,效能都可能得到顯著地提升。 * PreparedStatement*是一個非常棒的的批處理命令,一些資料庫系統(例如Oracle)只支援預處理語句的批處理。

另一方面,Hibernate則更加靈活,它允許我們只需修改一個配置即可快速切換為批處理操作

語句快取

語句快取是另一種提高持久層效能的方法,這是一種鮮為人知但又容易掌握的效能優化方法。

只要底層的JDBC驅動程式支援,你就可以在客戶端(驅動程式)或資料庫端(語法樹甚至執行計劃)中快取PreparedStatement

規模的縮放

資料庫複製和分片是提高吞吐量非常好的方法,我們應該充分利用這些經過實踐檢驗的架構模式,以擴充套件企業應用的持久層。

架構改進

快取

現在記憶體的價格很低,而且越來越低,從磁碟或通過網路來檢索資料的效能代價仍然很高。快取自然而然的變成了在應用程式效能方面不能忽視的關鍵。

當然,在應用的拓撲結構中引入一個獨立的快取系統確實會增加架構的複雜度,所以,應當充分利用當前使用的庫和框架現有的快取功能。

例如,大多數的持久化框架都支援快取。 Spring MVC等Web框架還可以使用Spring中內建的快取支援,以及基於ETags的強大的HTTP級快取。

橫向擴充套件

無論我們在單個例項中準備了多少硬體,都會有不夠用的時候。簡而言之,擴充套件有著天生的侷限性,當系統遇到這些問題時,橫向擴充套件是處理更多負載的唯一途徑。這一步肯定會相當的複雜,但卻是擴充套件應用的唯一辦法。

對大多數的現代框架和庫來說,這方面還是支援得很好的,而且會變得越來越好。 Spring生態系統有一個完整的專案集,專門用於解決這個特定的應用程式架構領域,其他大多數的框架也都有類似的支援。

除了能夠提升Java的效能,通過叢集進行橫向擴充套件也有其他的好處,新增新的節點能產生冗餘,並更好的處理故障,從而提高整個系統的可用性。

結論

在這篇文章中,我們圍繞著提升Java應用的效能探討了許多概念。我們首先介紹了負載測試、基於APM工具的應用程式和伺服器監控,隨後介紹了編寫高效能Java程式碼的一些最佳實踐。最後,我們研究了JVM特定的調優技巧、資料庫端的優化和架構方面的調整。

相關文章