原文はこちら。
The original article was written by Per Lidén (software engineer, Oracle).
https://malloc.se/blog/zgc-jdk17
JDK 17は9月14日にリリースされました。
JDK 17 General-Availability Release
http://jdk.java.net/17
これはLong-Term Support(LTS)リリースであり、長年にわたってサポートとアップデートが行われることを意味します。今回のリリースは、ZGCの運用環境で利用可能なバージョンが含まれる初めてのLTSリリースでもあります。記憶をたどると、実験的なバージョンのZGCはJDK 11(前のLTSリリース)に含まれており、運用環境で利用可能なバージョンのZGCはJDK 15(non-LTS)に初めて登場しました。
ZGC wiki
https://wiki.openjdk.java.net/display/zgc
JDK 17では、ZGCに41のバグ修正と機能強化が施されており、ここではそのうちのいくつかを紹介します。しかし、その前に、過去のJDKリリースにおけるZGCの機能/強化点について知りたい方は、以下の過去の投稿をお読みください。
- What’s new in JDK 16
https://malloc.se/blog/zgc-jdk16
https://logico-jp.io/2021/03/31/zgc-whats-new-in-jdk-16/ - What’s new in JDK 15
https://malloc.se/blog/zgc-jdk15
https://logico-jp.io/2020/09/25/zgc-whats-new-in-jdk-15/ - What’s new in JDK 14
https://malloc.se/blog/zgc-jdk14
それでは、ZGCの観点からJDK 17の新機能をご紹介します。
Dynamic Number of GC Threads
JVMには長い間、-XX:+UseDynamicNumberOfGCThreads
というオプションがありました。これを有効にすると、さまざまな処理に使用するGCスレッド数について動的に変動させるようにGCに指示します。使用されるスレッドの数は常に再評価されるため、時間とともに変化します。このオプションは、いくつかの理由で役に立ちます。例えば、特定の作業負荷に対して最適なGCスレッド数を把握するのは難しい場合があります。通常は、(使用しているGCに応じて)-XX:ParallelGCThreads
や-XX:ConcGCThreads
の様々な設定を試して,どの設定で最も良い結果をもたらすかを確認します。複雑なことに、最適なGCスレッドの数は、アプリケーションがさまざまなフェーズを経るにつれて時間とともに変化する可能性があるという点です。そのため、固定のGCスレッドの数を設定することは本質的に最適ではありません。
JDK 17までは、ZGCは-XX:+UseDynamicNumberOfGCThreads
を無視し、常に固定のスレッド数を使用していました。JVM起動時に、ZGCはheuristicsを使い固定の値(-XX:ConcGCThreads
)を決定しました。一度設定された数は二度と変更されませんでした。JDK17では、(ZGCを使用する場合)ZGCは-XX:+UseDynamicNumberOfGCThreads
を尊重し、デフォルトで有効になっています。有効にすると、ZGCは可能な限り少ないスレッドを使用しようとしますが、ガベージの生成速度にあわせて収集し続けるには十分なスレッドが必要です。この設定により、必要以上のCPU時間の使用を避けることができ、その結果、Javaスレッドにより多くのCPU時間を使えるようになります。
また、この機能を有効にすると、-XX:ConcGCThreads
の意味が「この個数のスレッドを使用する」から「最大でこの個数のスレッドを使用する」に変わることに注意してください。しかし、型破りなワークロードでない限り、通常は-XX:ConCGCThreads
をいじる必要はありません。ZGCのheuristicsは、実行しているシステムのサイズに基づいて、適切な最大スレッド数を選択します。
この機能の動作を説明するために、SPECjbb2015を実行したときのグラフを見てみましょう。
最初のグラフは、実行中に使用されたGCスレッドの数を示しています。SPECjbb2015では、初期の立ち上げフェーズと、負荷(注入率)が徐々に増加する長時間のフェーズがあります。ZGCが使用するスレッド数は、ZGCが追いつくために必要な作業量を反映していることがわかります。すべての(この場合は5つの)スレッドを必要とするケースはごくわずかです。
2番目のグラフではベンチマークのスコアが表示されています。ZGCでは常時すべてのGCスレッドを使用しなくなっているため、より多くのCPU 時間をJavaスレッドに渡すことができ、その結果、スループット(max-jOPS)とレイテンシー(critical-jOPS)が向上しています。
何らかの理由で(JDK16以前と同様)固定数のGCスレッドを使い続けたい場合は、-XX:-UseDynamicNumberOfGCThreads
を使ってこの機能を無効にできます。
Fast JVM Termination
ZGCを使用していると、(例えば、Ctrl+C
を押したり、アプリケーションがSystem.exit()
を呼び出したりして)実行中のJavaプロセスを終了させても、必ずしもすぐには効果が出ないことに気づかれたかもしれません。JVMが実際に終了するまで、しばらく(最悪の場合、何秒も)かかることがあります。これは非常に厄介なことで、速やかに終了できることが重要な環境では問題となります。
では、ZGCでJVMの終了に時間がかかることがあるのはなぜでしょうか?その理由は、JVMのシャットダウンシーケンスがGCと連携し、GCが今やっていることを止めて「安全」な状態にする必要があるからです。ZGCが「安全」な状態にあるのは、アイドル状態、つまり現在GCを実行していないときだけでした。終了シグナルが届いたときに非常に長いGCサイクルが進行していた場合、JVMのシャットダウンシーケンスは、ZGCがアイドル状態になって再び「安全」な状態になるまで、単にそのGCサイクルが完了するのを待つ必要がありました。
これがJDK 17で対処されました。ZGCは、進行中のGCサイクルを中止して、必要に応じて「安全」な状態に素早く到達できるようになりました。(その結果)ZGCを実行するJVMの終了は、ほぼ瞬時に行われるようになりました。
Reduced Mark Stack Memory Usage
ZGCはストライピングマーキングを行います。これは、ヒープをストライプ(縞)に分割し、各GCスレッドがそれらのストライプの1つにあるオブジェクトをマークするように割り当てられることを意味します。これにより、GCスレッド間の共有状態を最小化し、2 つの GC スレッドがヒープの同じ部分にあるオブジェクトをマークすることがないため、マーキング処理がより一層キャッシュと相性がよくなります。またこのアプローチは、ストライプがほぼ同じ量の作業を持つ傾向があるため、GCスレッド間の自然な作業バランスをもたらします。
JDK 17以前では、ZGCのマーキングはストライピングに厳密に従っていました。オブジェクトグラフを追跡中に、割り当てられたストライプに属さないヒープの一部を指すオブジェクト参照にGCスレッドが遭遇した場合、そのオブジェクト参照は、他のストライプに関連付けられたスレッドローカルなマークスタックに置かれました。そのスタックが一杯(254エントリ)になると、そのストライプのマーキングを処理するように割り当てられたGCスレッドに引き渡されました。まだマークされていないオブジェクトへのオブジェクト参照をロードするJavaスレッドも同じことを行いますが、ただしオブジェクト参照を関連するスレッドローカルのマークスタックに常に配置しつつも実際のマーク作業を決して行うことはありません。
この方法はほとんどのワークロードでうまくいきますが、異常な問題もあります。N:1の関係が1つ以上あるオブジェクトグラフがある場合(ここでNが非常に大きな数だとします)、マークスタックに大量(何ギガバイトも)のメモリを使用する危険性があります。これが問題になる可能性があることは以前からわかっており、小さな合成テストを書いてそれを引き起こすこともできましたが、実際にそれを露呈するような実世界のワークロードに出会うことはありませんでした。Tencent社のOpenJDKコントリビューターが野に放たれていたこの問題に遭遇したと報告するまでは。そこで、この問題を解決することにしました。
JDK 17での修正点は、次のような方法で厳密なストライピングを緩和することです。
- GCスレッドでは、オブジェクト参照がどのストライプを指しているかに関わらず、まずオブジェクトのマークを試み(つまり、GCスレッドの割り当てられたストライプから外れる可能性があります)、まだマークされていなければ、オブジェクト参照を関連するマークスタックにプッシュします。
- Javaスレッドでは、まずオブジェクトがすでにマークされているかどうかをチェックし、まだマークされていなかった場合は、オブジェクトの参照を関連するマークスタックにプッシュします。
これらの調整により、GCスレッドが同じオブジェクト参照に何度も出くわすような、たくさんの重複したオブジェクト参照をマークスタックにプッシュするという、病的なまでのN:1のケースでのマークスタックのメモリの過剰使用を止めることができます。オブジェクトは一度だけマークされればよいので、重複は無意味です。プッシュする前にマークし、以前にマークされていないオブジェクトのみをプッシュすることで、重複したオブジェクトの生成を止めることができます。
実のところ、当初上記対策を行うことに少し抵抗がありました。というのも、GC スレッドは他の GC スレッドが作業を割り当てられているストライプに属するメモリ内のオブジェクトをマークするために、アトミックな比較・スワップ操作を行うようになったからです。これにより、厳密なストライピングが破壊され、キャッシュフレンドリーではなくなります。Javaスレッドは、オブジェクトがマークされているかどうかを確認するために、以前は行っていなかったアトミックなロードも行うようになりました。同時に、GC スレッドが行うその他の作業(オブジェクトのフィールドをスキャン/フォローしたり、ヒープ領域ごとの生存オブジェクト/バイト数を追跡したりする作業)は、依然として厳密なストライピングに準拠しています。最終的には、ベンチマークによって、当初の懸念が杞憂に終わったことがわかりました。GCマーキング時間は影響を受けず、Javaスレッドへの影響も目立ちませんでした。その一方で、メモリを過剰に使用しない、より堅牢なマーキング方式を手に入れることができました。
macOS on ARM Supported
しばらく前に、Appleは同社のMacコンピュータをx86からARMに移行する長期計画を発表しました。その後間もなく、JEP 391で、この新しいプラットフォームへのJDKの移植が提案されました。
JEP 391: macOS/AArch64 Port
https://openjdk.java.net/jeps/391
JVMのコードベースはかなりモジュール化されており、OSやCPUに特化したコードは、プラットフォームに依存しない共有コードから分離されています。JDKはすでにmacOS/x86とLinux/Aarch64をサポートしていたので、macOS/Aarch64のサポートに必要な主要部分はすでに存在していました。もちろん、JDKのmacOS/Aarch64ビルドの出荷とサポートを計画している人は、新しいハードウェアへの投資、CIパイプラインへの新しいプラットフォームの統合など、まだ作業が必要です。
ZGCに関しても、ほぼ同じことが言えます。macOS/x86とLinux/Aarch64の両方がすでにサポートされていたので、この新しいOS/CPUの組み合わせのビルドとテストを可能にすることがほとんどでした。JDK 17の時点で、ZGCは以下のプラットフォームで動作します(詳細は下表をご覧ください)。
Platform | Supported | Since | Comment |
---|---|---|---|
Linux/x64 | JDK 11 | ||
Linux/AArch64 | JDK 13 | ||
macOS/x64 | JDK 14 | ||
macOS/AArch64 | JDK 17 | ||
Windows/x64 | JDK 14 | Windows version 1803 (Windows 10 もしくは Windows Server 2019) 以後が必要 | |
Windows/AArch64 | JDK 16 |
ZGCのコードベースのほとんどは引き続きプラットフォーム非依存です。現在のコードの状況は以下の通りです。
GarbageCollectorMXBeans
for Cycles and Pauses
GarbageCollectorMXBean
はGCに関する情報を提供します。
Interface GarbageCollectorMXBean
https://docs.oracle.com/en/java/javase/17/docs/api/java.management/java/lang/management/GarbageCollectorMXBean.html
このBeanを使ってアプリケーションは、概要情報(これまでに行われたGCの数、GCを行うのに費やした累積時間など)を抽出したり、GarbageCollectionNotificationInfo
通知をリッスンして、個々のGCに関するより詳細な情報(GCの原因、開始時刻、終了時刻など)を取得したりできます。
Class GarbageCollectionNotificationInfo
https://docs.oracle.com/en/java/javase/17/docs/api/jdk.management/com/sun/management/GarbageCollectionNotificationInfo.html
JDK 17以前、ZGCではZGC
というたった1個のBeanを公開していました。このBeanは、ZGCサイクルに関する情報を提供していました。サイクルには、開始から終了までのすべてのGCフェーズが含まれます。ほとんどのフェーズは同時進行ですが、中にはStop-The-Worldのような一時停止もあります。サイクルに関する情報は有用ですが、GCの実行に費やされた時間のうち、Stop-The-Worldの一時停止に費やされた時間がどれくらいあるのかを知りたいと思うかもしれません。この情報は、ZGC
というたった1個のBeanでは得られませんでした。この問題に対処するために、ZGCでは現在、ZGC Cycles
と呼ばれるBeanとZGC Pauses
と呼ばれるBeanの2つを利用できます。名前が示すように、それぞれのBeanで提供する情報は、それぞれGCサイクルとGCポーズに対応しています。
JDK16と17の違いを示すちょっとした例を紹介します。この例では、まずSystem.gc()
を100回呼び出して、利用可能なGarbageCollectorMXBean
から概要情報を抽出します。
import java.lang.management.ManagementFactory;
public class ExampleGarbageCollectorMXBean {
public static void main(String[] args) {
// Run 100 GCs
for (int i = 0; i < 100; i++) {
System.gc();
}
// Print basic information from available beans
for (final var bean : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.println(bean.getName());
System.out.println(" Count: " + bean.getCollectionCount());
System.out.println(" Total Time: " + bean.getCollectionTime() + "ms");
System.out.println(" Average Time: " + (bean.getCollectionTime() / (double)bean.getCollectionCount()) + "ms");
}
}
}
JDK 16で実行すると、以下のような出力があります。
$ java -XX:+UseZGC ExampleGarbageCollectorMXBean
ZGC
Count: 100
Total Time: 424ms
Average Time: 4.24ms
JDK 17で実行すると、以下のような出力があります。
$ java -XX:+UseZGC ExampleGarbageCollectorMXBean
ZGC Cycles
Count: 100
Total Time: 412ms
Average Time: 4.12ms
ZGC Pauses
Count: 300
Total Time: 2ms
Average Time: 0.006666666666666667ms
どちらのケースでも、GCは100 サイクル実行し、各サイクルの実行に平均4ミリ秒以下を要したことがわかります。JDK 17では、各サイクルに3回のStop-the-Worldポーズがあり、各ポーズは平均して0.007ミリ秒(7マイクロ秒)以下だったこともわかりました。
Summary
- JVMオプション
-XX:+UseDynamicNumberOfGCThreads
がサポートされました。この機能はデフォルトで有効であり、ZGCが利用するGCスレッド数についてうまくやるように指示します。これにより、Javaアプリケーションレベルでより高いスループットとより小さいレイテンシにつながります。 - GC実行中のJVMの終了がほぼ即座に行われるようになりました。
- マーキングアルゴリズムがより少ないメモリを使用するようになり、過剰なメモリ使用の傾向はなくなりました。
- ZGCはmacOS/Aarch64で動作するようになりました。
- 2個の
GarbageCollectorMXBean
がZGCで公開され、GCサイクルとGCポーズの両方の情報を提供するようになりました。
ZGCに関する詳細情報は、OpenJDK WikiやInside JavaのGCセクション、もしくはこのブログをご覧ください。
OpenJDK ZGC Wiki
https://wiki.openjdk.java.net/display/zgc/Main
Inside Java – Garbage Collectors
https://inside.java/tag/gc
原文著者のブログ
https://malloc.se/