最佳化 Java 應用程式

本指南說明如何最佳化以 Java 程式設計語言編寫的 Knative 服務服務,並提供背景資訊,協助您瞭解部分最佳化作業的取捨考量。本頁資訊是一般最佳化提示的補充內容,這些提示也適用於 Java。

傳統 Java 網頁應用程式的設計宗旨,是盡可能以高並行和低延遲的方式處理要求,因此通常是長時間執行的應用程式。JVM 本身也會透過 JIT 隨著時間最佳化執行程式碼,因此熱路徑會經過最佳化,應用程式也會隨著時間更有效率地執行。

這些傳統 Java 網頁型應用程式的許多最佳做法和最佳化措施,都與下列項目有關:

  • 處理並行要求 (包括以執行緒為基礎和非封鎖 I/O)
  • 使用連線集區和批次處理非重要函式,例如將追蹤記錄和指標傳送至背景工作,以減少回應延遲。

雖然許多傳統最佳化方式適用於長時間執行的應用程式,但在 Knative 服務中可能無法發揮同樣的效果,因為這類服務只會在主動處理要求時執行。本頁面將介紹幾種不同的 Knative 服務最佳化和取捨方式,有助於縮短啟動時間及減少記憶體用量。

最佳化容器映像檔

最佳化容器映像檔可縮短載入和啟動時間。您可以透過下列方式最佳化圖片:

  • 儘可能降低容器映像檔的大小
  • 避免使用巢狀程式庫封存 JAR
  • 使用 Jib

儘可能降低容器映像檔的大小

如要進一步瞭解這個問題,請參閱減少容器的一般提示頁面。一般提示頁面建議只保留容器映像檔中必要的內容,舉例來說,請確保容器映像檔不含下列項目:

  • 原始碼
  • Maven 建構構件
  • 建立工具
  • Git 目錄
  • 未使用的二進位檔/公用程式

如果您是從 Dockerfile 內建構程式碼,請使用 Docker 多階段建構,確保最終容器映像檔只包含 JRE 和應用程式 JAR 檔案本身。

避免巢狀程式庫封存 JAR

部分熱門架構 (例如 Spring Boot) 會建立應用程式封存 (JAR) 檔案,其中包含其他程式庫 JAR 檔案 (巢狀 JAR)。這些檔案需要在啟動時解壓縮,並可加快 Knative 服務的啟動速度。盡可能使用外部化程式庫建立精簡 JAR:使用 Jib 將應用程式容器化,即可自動執行這項操作

使用 Jib

使用 Jib 外掛程式建立最小容器,並自動扁平化應用程式封存檔。Jib 適用於 Maven 和 Gradle,且可直接用於 Spring Boot 應用程式。部分應用程式架構可能需要額外的 Jib 設定。

JVM 最佳化

為 Knative 服務服務最佳化 JVM,可提升效能並減少記憶體用量。

使用可因應容器的 JVM 版本

在 VM 和機器中,對於 CPU 和記憶體配置,JVM 會從已知位置 (例如 Linux 中的 /proc/cpuinfo/proc/meminfo) 瞭解可使用的 CPU 和記憶體。不過,在容器中執行時,CPU 和記憶體限制會儲存在 /proc/cgroups/... 中。舊版 JDK 會繼續在 /proc 中尋找,而不是 /proc/cgroups,這可能會導致 CPU 和記憶體用量超出分配量。這可能會導致:

  • 執行緒數量過多,因為執行緒集區大小是由 Runtime.availableProcessors() 設定
  • 預設堆積上限超過容器記憶體上限。JVM 會積極使用記憶體,然後再進行垃圾收集。這很容易導致容器超出容器記憶體限制,並遭到 OOMKilled。

因此,請使用可因應不同容器的 JVM 版本。OpenJDK 版本大於或等於 8u192 時,預設會支援容器。

瞭解 JVM 記憶體用量

JVM 記憶體用量由原生記憶體用量和堆積用量組成。應用程式工作記憶體通常位於堆積中。堆積大小受限於「最大堆積」設定。使用 Knative 服務 256MB RAM 執行個體時,您無法將所有 256MB 指派給 Max Heap,因為 JVM 和 OS 也需要原生記憶體,例如執行緒堆疊、程式碼快取、檔案控制代碼、緩衝區等。如果應用程式遭到 OOMKilled,且您需要瞭解 JVM 記憶體用量 (原生記憶體 + 堆積),請開啟原生記憶體追蹤功能,在應用程式成功結束時查看用量。如果應用程式遭到 OOMKilled,就無法列印資訊。在這種情況下,請先以更多記憶體執行應用程式,確保應用程式能順利產生輸出內容。

您無法透過 JAVA_TOOL_OPTIONS 環境變數開啟原生記憶體追蹤功能,您需要在容器映像檔進入點新增 Java 啟動指令列引數,以便使用這些引數啟動應用程式:

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

您可以根據要載入的類別數量,估算原生記憶體用量。建議使用開放原始碼的 Java Memory Calculator 估算記憶體需求。

關閉最佳化編譯器

根據預設,JVM 有多個 JIT 編譯階段。雖然這些階段可隨著時間提升應用程式效率,但也會增加記憶體用量負擔,並延長啟動時間。

如果是短期執行的無伺服器應用程式 (例如函式),建議關閉最佳化階段,以長期效率換取縮短啟動時間。

如果是 Knative Serving 服務,請設定環境變數:

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

使用應用程式類別資料分享功能

如要進一步縮短 JIT 時間並減少記憶體用量,請考慮使用應用程式類別資料共用 (AppCDS),以封存檔形式共用預先編譯的 Java 類別。啟動相同 Java 應用程式的另一個執行個體時,可以重複使用 AppCDS 封存檔。JVM 可以重複使用封存檔中預先計算的資料,縮短啟動時間。

使用 AppCDS 時,請注意下列事項:

  • 如要重複使用 AppCDS 封存檔,必須使用與原始封存檔完全相同的 OpenJDK 發行版本、版本和架構重新產生。
  • 您必須至少執行一次應用程式,才能產生要共用的類別清單,然後使用該清單產生 AppCDS 封存檔。
  • 類別的涵蓋範圍取決於應用程式執行期間執行的程式碼路徑。如要提高涵蓋範圍,請以程式輔助方式觸發更多程式碼路徑。
  • 應用程式必須順利結束,才能產生這份類別清單。請考慮實作應用程式標記,用於指出產生 AppCDS 封存檔,因此可以立即結束。
  • 只有以與產生封存檔完全相同的方式啟動新執行個體時,才能重複使用 AppCDS 封存檔。
  • AppCDS 封存檔僅適用於一般 JAR 檔案套件,無法使用巢狀 JAR。

使用遮蔽 JAR 檔案的 Spring Boot 範例

Spring Boot 應用程式預設會使用巢狀 uber JAR,這不適用於 AppCDS。因此,如果您使用 AppCDS,就必須建立遮蔽的 JAR。舉例來說,使用 Maven 和 Maven Shade 外掛程式:

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

如果遮蔽的 JAR 包含所有依附元件,您可以在容器建構期間使用 Dockerfile 產生簡單的封存檔:

# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM adoptopenjdk:11-jre-hotspot

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

關閉課程驗證

JVM 將類別載入記憶體以供執行時,會驗證類別是否未遭竄改,且沒有惡意編輯或損毀。如果您的軟體交付管道值得信賴 (例如,您可以驗證每個輸出內容),且您完全信任容器映像檔中的位元碼,而應用程式不會從任意遠端來源載入類別,則可以考慮關閉驗證。如果啟動時載入大量類別,關閉驗證功能可能會提升啟動速度。

如果是 Knative Serving 服務,請設定環境變數:

JAVA_TOOL_OPTIONS="-noverify"

縮減執行緒堆疊大小

大多數 Java 網頁應用程式都是以每個連線一個執行緒為基礎。每個 Java 執行緒都會耗用原生記憶體 (不在堆積中)。這就是所謂的執行緒堆疊,每個執行緒預設為 1 MB。如果應用程式處理 80 個並行要求,則可能至少有 80 個執行緒,這表示使用的執行緒堆疊空間為 80 MB。這項記憶體大小不包含堆積大小。預設值可能大於必要值。您可以縮減執行緒堆疊大小。

如果減少太多,就會看到 java.lang.StackOverflowError。您可以分析應用程式,找出要設定的最佳執行緒堆疊大小。

如果是 Knative Serving 服務,請設定環境變數:

JAVA_TOOL_OPTIONS="-Xss256k"

減少執行緒

您可以減少執行緒數量、使用非封鎖反應式策略,以及避免背景活動,藉此最佳化記憶體。

減少執行緒數量

每個 Java 執行緒都可能因執行緒堆疊而增加記憶體用量。Knative 服務最多可處理 80 個並行要求。使用每個連線一個執行緒的模型時,最多需要 80 個執行緒來處理所有並行要求。大多數網路伺服器和架構都允許您設定執行緒和連線數量上限。舉例來說,在 Spring Boot 中,您可以在 applications.properties 檔案中設定連線數上限:

server.tomcat.max-threads=80

撰寫非阻塞反應式程式碼,最佳化記憶體和啟動程序

如要真正減少執行緒數量,請考慮採用非封鎖反應式程式設計模型,這樣在處理更多並行要求時,執行緒數量就能大幅減少。Spring Boot with Webflux、Micronaut 和 Quarkus 等應用程式架構支援反應式網頁應用程式。

Spring Boot with Webflux、Micronaut、Quarkus 等反應式架構的啟動時間通常較快。

如果您繼續在非封鎖架構中編寫封鎖程式碼,Knative 服務的輸送量和錯誤率就會大幅降低。這是因為非封鎖架構只會有幾個執行緒,例如 2 個或 4 個。如果程式碼會封鎖,則只能處理極少量的並行要求。

這些非封鎖架構也可能會將封鎖程式碼卸載至無界限的執行緒集區,也就是說,雖然可以接受許多並行要求,但封鎖程式碼會在新的執行緒中執行。如果執行緒以無界方式累積,CPU 資源就會耗盡,並開始出現顛簸現象。延遲時間會受到嚴重影響。如果使用非封鎖架構,請務必瞭解執行緒集區模型,並據此設定集區界限。

避免背景活動

如果執行個體不再收到要求,Knative 服務會節流執行個體 CPU。在 Knative 服務中執行時,有背景工作的傳統工作負載需要特別考量。

舉例來說,如果您收集應用程式指標,並在背景批次處理指標,以便定期傳送,那麼 CPU 受到節流時,這些指標就不會傳送。如果應用程式持續收到要求,您可能較少看到問題。如果應用程式的每秒查詢次數偏低,背景工作可能永遠不會執行。

以下是幾個需要注意的常見背景模式:

  • JDBC 連線集區 - 清理和連線檢查通常會在背景執行
  • 分散式追蹤傳送者 - 分散式追蹤通常會分批傳送,或在緩衝區已滿時定期傳送。
  • 指標傳送者 - 指標通常會批次處理,並定期在背景傳送。
  • 如果是 Spring Boot,任何使用 @Async 註解加註的方法
  • 計時器 - 任何以計時器為準的觸發條件 (例如CPU 受到節流時,ScheduledThreadPoolExecutor、Quartz 或 @Scheduled Spring 註解可能無法執行。
  • 訊息接收者 - 例如 Pub/Sub 串流提取用戶端、JMS 用戶端或 Kafka 用戶端,通常會在背景執行緒中執行,不需要要求。如果應用程式沒有任何要求,這些功能就不會運作。在 Knative serving 中,不建議採用這種方式接收訊息。

應用程式最佳化

在 Knative 服務程式碼中,您也可以進行最佳化,加快啟動時間並減少記憶體用量。

減少啟動工作

傳統的 Java 網頁應用程式在啟動期間可能需要完成許多工作,例如預先載入資料、暖機快取、建立連線集區等。如果這些工作依序執行,速度可能會很慢。不過,如要平行執行,請增加 CPU 核心數量。

Knative serving 目前會傳送實際使用者要求,觸發冷啟動執行個體。如果要求指派給新啟動的執行個體,使用者可能會遇到長時間延遲。Knative serving 目前沒有「完備性」檢查,可避免將要求傳送至未準備就緒的應用程式。

使用連線集區

如果您使用連線集區,請注意連線集區可能會在背景逐出不必要的連線 (請參閱「避免執行背景工作」)。如果應用程式的每秒查詢次數較低,且可容許高延遲時間,請考慮為每個要求開啟及關閉連線。如果應用程式的 QPS 較高,只要有有效要求,背景逐出作業就會持續執行。

在這兩種情況下,應用程式的資料庫存取權都會受到資料庫允許的最大連線數限制。計算每個 Knative 服務執行個體可建立的最大連線數,並設定 Knative 服務執行個體數量上限,確保執行個體數量上限乘以每個執行個體的連線數,小於允許的最大連線數。

使用 Spring Boot

如果您使用 Spring Boot,請考慮下列最佳化措施

使用 Spring Boot 2.2 以上版本

從 2.2 版開始,Spring Boot 已大幅最佳化啟動速度。如果使用的 Spring Boot 版本低於 2.2,建議您升級,或手動套用個別最佳化設定

使用延遲初始化

在 Spring Boot 2.2 以上版本中,可以開啟全域延遲初始化旗標。這會提升啟動速度,但第一個要求的延遲時間可能會較長,因為需要等待元件首次初始化。

您可以在「application.properties」中開啟延遲初始化:

spring.main.lazy-initialization=true

或使用環境變數:

SPRING_MAIN_LAZY_INITIALIZATIION=true

不過,如果您使用最少執行個體數,延遲初始化就沒有幫助,因為初始化作業應該會在最少執行個體數啟動時發生。

避免掃描課程

類別掃描會導致 Knative serving 額外讀取磁碟,因為在 Knative serving 中,磁碟存取速度通常比一般機器慢。請確保元件掃描受到限制或完全避免。建議使用 Spring Context Indexer 預先產生索引。這項做法是否能提升啟動速度,取決於您的應用程式。

舉例來說,在 Maven pom.xml 中,新增索引器依附元件 (實際上是註解處理器):

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

在非正式版中使用 Spring Boot 開發人員工具

如果您在開發期間使用 Spring Boot 開發人員工具,請確保該工具不會封裝在正式版容器映像檔中。如果您在建構 Spring Boot 應用程式時,沒有使用 Spring Boot 建構外掛程式 (例如使用 Shade 外掛程式,或使用 Jib 容器化),就可能發生這種情況。

在這些情況下,請確保建構工具明確排除 Spring Boot 開發人員工具。或者,明確停用 Spring Boot 開發人員工具

後續步驟

如需更多提示,請參閱