Chronicle Queue是一個(gè)持久性的低延遲Java消息傳遞框架。它適用于具有高性能的關(guān)鍵性應(yīng)用程序。由于Chronicle Queue運(yùn)行在映射到本地的內(nèi)存上,因此它消除了垃圾收集的需求,并為開(kāi)發(fā)人員提供了確定性和高性能。
本文將使用開(kāi)源的Chronicle Queue的兩個(gè)線(xiàn)程,彼此交換256字節(jié)的消息數(shù)據(jù)。同時(shí),為了最小化對(duì)于磁盤(pán)子系統(tǒng)的影響,所有消息都將被存儲(chǔ)在共享內(nèi)存--/dev/shm中。
通常,在此類(lèi)基準(zhǔn)測(cè)試中,一個(gè)單一生產(chǎn)者(producer)線(xiàn)程會(huì)將消息寫(xiě)入具有納秒時(shí)間戳(nanosecond timestamp)的隊(duì)列中。而另一個(gè)消費(fèi)者線(xiàn)程則會(huì)從該隊(duì)列中讀取消息,并在直方圖中記錄時(shí)間的增量。生產(chǎn)者保持每秒100,000條消息的持續(xù)輸出速率。其中,每條消息中的有效負(fù)載為256字節(jié)。由于數(shù)據(jù)會(huì)在100秒的跨度內(nèi)被測(cè)量,因此出現(xiàn)的大多數(shù)抖動(dòng)都能夠被反映到測(cè)量中,并且可以確保那些具有較高百分位數(shù),落在合理的置信區(qū)間內(nèi)。
我們的目標(biāo)主機(jī)是擁有一個(gè)AMD Ryzen 9 5950X的16核處理器,并且以3.4 GHz運(yùn)行在Linux 5.11.0-49-generic #55-Ubuntu SMP上。由于該CPU的2-8核是隔離的,因此操作系統(tǒng)不會(huì)去自動(dòng)調(diào)度任何用戶(hù)進(jìn)程,而且會(huì)避開(kāi)在這些核上的大多數(shù)中斷。
1.Java 代碼
下面顯示了生產(chǎn)者內(nèi)部循環(huán)的部分代碼:
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);
}
}
而在另一個(gè)線(xiàn)程中,消費(fèi)者(consumer)線(xiàn)程會(huì)通過(guò)如下代碼(下面僅為縮短的部分),在其內(nèi)部循環(huán)運(yùn)行。
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;
}
}
}
可以看出,消費(fèi)者線(xiàn)程會(huì)讀取每個(gè)納米時(shí)間戳,并在一個(gè)數(shù)組中記錄相應(yīng)的延遲。這些時(shí)間戳稍后會(huì)在基準(zhǔn)測(cè)試完成時(shí),被放入供打印的直方圖中。而且,只有在JVM被正確地“預(yù)熱”、以及C2編譯器具有了JIT(Just-In-Time)的熱執(zhí)行路徑后,測(cè)量才會(huì)開(kāi)始。
2.JVM的各種變體版本
目前,Chronicle Queue能夠正式支持包括Java 8、Java 11和Java 17在內(nèi)的,所有最近的LTS(Light Task Schedule)版本,因此它們都可以被用于基準(zhǔn)測(cè)試。同時(shí),我們還會(huì)用到GraalVM的社區(qū)版和企業(yè)版。以下便是我們?cè)跍y(cè)試中用到的特定JVM變體版本的列表:
表 1,列出了使用到的特定JVM變體版本
3.測(cè)量
由于基準(zhǔn)測(cè)試會(huì)運(yùn)行100秒,并且每秒會(huì)有100,000條消息被產(chǎn)生,因此在每個(gè)基準(zhǔn)測(cè)試期間,我們會(huì)有100,000 * 100 = 1000萬(wàn)條消息需要采樣。直方圖會(huì)將每個(gè)樣本置于50%(中位數(shù))、90%、99%、以及99.9%等特定的百分位處。下表顯示了測(cè)試針對(duì)這些百分位,所接收到的消息總數(shù):
表 2,顯示每個(gè)百分位數(shù)的消息數(shù)
對(duì)于上表而言,我們鎖定測(cè)量值的變化相對(duì)較小的區(qū)間,對(duì)于高達(dá)99.99%的百分位數(shù),置信區(qū)間可能會(huì)比較合理。而99.999%的百分位數(shù),則可能需要至少要運(yùn)行半小時(shí)左右,而不是僅僅使用100秒的時(shí)間,去收集數(shù)據(jù),以生成任何具有合理置信區(qū)間的數(shù)據(jù)。
4.基準(zhǔn)測(cè)試的結(jié)果
對(duì)于每個(gè)Java變體版本,我們都運(yùn)行了如下基準(zhǔn)測(cè)試:
Shell
mvn exec:java@QueuePerformance
注意,我們的生產(chǎn)者和消費(fèi)者線(xiàn)程將會(huì)被鎖定,以便分別在彼此隔離的CPU的2和4核上運(yùn)行。以下便是它們運(yùn)行了一段時(shí)間后的典型進(jìn)程特征:
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
可以看出,生產(chǎn)者和消費(fèi)者線(xiàn)程在每條消息之間都會(huì)旋轉(zhuǎn)等待(spin-wait),因此每個(gè)都會(huì)消耗整個(gè)CPU的內(nèi)核。如果CPU的消耗是一個(gè)潛在的問(wèn)題,那么其延遲和確定性則可以通過(guò)在無(wú)消息可用的情況下,將線(xiàn)程暫停一小段時(shí)間(例如LockSupport.parkNanos(1000)),來(lái)降低功耗。通常,我們會(huì)以納秒(ns)為單位來(lái)衡量測(cè)試結(jié)果。當(dāng)然,許多其他類(lèi)型的延遲測(cè)量也會(huì)以微秒(= 1,000 ns)、甚至毫秒(= 1,000,000 ns)為單位進(jìn)行計(jì)量。此處的1 ns大致對(duì)應(yīng)于對(duì)CPU 1級(jí)高速緩存的訪問(wèn)時(shí)間。以下所有測(cè)試值均是以ns為單位的基準(zhǔn)測(cè)試結(jié)果:
表 3,顯示了使用的各種JDK的延遲結(jié)果(*)表示未被Chronicle Queue正式支持
5.典型延遲(中位數(shù))
由上表可知,對(duì)于典型(中位數(shù))值,各種JDK之間并沒(méi)有顯著的差異,只是OpenJDK 11會(huì)比其他版本要慢30%。其中最快的是GraalVM EE 17,它與OpenJDK 8、以及OpenJDK 17的差異很小。下面展示的圖表包含了使用各種JDK變體版本,在處理256字節(jié)消息時(shí)的典型延遲(當(dāng)然是越低越好):
圖 1,顯示了各種JDK變體版本的中位數(shù)(典型)延遲(以ns為單位)
由圖可知,典型(中位數(shù))的延遲會(huì)因運(yùn)行環(huán)境而略有不同,它們數(shù)字的變化約為5%。
6.更高的百分位數(shù)
下面是另一種圖表,它展示了各種JDK變體版本的99.99%百分位數(shù)的延遲(當(dāng)然也是越低越好)。從較高的百分位數(shù)來(lái)看,各種受支持的JDK變體版本之間,并沒(méi)有太大的差異。GraalVM EE再次稍快一點(diǎn),但是此處的相對(duì)差異變得更小了。而OpenJDK 11似乎比其他變體版本稍差一些(-5%),不過(guò)其誤差增量仍在可接受的范圍內(nèi)。
圖 2,顯示了各種JDK變體版本的99.99%百分位延遲(以ns為單位)
7.小結(jié)
根據(jù)上述代碼的執(zhí)行邏輯:從主要內(nèi)存處訪問(wèn)64位的數(shù)據(jù),大約需要100個(gè)周期(即,在當(dāng)前硬件上相當(dāng)于大約30 ns)。通過(guò)上面的測(cè)試比較,我們可以看出, Chronicle Queue從生產(chǎn)者那里獲取數(shù)據(jù),并通過(guò)寫(xiě)入內(nèi)存映射文件的方式持久化數(shù)據(jù),為線(xiàn)程間通信和happens-before的保證,應(yīng)用適當(dāng)?shù)膬?nèi)存防護(hù),然后將數(shù)據(jù)提供給消費(fèi)者。與在30 ns內(nèi)的單個(gè)64位內(nèi)存訪問(wèn)相比,所有這些通常都發(fā)生在600 ns左右的256字節(jié)的消息上。這些由Chronicle Queue產(chǎn)生的延遲比較結(jié)果令人印象深刻。
可見(jiàn),OpenJDK 17和GraalVM EE 17都提供了最佳的延遲結(jié)果,屬于應(yīng)用程序的優(yōu)先選擇。當(dāng)然,如果需要抑制異常值、或者盡可能地降低總體延遲的話(huà),那么GraalVM EE 17會(huì)更加適合一些。
原文鏈接:https://dzone.com/articles/which-jvm-version-is-the-fastest
譯者介紹
陳峻 (Julian Chen),51CTO社區(qū)編輯,具有十多年的IT項(xiàng)目實(shí)施經(jīng)驗(yàn),善于對(duì)內(nèi)外部資源與風(fēng)險(xiǎn)實(shí)施管控,專(zhuān)注傳播網(wǎng)絡(luò)與信息安全知識(shí)與經(jīng)驗(yàn);持續(xù)以博文、專(zhuān)題和譯文等形式,分享前沿技術(shù)與新知;經(jīng)常以線(xiàn)上、線(xiàn)下等方式,開(kāi)展信息安全類(lèi)培訓(xùn)與授課。