Java 應用程序中的易變性(通常是由暫停或延遲導致的,其發生時間無法預測)可能在整個軟件棧中發生。延遲可由以下因素引起:
硬件(緩存期間)
固件(處理 CPU 溫度數據等系統管理中斷的過程中)
操作系統(響應一個中斷或執行定期調度的後台活動)
在相同系統上運行的其他程序
JVM(垃圾收集、即時編譯和類加載)
Java 應用程序本身
很難在較高級別上補償較低級別上的延遲,所以,如果您試圖僅在應用程序級別解決易變性,您可能只是轉移了 JVM 或 OS 延遲,並沒有解決實際問題。幸運的是,較低級別的延遲可能比較高級別上的延遲相對短一些,所以只有在降低易變性的需求非常強烈時,才需要深入到比 JVM 或 OS 更低的級別上。如果需求不是那麼強烈,您可以將精力集中在 JVM 級別上或應用程序中。
實時 Java 提供了必要的工具來堵截 JVM 和應用程序中的易變性源頭,交付用戶要求的服務質量。本文詳細介紹 JVM 和應用程序級別上的易變性源頭,介紹可用於減輕其影響的工具和技術。然後介紹一個簡單的 Java 服務器應用程序來演示其中一些概念。
解決易變性源頭
JVM 中的易變性主要源自於 Java 語言的動態特性:
內存絕不會被應用程序顯式釋放,而是被垃圾收集器定期回收。
類在被應用程序首次使用時才進行解析。
本機代碼在應用程序運行時由即時(JIT)編譯器編譯(而且可以重新編譯),基於經常調用的類和方法。
在 Java 應用程序級別上,線程管理是與易變性相關的關鍵區域。
垃圾收集暫停
當垃圾收集器回收程序不再使用的內存時,它可以停止任何應用程序線程。(這種類型的收集器稱為 Stop-the-world 或 STW 收集器)。或者它可以與應用程序同時執行自己的一些工作。無論是哪種情況,垃圾收集器需要的資源都不能供應用程序使用,所以,眾所周知,垃圾收集(GC)是 Java 應用程序性能中的暫停和易變性的源頭。盡管許多 GC 模型都具有自己的優缺點,但當應用程序的目標是縮短 GC 暫停時,兩個主要的選擇將是分代(generational)和實時 收集器。
分代收集器將堆組織為至少兩個部分,這兩個部分通常稱為新 和舊(有時稱為保留)空間。新對象始終在新空間中分配。當新空間耗盡空閒內存時,將僅在該空間中進行垃圾收集。使用相對較小的新空間可能時 GC 周期更短。在多次新空間垃圾收集過程中存留下來的對象會被提升到舊空間中。舊空間垃圾收集發生的頻率通常比新空間垃圾收集低得多,但是由於舊空間比新空間大得多,所以這些 GC 周期可能長得多。分代垃圾收集器提供了相對較短的平均 GC 暫停時間,但是舊空間收集的開銷可能導致這些暫停時間的標准偏差非常大。對於活動數據集不會經常更改,但會產生大量垃圾的應用程序而言,分代收集器是最有效的。在這種場景中,舊空間收集極少發生,因此 GC 暫停時間取決於短的新空間收集時間。
與分代收集器相反,實時垃圾收集器會控制自身的行為,以顯著縮短 GC 周期的長度(通過在應用程序空閒時執行周期)或減輕這些周期對應用程序性能的影響(通過基於與應用程序之間的一種 “契約”,以更小的增量執行工作)。使用這類收集器,您可以預測完成特定任務的最遭情形。例如,IBM® WebSphere® Real-Time JVM 中的垃圾收集器將 GC 周期劃分為較小的工作片段(稱為 GC 限額),這些限額可以增量方式完成。對限額的調度對應用程序性能的影響極小,其延遲可低至幾百微秒,通常小於 1 毫秒。為了達到這種延遲級別,垃圾收集器必須能夠計劃自己的工作,方法是引入應用程序利用契約 的概念。此契約管理允許 GC 中斷應用程序執行工作的頻率。例如,默認的利用契約為 70%,也就是在實時操作系統上運行時,僅允許 GC 使用每 10 毫秒中的至多 3 毫秒,典型的暫停時間大約為 500 微秒。(參見 “實時 Java,第 4 部分: 實時垃圾收集”,獲取對 IBM WebSphere Real Time 垃圾收集器操作的詳細介紹)。
在實時垃圾收集器上運行應用程序時,堆大小和應用程序利用率是要考慮的重要調優選項。隨著應用程序利用率的增加,垃圾收集器完成其工作的時間會更短,因此需要更大的堆來確保 GC 周期可以增量式地完成。如果垃圾收集器無法跟上分配速度,GC 將采用同步收集。
例如,與在使用分代垃圾收集器的 JVM 上(未提供利用契約)運行時相比,在 IBM WebSphere Real-Time JVM 上運行的應用程序(具有 70% 的默認應用程序利用契約)默認需要更大的堆。由於實時垃圾收集器控制著 GC 暫停時間的長度,所以增加堆大小會降低 GC 頻率,不會延長各次暫停時間。另一方面,在非實時垃圾收集器中,增加堆大小通常會降低 GC 周期的頻率,這會降低垃圾收集器的總體影響。當發生垃圾收集時,暫停時間通常會更長(因為需要檢查更大的堆)。
在 IBM WebSphere Real Time JVM 中,可以使用 -Xmx<size> 選項調整堆大小。例如,-Xmx512m 指定堆大小為 512MB。還可以調整應用程序利用率。例如,-Xgc:targetUtilization=80 將利用率設置為 80%。
Java 類加載暫停
Java 語言規范要求在應用程序首次引用類時對類進行解析、加載、驗證和初始化。如果對一個類 C 的首次引用發生在時間關鍵型操作期間,那麼解析、驗證、加載和初始化 C 的時間可能導致執行操作的時間比預期更長。由於加載 C 涉及到驗證該類(這可能需要加載其他類),所以 Java 應用程序為了能夠首次使用特定類而發生的總延遲可能比預期長很多。
為什麼類只能在應用程序執行期間首次被引用?很少執行的路徑是加載新類的一個常見原因。例如,清單 1 中的代碼包含一個可能很少發生的 if 條件。(為了簡單起見,我們盡可能省略了本文中所有清單中的異常和錯誤處理)。
清單 1. 用於加載新類的很少執行的條件示例
Iterator<MyClass> cursor = list.iterator();
while (cursor.hasNext()) {
MyClass o = cursor.next();
if (o.getID() == 17) {
NeverBeforeLoadedClass o2 = new NeverBeforeLoadedClass(o);
// do something with o2
}
else {
// do something with o
}
}
異常類是只能在應用程序執行期間加載的類的另一個例子,因為異常在理想情況下(但不一定會遇到這種情況)很少發生。由於異常通常難以快速處理,所以加載額外的類的附加開銷可能使操作延遲超出重要阈值。一般而言,應該盡可能避免在時間關鍵型操作期間拋出異常。
也可以在 Java 類庫中使用某些服務(比如反射)時加載新類。反射類的底層實現會動態生成將加載到 JVM 中的新類。在時間敏感型代碼中反復使用反射類可能導致持續不斷的類加載活動,這會引起延遲。使用 -verbose:class 選項是檢測正在被創建的類的最佳方式。或許避免在程序執行期間創建這些類的最佳方式在於,避免在應用程序的時間關鍵型部分使用反射服務來從字符串映射類、字段或方法。相反,在應用程序執行過程中盡早調用這些服務並存儲結果共以後使用,這可以避免在不需要時動態創建大部分這樣的類。
一種在應用程序的時間敏感型部分避免類加載延遲通用技術是,在應用程序啟動或初始化期間預先加載類。盡管這個預加載步驟帶來一定的啟動延遲(改善一個指標通常會對其他指標帶來負面影響),但是如果小心使用,這一步可以在以後消除不需要的類加載過程。這種啟動流程很容易實現,如清單 2 所示:
清單 2. 從一組類中以受控方式加載類
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
String className = classIt.next();
try {
Class clazz = Class.forName(className);
String n=clazz.getName();
} catch (Exception e) {
System.err.println("Could not load class: " + className);
System.err.println(e);
}
注意 clazz.getName() 調用,它強制執行類初始化。構建類列表需要在應用程序運行時從其中收集信息,或者使用一個實用工具來確定應用程序將加載哪些類。例如,可以使用 -verbose:class 選項在程序運行時捕獲輸出。清單 3 顯示了在使用 IBM WebSphere Real Time 產品時,此命令的可能輸出:
清單 3. 使用 -verbose:class 命令運行 java 的部分輸出
...
class load: java/util/zip/ZipConstants
class load: java/util/zip/ZipFile
class load: java/util/jar/JarFile
class load: sun/misc/JavaUtilJarAccess
class load: java/util/jar/JavaUtilJarAccessImpl
class load: java/util/zip/ZipEntry
class load: java/util/jar/JarEntry
class load: java/util/jar/JarFile$JarFileEntry
class load: java/net/URLConnection
class load: java/net/JarURLConnection
class load: sun/net/www/protocol/jar/JarURLConnection
...
通過存儲應用程序在執行時將加載的類列表,並使用該列表填充 清單 2 中顯示的循環的類名稱列表,可以確保在應用程序開始運行之前加載這些類。當然,不同時刻執行應用程序可能加載不同的路徑,所以一次執行的列表可能並不完整。出於此原因,如果應用程序正在開發之中,新編寫或修改的代碼可能依賴於未包含在列表中的新類(或者雖然包含在列表中,但不再需要的類)不幸的是,維護類列表可能是使用此方法預加載類的非常模麻煩的一部分。如果使用此方法,請記住,-verbose:class 輸出的類名稱與 -verbose:class does not match the format that's needed by Class.forName() 需要的格式並不匹配:詳細輸出中使用正斜槓將類包分開,而 Class.forName() 期望用句點來分開它們。
對於存在類加載問題的應用程序,可以借助一些工具來管理預加載,包括 Real Time Class Analysis Tool (RATCAT) 和 IBM Real Time Application Execution Optimizer for Java(參見 參考資料)。這些工具能夠在一定程度上自動識別要預加載的正確類列表,以及將類預加載代碼合並到應用程序中。
JIT 代碼編譯暫停
JIT 優化示例
JIT 優化的一個初始示例是數組副本專業化。對於經常執行的方法,JIT 編譯器可以分析特定數組副本調用的長度,確定是否有些長度是相同的。在分析調用一段時間之後,JIT 編譯器可以發現數組副本長度幾乎總是為 12 字節。了解這一點之後,JIT 可以為數組副本生成一個非常快速的路徑,以對目標處理器最有效的方式直接復制所需的 12 字節。JIT 插入一個條件檢查,查看長度是否為 12 字節,如果是,則執行這個極其高效的快速路徑副本。如果長度不為 12,則生成一條不同路徑來以默認方式執行副本,這可能需要更長的時間,因為它需要處理任何數組長度。如果應用程序中的大多數操作都是用快速路徑,那麼常見的操作延遲將基於直接復制這 12 字節所需的時間。但是與通用操作計時相比,需要具有不同長度的副本的任何操作都將被延遲。
JVM 中的第三個延遲來源是 JIT 編譯器。在應用程序運行時,它將程序的方法從 javac 生成的字節碼翻譯為運行應用程序的 CPU 的本機指令。JIT 編譯器是 Java 平台取得成功的基礎,因為它實現了很高的應用程序性能,而且沒有犧牲 Java 字節碼的平台獨立性。在過去 10 多年中,JIT 編譯器工程師一直在盡力改善 Java 應用程序的吞吐量和延遲。
不幸的是,這類改進帶來了 Java 應用程序性能的暫停,因為 JIT 編譯器從應用程序 “偷取” 了一些周期來為特定方法生成已編譯(或要重新編譯)的代碼。取決於被編譯方法的大小和 JIT 選擇編譯它的積極程度,編譯時間可能小於 1 微妙,也可能大於 1 秒(對於 JIT 編譯器發現的非常大的方法,這類方法會占用應用程序的大量執行時間)。但是 JIT 編譯器本身的行為並不是應用程序計時中的意外偏差的唯一來源。因為 JIT 編譯器工程師將絕大部分精力都用在平均性能上,以最有效地改進吞吐量和延遲性能,所以 JIT 編譯器通常執行多種優化,這些優化 “通常” 是正確的或 “在大部分情況下” 具有很高的性能。一般而言,這些優化非常有效,並且開發了啟發方法來使優化很好地符合最常見的應用程序運行場景。但是,在一些情形下,這類優化可能帶來嚴重的性能易變性。
除了預加載所有類,還可以請求 JIT 編譯器在應用程序初始化期間顯式編譯這些類的方法。清單 4 擴展了 清單 2 中的類預加載代碼,以控制方法編譯:
清單 4. 受控的方法編譯
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
String className = classIt.next();
try {
Class clazz = Class.forName(className);
String n = clazz.name();
java.lang.Compiler.compileClass(clazz);
} catch (Exception e) {
System.err.println("Could not load class: " + className);
System.err.println(e);
}
}
java.lang.Compiler.disable(); // optional
這段代碼將使 JIT 編譯器加載一組類並編譯所有這些類的方法。最後一行為應用程序的其余執行部分禁用 JIT 編譯器。
與允許 JIT 編譯器自由選擇將編譯哪些方法相比,此方法通常會導致較低的總吞吐量或延遲性能。因為在 JIT 編譯器運行之前不必調用方法,JIT 編譯器僅擁有少量與如何最佳地優化它要編譯的方法相關的信息,所以這些方法的執行速度會更慢。而且,由於編譯器被禁用,不會重新編譯任何方法,即使這些方法占用了程序執行時間的一大部分,所以,大多數現代 JVM 中使用的這類自適應 JIT 編譯框架將不起作用。要減少大量由 JIT 編譯器引起的暫停,不是必須使用 Compiler.disable() 命令,但是保留下來的暫停將是在應用程序的熱方法上執行的更加頻繁的重編譯,這通常需要更長的編譯時間,對應用程序計時的潛在影響更大。在調用 disable() 方法時,可能不會卸載特定 JVM 中的 JIT 編譯器,所以在應用程序運行時階段,仍然可能消耗內存、加載共享庫以及出現其他 JIT 編譯器工件。
當然,本機代碼編譯對各個應用程序的性能的影響程度不盡相同。確定編譯是否存在問題的最好方法是打開詳細輸出,確定編譯發生的時間,進而確定它們是否影響應用程序計時。例如,使用 IBM WebSphere Real Time JVM,您可以使用 -Xjit:verbose 命令行選項打開 JIT 詳細日志。
除了這種預加載和早期編譯方法,應用程序作者無法執行太多操作來避免由 JIT 編譯器引起的暫停,但特定於供應商的 JIT 編譯器命令行選項除外(一種充滿風險的方法)。JVM 供應商很少在生產場景中支持這些選項。由於它們不是默認的配置,所以供應商沒有很好地測試它們,它們在各個版本中的名稱和含義也可能不同。
但是,一些替代的 JVM 可以為您提供一些選項,具體取決於 JIT 編譯器引起的暫停對您有多重要。設計用於硬實時 Java 系統的實時 JVM 提供了更多選項。例如,IBM WebSphere Real Time For Real Time Linux® JVM 具有 5 種代碼編譯戰略,可以將它們與各種功能結合使用來減少 JIT 編譯器暫停:
默認 JIT 編譯,JIT 編譯器線程在較低優先級上運行
較低優先級上的默認 JIT 編譯,在最初使用了提前(Ahead-of-time,AOT)編譯代碼
在啟動時受程序控制的編譯,啟用了重新編譯
在啟動時受程序控制的編譯,禁用了重新編譯
僅 AOT 編譯代碼
這些選項根據預期的吞吐量/延遲性能級別和預期的暫停時間的降序來排列。默認的 JIT 編譯選項使用在最低優先級(可能低於應用程序線程)上運行的 JIT 編譯線程,該選項提供了最高的預期吞吐量性能,但也可能顯示由(這 5 個選項的)JIT 編譯引起的最多的暫停。前兩個選項使用異步編譯,這意味著如果應用程序線程嘗試調用被選擇用於重新編譯的方法,那麼該線程無需等到編譯完成。最後一個選項具有最低的預期吞吐量/延遲性能,但沒有由 JIT 編譯器引起的暫停,因為此方案完全禁用了 JIT 編譯器。
IBM WebSphere Real Time for Real Time Linux JVM 提供了一個稱為 admincache 的工具,支持創建包含來自一組 JAR 文件的類文件的共享類緩存,也可以在相同代碼中存儲這些類的提前編譯代碼。可以在您的 java 命令行設置一個選項,以將存儲在共享類緩存中的類從緩存加載到 JVM 中,以及在加載類時將 AOT 代碼自動加載到 JVM 中。類似於 清單 2 中所示的類預加載循環已足夠確保您充分獲取提前編譯代碼的優勢。參見 參考資料,獲取 admincache 文檔的鏈接。
線程管理
在交易服務器等多線程應用程序中,控制線程的執行對於消除交易時間的易變性至關重要。盡管 Java 編程語言定義了一種線程模型,該模型包含線程優先級的概念,但實際 JVM 中的線程行為主要由實現定義,包含 Java 程序可以依賴的許多規則。例如,盡管可以為 Java 線程分配 10 個線程優先級中的一個,但這些應用程序級優先級到操作系統優先級值之間的映射是由實現定義的。(對於 JVM,將所有 Java 線程優先級映射到相同的操作系統優先級值是一種非常有效的方法)。出於此原因,Java 線程的調度策略也是由實現定義的,但是通常在最終被分成一些時間段,所以即使是高優先級的線程最終也會與低優先級線程共享 CPU 資源。與較低優先級線程共享資源可能導致在調度較高優先級線程時出現延遲,以讓其他任務可以獲得一個時間片段。請記住,線程獲取的 CPU 量不僅依賴於優先級,還依賴於需要調度的線程總數。除非可以嚴格控制在任何給定時間有多少活動線程,否則,即使是最高優先級線程用於執行操作的時間也可能出現相對較大的差異。
所以,即使您為工作者線程指定最高的 Java 線程優先級(java.lang.Thread.MAX_PRIORITY),也不會提供與系統上較低優先級任務之間太高的隔離級別。不幸的是,除了使用固定的工作線程集(不繼續分配新線程,而依賴於 GC 收集未使用的線程,或者擴大和縮小線程池)並嘗試將在應用程序運行時系統上低優先級活動的數量減到最少,您無法采取其他更多措施,因為標准 Java 線程模型未提供控制線程行為所需的工具。在這裡,即使是軟實時 JVM(如果它依賴於標准 Java 線程模型)也不能經常提供幫助。
但是,與標准 Java 相比,支持 Real Time Specification for Java (RTSJ) 的硬實時 JVM(比如 IBM WebSphere Real Time for Real Time Linux V2.0 或 Sun 的 RTS 2)可以提供大大改進的線程行為。在對標准 Java 語言和 VM 規范的增強中,RTSJ 引入了兩類新的線程 RealtimeThread 和 NoHeapRealtimeThread,它們的定義比標准 Java 線程模型要嚴格得多。這些線程類型提供了真正基於搶占優先級的調度機制:如果需要執行高優先級任務並且處理器核心上目前計劃執行一個較低優先級任務,那麼該較低優先級任務將被搶占,以便高優先級任務可以執行。
大部分實時 OS 都能夠在數十微秒內執行這種搶占機制,這僅會影響到具有極高的計時需求的應用程序。兩種新線程類型都是用 FIFO(先進先出)調度策略,而不是在大部分 OS 上運行的 JVM 所使用的熟悉的循環調度策略。循環調度和 FIFO 調度策略之間最明顯的區別在於,在具有相同優先級的線程中,一旦計劃讓一個線程繼續執行,那麼它只有在遇到阻塞或資源釋放處理器時才會停止。此模型的優點在於,執行特定任務的時間更加容易預測,因為處理器不是共享的,即使有多個具有相同優先級的任務。出於此原因,如果您可以通過消除同步和 I/O 優先級來使線程不受阻塞,在線程啟動之後,OS 將不會干預它。但是,在實際中,消除所有同步非常困難,所以很難為實際任務實現這種理想目標。盡管如此,FIFO 調度機制為嘗試減少延遲的應用程序設計師提供了一項重要幫助。
您可以將 RTSJ 想作一個大型工具箱,它可以幫助您設計具有實時行為的應用程序,您可以僅使用兩三個工具,或者可以重新編寫應用程序來提供高度可預測的性能。修改應用程序來使用 RealtimeThread 通常不會很困難,甚至可以在不訪問實時 JVM 來編譯 Java 代碼的情況下實現這一點,但是請小心使用 Java 反射服務。
但是,要利用 FIFO 調度機制的易變性優勢,可能需要進一步更改您的應用程序。FIFO 調度的工作方式與循環調度不同,這些區別可能導致一些 Java 程序掛起。例如,如果應用程序依賴於 Thread.yield() 來允許其他線程在核心上運行(這種技術經常用於在不使用整個核心的情況下輪詢某個條件),那麼預期的效果將不會發生,因為使用 FIFO 調度機制,Thread.yield() 不會阻塞當前線程。由於當前線程仍然是可調度的,並且它位於 OS 內核中的調度隊列的最前面,所以它將繼續執行。因此,意圖提供對 CPU 資源的公平訪問(等待某個條件變為真)的編碼模式實際上將消耗運行它的整個 CPU 核心。而這是最可能的結果。如果需要設置此條件的線程具有較低的優先級,那麼它可能始終不能訪問核心來設置條件。在如今的多核處理器中,這種問題可能很少發生,但是它強調您需要謹慎考慮在采用 RealtimeThread 線程時使用哪些優先級。最安全的方法是讓所有線程使用一個優先級值,不使用 Thread.yield() 和將消耗整個 CPU(因為它們不會被阻塞)的其他類型的自旋循環。當然,充分利用可用於 RealtimeThread 的優先級值將最容易滿足服務質量目標。(關於在應用程序中使用 RealtimeThread 的更多技巧,請參閱 “實時 Java,第 3 部分: 線程化和同步。”)
Java 服務器示例
在本文剩余部分,我們將應用在前面章節中介紹的一些想法,使用 Java 類庫中的 Executors 服務構建一個相對簡單的 Java 服務器應用程序。只需少量應用程序代碼,Executors 服務就可以用於創建一個服務器來管理工作者線程池,如清單 5 所示:
清單 5. 使用 Executors 服務的 Server 和 TaskHandler 類
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;
class Server {
private ExecutorService threadPool;
Server(int numThreads) {
ThreadFactory theFactory = new ThreadFactory();
this.threadPool = Executors.newFixedThreadPool(numThreads, theFactory);
}
public void start() {
while (true) {
// main server handling loop, find a task to do
// create a "TaskHandler" object to complete this operation
TaskHandler task = new TaskHandler();
this.threadPool.execute(task);
}
this.threadPool.shutdown();
}
public static void main(String[] args) {
int serverThreads = Integer.parseInt(args[0]);
Server theServer = new Server(serverThreads);
theServer.start();
}
}
class TaskHandler extends Runnable {
public void run() {
// code to handle a "task"
}
}
此服務器可以創建所有需要的線程,直到達到創建服務器(從此示例中的命令行解碼)時指定的最大數量。每個工作者線程使用 TaskHandler 類執行一部分工作。出於我們的目的,我們將創建一個 TaskHandler.run() 方法,它每次運行都應該花相同的時間。因此,執行 TaskHandler.run() 的時間上的任何易變性都源自於底層 JVM 中的暫停或易變性、某個線程問題或在堆棧的較低級別上引入的暫停。清單 6 給出了 TaskHandler 類:
清單 6. 具有可預測性能的 TaskHandler 類
import java.lang.Runnable;
class TaskHandler implements Runnable {
static public int N=50000;
static public int M=100;
static long result=0L;
// constant work per transaction
public void run() {
long dispatchTime = System.nanoTime();
long x=0L;
for (int j=0;j < M;j++) {
for (int i=0;i < N;i++) {
x = x + i;
}
}
result = x;
long endTime = System.nanoTime();
Server.reportTiming(dispatchTime, endTime);
}
}
此 run() 方法中的循環計算 N (50,000) 個整數中前 M (100) 個整數的和。M 和 N 的值已經選定,以便運行循環的事務時間為 10 毫秒左右,使一項操作可以被一個 OS 調度限額(通常持續約 10 毫秒)中斷。我們在此計算構造此循環的目的在於,使 JIT 編譯器能夠生成將執行高度可預測的時間量的出色代碼:run() 方法不會顯式在對 System.nanoTime() 的兩次調用中顯式阻塞,這兩次調用用於計算循環運行的時間。由於上述代碼高度可預測,所以我們可以使用它來展示延遲和易變性的不一定來源於您測試的代碼。
我們使此應用程序稍微真實一些,在運行 TaskHandler 代碼時激活垃圾收集器子系統。清單 7 給出了這個 GCStressThread 類:
清單 7. 用於不斷生成垃圾的 GCStressThread 類
class GCStressThread extends Thread {
HashMap<Integer,BinaryTree> map;
volatile boolean stop = false;
class BinaryTree {
public BinaryTree left;
public BinaryTree right;
public Long value;
}
private void allocateSomeData(boolean useSleep) {
try {
for (int i=0;i < 125;i++) {
if (useSleep)
Thread.sleep(100);
BinaryTree newTree = createNewTree(15); // create full 15-level BinaryTree
this.map.put(new Integer(i), newTree);
}
} catch (InterruptedException e) {
stop = true;
}
}
public void initialize() {
this.map = new HashMap<Integer,BinaryTree>();
allocateSomeData(false);
System.out.println("\nFinished initializing\n");
}
public void run() {
while (!stop) {
allocateSomeData(true);
}
}
}
GCStressThread 通過 HashMap 維護一組 BinaryTree。它為存儲新 BinaryTree 結構的 HashMap 迭代一組相同的 Integer 鍵,這些結構是完全填充的 15 級 BinaryTrees。(所以每個 BinaryTree 有 215 = 32,768 個節點被存儲在 HashMap 中)。HashMap 在每次迭代時都持有 125 個 BinaryTree(活動數據),它每隔 10 毫秒就會將其中一個節點替換為新的 BinaryTree。通過這種方式,此數據結構維持著一個相當復雜的活動對象集,並且以特定速率生成垃圾。首先使用 initialize() 例程,借助 125 個 BinaryTree 來初始化HashMap,initialize() 例程不會在對每棵樹的收集之間暫停。一旦啟動了 GCStressThread(在啟動服務器之前),它將通過服務器的工作者線程全權處理 TaskHandler 操作。
我們不打算使用客戶端來驅動此服務器。我們將直接在服務器的主循環(在 Server.start() 方法中)中創建 NUM_OPERATIONS == 10000 操作。清單 8 給出了 Server.start() 方法:
清單 8. 在服務器內部分派操作
public void start() {
for (int m=0; m < NUM_OPERATIONS;m++) {
TaskHandler task = new TaskHandler();
threadPool.execute(task);
}
try {
while (!serverShutdown) { // boolean set to true when done
Thread.sleep(1000);
}
}
catch (InterruptedException e) {
}
}
如果我們收集完成每個 TaskHandler.run() 調用的時間統計信息,我們可以看到 JVM 和應用程序設計引入了多少易變性。我們使用具有 8 個物理核心的 IBM xServer e5440,安裝了 Red Hat RHEL MRG 實時操作系統。(禁用了超線程。注意,盡管超線程可以在基准測試中提供一些吞吐量改進,但是由於其虛擬核心並不完整,所以在啟用超線程的處理器上的操作的物理核心性能可能具有明顯不同的計時)。當在 8 核心機器上使用 IBM Java6 SR3 JVM 和 6 個線程運行此服務器時(我們將一個核心保留給 Server 主線程,另一個核心供 GCStressorThread 使用),我們得到了以下(代表性的)結果:
$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 16582 ms
Throughput is 603 operations / second
Histogram of operation times:
9ms - 10ms 9942 99 %
10ms - 11ms 2 0 %
11ms - 12ms 32 0 %
30ms - 40ms 4 0 %
70ms - 80ms 1 0 %
200ms - 300ms 6 0 %
400ms - 500ms 6 0 %
500ms - 542ms 6 0 %
可以看到,幾乎所有操作都在 10 毫秒內完成,但是一些操作花了超過半秒(長 50 倍)的時間。這個差異太大了!我們看看如何消除 Java 加載、JIT 本機代碼編譯、GC 和線程導致的延遲,從而消除這種易變性。
我們首先通過 -verbose:class 完整地運行應用程序,收集應用程序加載的類列表。我們將輸出存儲到一個文件,然後修改它,使該文件的每行上都具有一個格式正確的名稱。我們將一個 preload() 方法包含到 Server 類中,以加載每個類,JIT 編譯這些類的所有方法,然後禁用 JIT 編譯器,如清單 9 所示:
清單 9. 預加載服務器的類和方法
private void preload(String classesFileName) {
try {
FileReader fReader = new FileReader(classesFileName);
BufferedReader reader = new BufferedReader(fReader);
String className = reader.readLine();
while (className != null) {
try {
Class clazz = Class.forName(className);
String n = clazz.getName();
Compiler.compileClass(clazz);
} catch (Exception e) {
}
className = reader.readLine();
}
} catch (Exception e) {
}
Compiler.disable();
}
在這個簡單的服務器中,類加載並不是一個重大問題,因為 TaskHandler.run() 方法非常簡單:一旦加載該類,就不會在以後執行 Server 時發生太多類加載操作,這可以通過運行 -verbose:class 來驗證。主要的優點源自於在運行任何測試 TaskHandler 操作之前運行方法。盡管我們可以使用預備循環,但此方法是特定於 JVM 的,因為 JIT 編譯器用於選擇要編譯方法的啟發機制在各個 JVM 實現之間有所不同。使用 Compiler.compile() 服務器會提供更加可控的編譯行為,但正如本文前面提到的,使用此方法應該會使吞吐量下降。使用這些選項運行應用程序的結果如下:
$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 20936 ms
Throughput is 477 operations / second
Histogram of operation times:
11ms - 12ms 9509 95 %
12ms - 13ms 478 4 %
13ms - 14ms 1 0 %
400ms - 500ms 6 0 %
500ms - 527ms 6 0 %
注意,盡管最長的延遲沒有多大變化,但直方圖比最初小多了。JTI 編譯器明確引入了許多較短的延遲,所以較早執行編譯,然後禁用 JIT 編譯器顯然是一大進步。另一個有趣的結果是,普通操作時間變得更長了(從 9 - 10 毫秒增加到了 11 - 12 毫秒)。操作變慢了,原因在於在調用方法之前強制執行 JIT 編譯所生成的代碼質量比完整編譯的代碼質量要低很多。這個結果並不奇怪,因為 JIT 編譯器的一個最大優勢是利用應用程序的動態特征來使其更高效地運行。
我們將在本文余下部分繼續使用此類預加載和方法預編譯代碼。
由於我們的 GCStressThread 生成了一個不斷變化的活動數據集,所以使用分代 GC 策略並不能提供太多暫停時間優勢。相反,我們嘗試使用 IBM WebSphere Real Time for Real Time Linux V2.0 SR1 產品中的實時垃圾收集器。最初的結果令人失望,甚至在添加了 -Xgcthreads8 選項之後也是如此,該選項允許收集器使用 8 個 GC 線程,而不是默認的 1 個線程。(使用一個 GC 線程,收集器無法可靠地跟上此應用程序的分配速度)。
$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
10000 operations in 72024 ms
Throughput is 138 operations / second
Histogram of operation times:
11ms - 12ms 82 0 %
12ms - 13ms 250 2 %
13ms - 14ms 19 0 %
14ms - 15ms 50 0 %
15ms - 16ms 339 3 %
16ms - 17ms 889 8 %
17ms - 18ms 730 7 %
18ms - 19ms 411 4 %
19ms - 20ms 287 2 %
20ms - 30ms 1051 10 %
30ms - 40ms 504 5 %
40ms - 50ms 846 8 %
50ms - 60ms 1168 11 %
60ms - 70ms 1434 14 %
70ms - 80ms 980 9 %
80ms - 90ms 349 3 %
90ms - 100ms 28 0 %
100ms - 112ms 7 0 %
使用實時收集器顯著縮短了最大操作時間,但是同時也增加了操作時間跨度。更糟的是,吞吐率大大降低。
最後一步是為工作者線程使用 RealtimeThread,而不是常規 Java 線程。我們創建一個 RealtimeThreadFactory 類,我們可以將該類提供給 Executors 服務,如清單 10 所示:
清單 10. RealtimeThreadFactory 類
import java.util.concurrent.ThreadFactory;
import javax.realtime.PriorityScheduler;
import javax.realtime.RealtimeThread;
import javax.realtime.Scheduler;
import javax.realtime.PriorityParameters;
class RealtimeThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
RealtimeThread rtThread = new RealtimeThread(null, null, null, null, null, r);
// adjust parameters as needed
PriorityParameters pp = (PriorityParameters) rtThread.getSchedulingParameters();
PriorityScheduler scheduler = PriorityScheduler.instance();
pp.setPriority(scheduler.getMaxPriority());
return rtThread;
}
}
如果將 RealtimeThreadFactory 類的一個實例傳遞給 Executors.newFixedThreadPool() 服務器,將導致工作者線程變為使用具有最高優先級的 FIFO 調度機制的 RealtimeThread。垃圾收集器仍然會在需要執行工作時中斷這些線程,但其他較低優先級任務不會干擾工作者線程:
$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
Handled 10000 operations in 27975 ms
Throughput is 357 operations / second
Histogram of operation times:
11ms - 12ms 159 1 %
12ms - 13ms 61 0 %
13ms - 14ms 17 0 %
14ms - 15ms 63 0 %
15ms - 16ms 1613 16 %
16ms - 17ms 4249 42 %
17ms - 18ms 2862 28 %
18ms - 19ms 975 9 %
19ms - 20ms 1 0 %
通過此更改,我們顯著改善了最差的操作時間(降低到 19 毫秒)和總吞吐量(提升到每秒 357 個操作)。所以我們顯著改善了操作時間的易變性,但是在吞吐量性能上付出了昂貴的代價。垃圾收集器的操作(使用每 10 毫秒中的至多 3 毫秒)解釋了為什麼通常需要約 12 毫秒的操作可改善至 4 - 5 毫秒,這也是大量操作現在需要花 16 - 17 毫秒時間的原因。吞吐量的下降可能比預期更嚴重,因為除了使用 Metronome 垃圾收集器,實時 JVM 還修改了預防優先級反轉的鎖定原語,優先級反轉是使用 FIFO 調度時的一個重要問題(參見 “實時 Java,第 1 部分: 使用 Java 語言編寫實時系統”)。不幸的是,主線程與工作者線程之間的同步導致了最終影響吞吐量的大量開銷,但是我們沒有將其作為任何操作時間的一部分來進行測量(所以沒有在直方圖中顯示)。
所以,盡管我們在服務器中通過修改改善了可預測性,但它的吞吐量明顯降低。但是,如果將少數很長的操作時間當作不可接受的服務質量水平,那麼使用 RealtimeThread 和實時 JVM 可能是正確的解決方案。
結束語
在 Java 應用程序領域,吞吐量和延遲是應用程序和基准測試設計師選擇用於報告和優化的傳統指標。這種選擇對構建用於改善性能的 Java 運行時具有廣泛影響。盡管 Java 運行時已成為具有極慢的運行時延遲和極低的吞吐量的解釋器,但現在對於許多應用程序,JVM 仍然可以在這些指標上與其他語言抗衡。但是,直到最近,這種觀點不再適合一些可能對應用程序的已知性能產生極大影響的其他指標,尤其是會影響服務質量的易變性。
實時 Java 的引入為應用程序設計師提供了必要的工具來解決 JVM 和應用程序中的易變性源頭,從而交付消費者和客戶期望的服務質量。本文介紹了許多技術,您可以使用它們修改 Java 應用程序,以減少源自於 JVM 和線程調度的暫停和易變性。減少易變性通常會增加延遲時間和降低吞吐量性能。可接受的降低程度取決於適用於特定應用程序的工具。