ZGC | What’s new in JDK 16

原文はこちら。
The original article was written by Per Lidén (software engineer, Oracle).
https://malloc.se/blog/zgc-jdk16

JDK 16がリリースされました。いつも通り、新リリースにはたくさんの新機能や機能強化、バグ修正などが含まれています。JDK 16のZGCには、46個の機能強化25個のバグ修正が入っています。以下で興味深い機能強化についてご紹介します。

JDK 16
https://openjdk.java.net/projects/jdk/16
ZGC OpenJDK Wiki
https://wiki.openjdk.java.net/display/zgc

Sub-milliseond Max Pause Times

(a.k.a. Concurrent Thread-Stack Processing)

ZGCプロジェクトを開始したときの目標は、GCポーズに10msec以上の時間をかけないことでした。当時、10msecは野心的な目標に思えました。HotSpotの他のGCでは、特に大規模なヒープを使用した場合、最大のポーズ時間はそれ(10msec)よりも数倍悪くなります。この目標を達成するには、再配置、参照処理、クラスのアンロードなどの本当に重い作業を、Stop-The-Worldフェーズではなく、コンカレントフェーズで行うことが重要でした。当時のHotSpotには、このような同時処理を行うために必要なインフラの多くが欠けていたため、開発には数年を要しました。

最初の目標である10msecを達成した後、私たちは再度目標を定め、より野心的な目標、GCのポーズ時間は1msecを超えてはならないという目標を設定しました。JDK 16で、この目標も達成できたことをご報告します。ZGCの ポーズ時間はO(1)となりました。言い換えれば、 ポーズ時間は一定の時間で実行され、ヒープ、ライブセット、ルートセットのサイズに応じて増加することはありません(その他についても同じです)。もちろん、GCスレッドに CPU時間が渡されるかどうかは、OSのスケジューラ次第です。しかし、システムが過度にオーバープロビジョニングされていない限り、平均的なGCポーズ時間は約0.05msec(50μsec)、最大のポーズ時間は約0.5msec(500μsec)であることが期待できます。

それでは、ここまで何をやってきたかといいますと、JDK 16以前は、ZGCのポーズ時間はルートセットの(サブセットの)サイズに比例していました。より正確に言うと、Stop-The-Worldフェーズでスレッドスタックをスキャンしていました。これはつまり、Java アプリケーションに多数のスレッドがある場合、ポーズ時間が長くなっていました。また、これらのスレッドが深いコールスタックを持っている場合、ポーズ時間はさらに長くなります。JDK 16からは、スレッドスタックのスキャンが同時進行、つまり、Javaアプリケーションが実行され続けている間に行われるようになりました。

スレッド動作中にスレッドスタックを探るには、ちょっとしたマジックが必要なのは想像できるかと思います。これを実現するのがスタックウォーターマークバリア(Stack Watermark Barrier)と呼ばれるものです。簡単に言うと、Javaスレッドがスタックフレームに戻っても安全かどうかを最初にチェックしないと、スタックフレームに戻ることができないようにする仕組みです。これは、メソッドのリターン時に行われるセーフポイントのチェックに組み込まれている、低コストのチェックです。概念的には、スタック・フレームのロード・バリア(load barrier)と考えることができ、必要に応じて、スタック・フレームに戻る前に、Javaスレッドにスタック・フレームを安全な状態にするための何らかのアクションを取らせることができます。各Javaスレッドには、1 つ以上のスタックウォーターマークがあり、これらのウォーターマークは、 特別な操作をしなくても、スタックのどこまでが安全かをバリアーに伝えます。ウォーターマークを通過するためには、時間を要する経路を採用して1つまたは複数のフレームを現在の安全な状態にした上で、ウォーターマークを更新します。すべてのスレッドスタックを安全な状態にする作業は、通常、1つまたは複数のGCスレッドによって処理されますが、これが同時に行われるため、Javaスレッドは、GCがまだ手をつけていないフレームに戻ってきた場合、いくつかの自分のフレームを修正しなければならないことがあります。詳細に興味のある方は、JEP376をご覧ください。

JEP 376: ZGC: Concurrent Thread-Stack Processing
https://openjdk.java.net/jeps/376

JEP 376導入により、ZGCはStop-The-Worldフェーズで正確にゼロ個のルートをスキャンするようになりました。多くのワークロードでは、JDK 16以前から最大ポーズ時間が非常に短くなっていました。しかし、大規模なマシンで動作し、ワークロードに多数のスレッドがある場合は、最大ポーズタイムが1msecを大きく上回ることもありました。この改善を可視化するために、数千のJavaスレッドを持つ大規模マシンでSPECjbb®2015をJDK 15とJDK 16で実行し、比較してみました。

In-Place Relocation

JDK 16では、ZGCがインプレース・リロケーションをサポートしました。この機能は、ヒープがすでにいっぱいになっているときにGCがガベージを収集する必要がある状況で、OutOfMemoryErrorを回避するのに役立ちます。通常、ZGCはヒープをコンパクトにしてメモリ解放するために、まばらなヒープ領域のオブジェクトを、密に詰め込むことが可能な1個もしくは複数の空のヒープ領域に移動させます。この戦略は単純明快で、並列処理に適しています。しかし、この方法には一つの欠点があります。再配置のプロセスを開始するためには、ある程度の空きメモリ(各サイズの空のヒープ領域が少なくとも1つ)が必要です。ヒープが満杯の場合、つまりすべてのヒープ領域がすでに使用されている場合は、オブジェクトを移動する場所がありません。

JDK16以前のZGCでは、ヒープリザーブ(heap reserve)を用意してこの問題を解決していました。このヒープリザーブは、確保されたヒープ領域のセットで、Javaスレッドからの通常の割り当てには利用できませんでした。その代わり、オブジェクトを再配置する際には、GCだけがヒープリザーブを使用することができました。これにより、Java スレッドから見てヒープが満杯であっても、再配置プロセスを開始するための空のヒープ領域が利用可能であることが保証されます。ヒープリザーブは通常、ヒープのごく一部でした。以前のブログエントリでは、JDK 14でどのように改善し、小さなヒープをより良くサポートするようになったかを書いています。

Tiny Heaps (ZGC | What’s new in JDK 14)
https://malloc.se/blog/zgc-jdk14#tiny-heaps

しかし、ヒープリザーブのアプローチにはいくつかの問題がありました。たとえば、再配置を行う Java スレッドはヒープリザーブを利用できないため、再配置処理が完了することが保証されず、GC が(十分な)メモリを再利用できませんでした.通常のワークロードでは問題ありませんが、テストの結果この問題を引き起こすようなプログラムを作成することが可能であり、その結果、早期にOutOfMemoryErrorが発生しました。また、再配置に備えてヒープの一部を(小さい領域ながらも)確保しておくことは、ほとんどのワークロードではメモリの無駄遣いになります。

連続したメモリの塊を解放するもう一つの方法は、ヒープをその場で圧縮することです。他のHotSpotコレクター(G1、Parallel、Serialなど)は、いわゆるFull GCを行う際に、この方法を採用しています。このアプローチの主な利点は、メモリを解放するためのメモリを必要としないことです。言い換えれば、ある種のヒープリザーブを必要とせずに、いっぱいになったヒープをコンパクトにしてます。

しかし、インプレースでのヒープのコンパクト化にはいくつかの課題もあり、一般にオーバーヘッドを伴います。例えば、オブジェクトを移動させる順番が重要になります。そうしないと、まだ移動していないオブジェクトを上書きしてしまう恐れがあるからです。このため、GCスレッド間の調整が必要になりますが、これは並列処理にはあまり適していません。また、JavaスレッドがGCに代わってオブジェクトを再配置する際にできること、できないことにも影響します。

まとめると、どちらのアプローチにも利点があります。空のヒープ領域が利用可能な場合には、インプレースで再配置しない方が一般的にパフォーマンスが高くなります。一方、インプレースで再配置すると、空のヒープ領域が利用できない場合でも再配置プロセスが正常に完了することが保証されます。

JDK 16から、ZGCは両方のアプローチを使い分けるようになりました。これにより、一般的なケースでは良好な再配置のパフォーマンスを維持し、エッジケースでは再配置が常に正常に完了することを保証しながら、ヒープリザーブの必要性をなくすことができます。デフォルトでは、オブジェクトを移動できる空のヒープ領域がある限り、ZGCはインプレース再配置を行いません。そうでない場合は、ZGCはインプレース再配置に切り替えます。空のヒープ領域が利用可能になると、ZGCは再びインプレース再配置を行わないように切り替えます。

これらの再配置モード間の切り替えはシームレスに行われ、必要であれば同じGCサイクル内で複数回切り替わります。しかし、ほとんどのワークロードでは、そもそも切り替えが必要になる状況に遭遇することはありません。しかし、ZGCがこのような状況にうまく対処し、ヒープのコンパクト化に失敗して時期尚早すぎるOutOfMemoryErrorを投げることがないとわかっていれば、多少の安心感が得られるはずです。

ZGCのログも拡張され、各サイズグループ(Small/Medium/Large)のヒープ領域(ZPages)が何個その場で再配置されたかを示すようになりました。ここでは、54MB相当のスモールオブジェクトが再配置され、3個のスモールページがインプレース再配置される必要があった例を示しています。

          ...
          GC(15) Small Pages: 120 / 240M, Empty: 0M, Relocated: 54M, In-Place: 3
          GC(15) Medium Pages: 2 / 64M, Empty: 0M, Relocated: 0M, In-Place: 0
          GC(15) Large Pages: 1 / 4M, Empty: 0M, Relocated: 0M, In-Place: 0
          ...

Allocation & Initialization of Forwarding Tables

ZGCがオブジェクトを再配置すると、そのオブジェクトの新しいアドレスは、Javaヒープの外側に割り当てられたデータ構造である転送テーブル(forwarding table)に記録されます。再配置セット(メモリを解放するために圧縮するヒープ領域のセット)の一部として選択された各ヒープ領域には、ヒープ領域に関連付けられた転送テーブルがあります。

JDK16以前では、再配置セットが非常に大きい場合、転送テーブルの割り当てと初期化がGCサイクルタイム全体のかなりの部分を占めていました。再配置セットのサイズは、再配置中に移動したオブジェクトの数と相関があります。例えば、100GBを超えるヒープがあり、ワークロードによって断片化が多数発生し、小さな穴がヒープ全体に均等に配置されている場合、再配置セットは大きくなり、その割り当て/初期化には時間がかかります。もちろん、この作業は常に同時進行のフェーズで行われていたので、GCのポーズ時間に影響を与えることはありませんでした。それでも、ここには改善の余地がありました。

JDK 16では、ZGCが転送テーブルを一括して割り当てるようになりました。各テーブルにメモリを割り当てるためにmalloc/newを何度も(潜在的には何千回も)呼び出すのではなく、現在は1回の呼び出しですべてのテーブルが必要とするすべてのメモリを一度に割り当てるようになっています。これにより、典型的なアロケーションのオーバーヘッドとロック競合の可能性を回避し、これらのテーブルのアロケーションにかかる時間を大幅に短縮しています。

また、これらのテーブルの初期化もボトルネックの一つでした。転送テーブルはハッシュテーブルなので、初期化するには小さなヘッダーを設定し、転送テーブルのエントリーの(大きな可能性がある)配列をゼロにする必要があります。JDK 16以降、ZGCはこの初期化をスレッド1個ではなく、複数のスレッドを使って並列に行うようになりました。

まとめると、これらの変更により、転送テーブルの割り当てと初期化にかかる時間が大幅に短縮されました。特に、まばらにオブジェクトが存在する、非常に大きなヒープに対してGCを実施する場合には、1~2倍の時間短縮効果があります。

Summary

  • スレッドスタックの並列スキャンにより、ZGCのポーズ時間はマイクロ秒単位になり、平均ポーズ時間は約50µsec、最大ポーズ時間は約500µsecになりました。ポーズ時間は、ヒープ、ライブセット、ルートセットのサイズの影響を受けません。
  • ヒープリザーブがなくなったことで、ZGCは必要に応じてインプレース再配置を行います。これはメモリを節約するだけでなく、すべての状況でヒープが正常に圧縮されることを保証します。
  • 転送テーブルはより効率的に割り当てられ、初期化されるようになりました。これにより、特にオブジェクトがまばらに配置されている大きなヒープのGCにおいて、GCサイクルを完了するまでの時間が短縮されます。

ZGCの詳細については、OpenJDK Wiki、Inside JavaのGCセクション、または原文著者のブログをご覧ください。

OpenJDK Wiki
https://wiki.openjdk.java.net/display/zgc/Main
Inside Java – GC section
https://inside.java/tag/gc
perliden(原文著者のブログ)
https://malloc.se/

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中