Chronicle Queue是一個持久性的低延遲Java消息傳遞框架。它適用于具有高性能的關鍵性應用程序。由于Chronicle Queue運行在映射到本地的內存上,因此它消除了垃圾收集的需求,并為開發人員提供了確定性和高性能。
本文將使用開源的Chronicle Queue的兩個線程,彼此交換256字節的消息數據。同時,為了最小化對于磁盤子系統的影響,所有消息都將被存儲在共享內存--/dev/shm中。
通常,在此類基準測試中,一個單一生產者(producer)線程會將消息寫入具有納秒時間戳(nanosecond timestamp)的隊列中。而另一個消費者線程則會從該隊列中讀取消息,并在直方圖中記錄時間的增量。生產者保持每秒100,000條消息的持續輸出速率。其中,每條消息中的有效負載為256字節。由于數據會在100秒的跨度內被測量,因此出現的大多數抖動都能夠被反映到測量中,并且可以確保那些具有較高百分位數,落在合理的置信區間內。
我們的目標主機是擁有一個AMD Ryzen 9 5950X的16核處理器,并且以3.4 GHz運行在Linux 5.11.0-49-generic #55-Ubuntu SMP上。由于該CPU的2-8核是隔離的,因此操作系統不會去自動調度任何用戶進程,而且會避開在這些核上的大多數中斷。
1.Java 代碼
下面顯示了生產者內部循環的部分代碼:
Java
// Pin the producer thread to CPU 2
Affinity.setAffinity(2);
try (ChronicleQueue cq = SingleChronicleQueueBuilder.binary(tmp)
.blockSize(blocksize)
.rollCycle(ROLL_CYCLE)
.build()) {
ExcerptAppender appender = cq.acquireAppender();
final long nano_delay = 1_000_000_000L/MSGS_PER_SECOND;
for (int i = -WARMUP; i < COUNT; ++i) {
long startTime = System.nanoTime();
try (DocumentContext dc = appender.writingDocument()) {
Bytes bytes = dc.wire().bytes();
data.writeLong(0, startTime);
bytes.write(data,0, MSGSIZE);
}
long delay = nano_delay - (System.nanoTime() - startTime);
spin_wait(delay);
}
}
而在另一個線程中,消費者(consumer)線程會通過如下代碼(下面僅為縮短的部分),在其內部循環運行。
Java
// Pin the consumer thread to CPU 4
Affinity.setAffinity(4);
try (ChronicleQueue cq = SingleChronicleQueueBuilder.binary(tmp)
.blockSize(blocksize)
.rollCycle(ROLL_CYCLE)
.build()) {
ExcerptTailer tailer = cq.createTailer();
int idx = -APPENDERS * WARMUP;
while(idx < APPENDERS * COUNT) {
try (DocumentContext dc = tailer.readingDocument()) {
if(!dc.isPresent())
continue;
Bytes bytes = dc.wire().bytes();
data.clear();
bytes.read(data, (int)MSGSIZE);
long startTime = data.readLong(0);
if(idx >= 0)
deltas[idx] = System.nanoTime() - startTime;
++idx;
}
}
}
可以看出,消費者線程會讀取每個納米時間戳,并在一個數組中記錄相應的延遲。這些時間戳稍后會在基準測試完成時,被放入供打印的直方圖中。而且,只有在JVM被正確地“預熱”、以及C2編譯器具有了JIT(Just-In-Time)的熱執行路徑后,測量才會開始。
2.JVM的各種變體版本
目前,Chronicle Queue能夠正式支持包括Java 8、Java 11和Java 17在內的,所有最近的LTS(Light Task Schedule)版本,因此它們都可以被用于基準測試。同時,我們還會用到GraalVM的社區版和企業版。以下便是我們在測試中用到的特定JVM變體版本的列表:
表 1,列出了使用到的特定JVM變體版本
3.測量
由于基準測試會運行100秒,并且每秒會有100,000條消息被產生,因此在每個基準測試期間,我們會有100,000 * 100 = 1000萬條消息需要采樣。直方圖會將每個樣本置于50%(中位數)、90%、99%、以及99.9%等特定的百分位處。下表顯示了測試針對這些百分位,所接收到的消息總數:
表 2,顯示每個百分位數的消息數
對于上表而言,我們鎖定測量值的變化相對較小的區間,對于高達99.99%的百分位數,置信區間可能會比較合理。而99.999%的百分位數,則可能需要至少要運行半小時左右,而不是僅僅使用100秒的時間,去收集數據,以生成任何具有合理置信區間的數據。
4.基準測試的結果
對于每個Java變體版本,我們都運行了如下基準測試:
Shell
mvn exec:java@QueuePerformance
注意,我們的生產者和消費者線程將會被鎖定,以便分別在彼此隔離的CPU的2和4核上運行。以下便是它們運行了一段時間后的典型進程特征:
Shell
$ top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3216555 per.min+ 20 0 92.3g 1.5g 1.1g S 200.0 2.3 0:50.15 java
可以看出,生產者和消費者線程在每條消息之間都會旋轉等待(spin-wait),因此每個都會消耗整個CPU的內核。如果CPU的消耗是一個潛在的問題,那么其延遲和確定性則可以通過在無消息可用的情況下,將線程暫停一小段時間(例如LockSupport.parkNanos(1000)),來降低功耗。通常,我們會以納秒(ns)為單位來衡量測試結果。當然,許多其他類型的延遲測量也會以微秒(= 1,000 ns)、甚至毫秒(= 1,000,000 ns)為單位進行計量。此處的1 ns大致對應于對CPU 1級高速緩存的訪問時間。以下所有測試值均是以ns為單位的基準測試結果:
表 3,顯示了使用的各種JDK的延遲結果(*)表示未被Chronicle Queue正式支持
5.典型延遲(中位數)
由上表可知,對于典型(中位數)值,各種JDK之間并沒有顯著的差異,只是OpenJDK 11會比其他版本要慢30%。其中最快的是GraalVM EE 17,它與OpenJDK 8、以及OpenJDK 17的差異很小。下面展示的圖表包含了使用各種JDK變體版本,在處理256字節消息時的典型延遲(當然是越低越好):
圖 1,顯示了各種JDK變體版本的中位數(典型)延遲(以ns為單位)
由圖可知,典型(中位數)的延遲會因運行環境而略有不同,它們數字的變化約為5%。
6.更高的百分位數
下面是另一種圖表,它展示了各種JDK變體版本的99.99%百分位數的延遲(當然也是越低越好)。從較高的百分位數來看,各種受支持的JDK變體版本之間,并沒有太大的差異。GraalVM EE再次稍快一點,但是此處的相對差異變得更小了。而OpenJDK 11似乎比其他變體版本稍差一些(-5%),不過其誤差增量仍在可接受的范圍內。
圖 2,顯示了各種JDK變體版本的99.99%百分位延遲(以ns為單位)
7.小結
根據上述代碼的執行邏輯:從主要內存處訪問64位的數據,大約需要100個周期(即,在當前硬件上相當于大約30 ns)。通過上面的測試比較,我們可以看出, Chronicle Queue從生產者那里獲取數據,并通過寫入內存映射文件的方式持久化數據,為線程間通信和happens-before的保證,應用適當的內存防護,然后將數據提供給消費者。與在30 ns內的單個64位內存訪問相比,所有這些通常都發生在600 ns左右的256字節的消息上。這些由Chronicle Queue產生的延遲比較結果令人印象深刻。
可見,OpenJDK 17和GraalVM EE 17都提供了最佳的延遲結果,屬于應用程序的優先選擇。當然,如果需要抑制異常值、或者盡可能地降低總體延遲的話,那么GraalVM EE 17會更加適合一些。
原文鏈接:https://dzone.com/articles/which-jvm-version-is-the-fastest
譯者介紹
陳峻 (Julian Chen),51CTO社區編輯,具有十多年的IT項目實施經驗,善于對內外部資源與風險實施管控,專注傳播網絡與信息安全知識與經驗;持續以博文、專題和譯文等形式,分享前沿技術與新知;經常以線上、線下等方式,開展信息安全類培訓與授課。