X

A blog about Oracle Technology Network Japan

JDKの超高速な新ガベージ・コレクタを理解する

Guest Author

※本記事は、Raoul-Gabriel Urma と Richard Warburtonによる"Understanding the JDK’s New Superfast Garbage Collectors"を翻訳したものです。


ZGCとShenandoah、そして改善版のG1により、今までになく停止しないJavaが実現

 

著者:Raoul-Gabriel Urma、Richard Warburton

2019年11月21日

 

ここ半年間で行われてきた開発のうち、特にエキサイティングなものがJDKのガベージ・コレクタ(GC)の内部処理に関するものです。本記事ではさまざまな改善について説明しますが、その多くはJDK 12で初登場し、JDK 13でも引き続き行われているものです。まずは、Shenandoahについて説明します。Shenandoahは低遅延GCで、アプリケーションとほぼ同時に処理されます。また、JDK 12リリースに含まれるZGC(Java 11で導入された低遅延コンカレントGC)に対して行われた最新の改善にも触れたいと思います。さらに、Garbage First(G1)GCにおける2つの改善点について詳しく説明します。G1はJava 9以降でのデフォルトGCです。

 

GCの概要

Javaは非常に生産性が高い言語です。CやC++などの古い言語と比べた場合、開発者にとってJavaの利点の1つとなるのは、ガベージ・コレクションを使えることです。Java開発者である皆さんが、メモリの場所を明示的に解放せずに、メモリ・リークを起こしてしまうことについて心配する必要はほとんどありません。また、メモリを使い終わる前に解放してしまい、アプリケーションをクラッシュさせることを心配する必要もありません。ただし、ガベージ・コレクションは大きな生産性をもたらす反面、開発者は幾度となくパフォーマンスへの影響を懸念してきました。ガベージ・コレクションによってアプリケーションの速度が低下することはないでしょうか。また、アプリケーションが何度も止まってユーザー・エクスペリエンスが悪化することはないでしょうか。

多数存在するガベージ・コレクション・アルゴリズムについて、何年もかけて試行やテストが行われ、パフォーマンスの改善が繰り返されています。そのようなアルゴリズムのパフォーマンスは、一般的に2つの部分に分けて考えることができます。1つ目は、ガベージ・コレクションのスループットです。これは、アプリケーションのCPUタイムのうち、アプリケーションのコードの実行ではなく、ガベージ・コレクション作業の実行に使われる時間を意味します。2つ目は、その結果として生じる遅延、すなわち個々の一時停止による待ち時間です。

多くの停止型GC(たとえば、Java 9より前のデフォルトGCだったParallel GC)では、アプリケーションのヒープ・サイズを増やすことで、スループットは向上しますが、最長の一時停止時間は長くなります。このタイプのGCでは、ヒープの増加によりガベージ・コレクション・サイクルの実行頻度が低下するため、ガベージ・コレクションの作業効率は向上します。しかし、それぞれのサイクルで実行する作業が増えることから、個々の一時停止時間は長くなります。つまり、大きなヒープでParallel GCを使用した場合、一時停止時間が長くなる可能性があります。割り当てられた古い世代のオブジェクトを回収するためにかかる時間は、世代のサイズ、すなわちヒープのサイズに応じて増加するからです。しかし、インタラクティブではないバッチ・ジョブなどを実行している場合は、Parallel GCが効率的なコレクタとなる可能性もあります。

Java 9以降のOpenJDKとOracle JDKでは、G1コレクタがデフォルトのGCとなっています。G1がガベージ・コレクションを行う際のアプローチを大まかに表現するなら、ユーザーが提供する目標時間に応じて、GCによる一時停止を細かく分割するというものです。つまり、一時停止時間を短くしたい場合は、目標時間を短くすればよいことになります。逆に、GCに使われるCPUを減らしてアプリケーションに使われるCPUを増やしたい場合は、目標時間を長くします。Parallel GCはスループット重視のコレクタでしたが、G1は万能型を目指しており、スループットを下げて一時停止時間を短くする仕組みを提供しています。

ただし、G1を使えば一時停止時間を自在に操れるというわけではありません。ヒープのサイズが巨大であること、または短時間に多数のオブジェクトが割り当てられることが原因で、1回のガベージ・コレクション・サイクルで行うべき作業量が増加するにつれて、一時停止時間を分割するというアプローチにも限界が見えてきます。たとえるなら、大きな塊の食べ物を細かく切れば、消化しやすくはなるものの、皿の上の食べ物が多すぎれば、食べ終えるまでに長い時間がかかるということです。ガベージ・コレクションにも同じことが言えます。

この点が、JDK 12のShenandoah GCによって解決しようとしている問題の領域です。Shenandoahは遅延に関するスペシャリストで、ヒープが大きくても一時停止時間を一貫して短く抑えることができます。Parallel GCと比べた場合、ガベージ・コレクション作業の実行にかかるCPUタイムがわずかに増加する可能性がありますが、一時停止時間は大幅に短縮されます。これは、金融やギャンブル、広告といった業界の低遅延システムには非常に有効です。インタラクティブなWebサイトでも、一時停止時間が長いことによってユーザーがいらいらする可能性がある場合は、同じことが言えます。

本記事では、これらのGCの最新バージョンと、G1の最新アップデートについて説明します。皆さんのアプリケーションに最適な機能バランスを実現するうえで、参考になれば幸いです。

 

Shenandoah

Shenandoahは、JDK 12の一部としてリリースされた新しいGCです。実は、Shenandoahの開発作業は、JDK 8uおよび11uリリースの改善としてバックポートされています。これまでJDK 12にアップグレードする機会がなかったという方にとっては朗報でしょう。

Shenandoahに切り替える方がいい場合と、その理由について考えてみます。Shenandoahの内部の仕組みについて詳しく触れることはできませんが、このテクノロジーに興味がある方は、本号の記事や、OpenJDK WikiのShenandoahのページをご覧ください。

ShenandoahがG1より優れている主な点は、アプリケーション・スレッドに対するガベージ・コレクション・サイクルの同時実行性が高いことです。G1では、アプリケーションを一時停止しなければヒープ領域を退避(オブジェクトを移動)できません。一方のShenandoahでは、アプリケーションを実行しながらオブジェクトを再配置することができます。この同時再配置を実現するために使われているのが、ブルックス・ポインタと呼ばれるものです。このポインタは、Shenandoahヒープ内のすべてのオブジェクトが持つ追加フィールドで、それぞれのオブジェクト自体を指しています。

なぜこのようなことをしているのでしょうか。オブジェクトを移動する場合、ヒープ内にあってそのオブジェクトを参照しているすべてのオブジェクトを修正する必要があります。Shenandoahでは、オブジェクトを新しい場所に移動するとき、古いブルックス・ポインタはそのままにして、参照をオブジェクトの新しい場所に転送します。オブジェクトが参照された場合、アプリケーションでは転送ポインタをたどって新しい場所を参照します。転送ポインタを持つ古いオブジェクトは、最終的にはクリーンアップする必要があります。しかし、オブジェクト自体を移動する手順からクリーンアップ操作を切り離すことにより、Shenandoahではオブジェクトの同時再配置が容易に実現可能となっています。

Java 12以降のアプリケーションでShenandoahを使うためには、次のオプションを使ってShenandoahを有効化します。

-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC

現時点でいきなりJava 12への移行はできないものの、Shenandoahを試してみたいという方は、Java 8とJava 11へのバックポートを利用できます。なお、Oracleが提供するJDKビルドではShenandoahを利用できない点は覚えておいてください。しかし、他のOpenJDKディストリビュータによるビルドでは、デフォルトの状態でShenandoahを使うことができるようになっています。Shenandoahについての詳細は、JEP 189をご覧ください。

コンカレントGCの選択肢は、Shenandoahだけではありません。ZGCもまた、OpenJDK(Oracleのビルドも含む)で提供されているGCです。ZGCもJDK 12で改善が行われています。そのため、ガベージ・コレクションによる一時停止問題の影響を受けているアプリを抱えており、Shenandoahを試してみたいと考えている方は、次に説明するZGCも確認してみることをお勧めします。

 

同時クラス・アンロード機能が搭載されたZGC

ZGCの主な目的は、低遅延、スケーラビリティ、使いやすさです。これを実現するため、ZGCでは、スレッド・スタックのスキャンを除くすべてのガベージ・コレクション操作を行っている間も、Javaアプリケーションの実行を継続できるようにしています。さらに、数百MBからTBサイズまでのJavaヒープに対応しつつも、通常は2 ms以内という非常に短い一時停止時間を一貫して維持しています。

一時停止時間が短く予測可能であることは、アプリケーション開発者とシステム・アーキテクトの両方にとって重要なことでしょう。開発者は、ガベージ・コレクションによる一時停止を避けるため、巧妙に設計しようと悩む必要がなくなります。また、確実に短い一時停止時間を実現することは、非常に多くのユースケースにおいて大変重要なことですが、システム・アーキテクトも、そのためにGCパフォーマンス・チューニングに特化した専門知識を求められることがなくなります。ビッグ・データを扱うものなど、大量のメモリを必要とするアプリケーションにZGCが適しているのはそのためです。ただし、ヒープが小さくても、一時停止時間を予測可能な極めて短い時間にとどめる必要がある場合は、ZGCが有望な候補になります。

ZGCは、JDK 11に試験運用版機能として追加されました。JDK 12のZGCには、同時クラス・アンロードのサポートが追加されました。このサポートにより、使われていないクラスをアンロードする間もJavaアプリケーションは一時停止せずに実行を続けられるようになりました。

同時クラス・アンロードの実行には複雑な処理が必要です。そのため、クラスのアンロードは、すべてを一時停止させたStop-The-Worldの状態で行われるのが昔からの慣例でした。使われなくなった一連のクラスを特定するためには、まず参照処理を行う必要があります。続いて、ファイナライザの処理があります。私たちにとっては、Object.finalize()メソッドの実装です。参照処理の一部として、ファイナライザから到達できる一連のオブジェクトを検索する必要があります。ファイナライザでは、無制限にチェーンできるリンクを介して、クラスを推移的に生存させている可能性があるからです。ファイナライザから到達できるすべてのオブジェクトにアクセスした場合、膨大な時間がかかる可能性があります。最悪のケースでは、1つのファイナライザからJavaヒープ全体に到達する可能性すらあります。ZGCでは、Javaアプリケーションを実行しながら参照処理を行います(JDK 11でZGCが導入された時点でこの動作になっています)。

参照処理の終了後、不要になったクラスがZGCで認識されます。こういったクラスが破棄された結果、無効な古いデータを含むデータ構造が生まれます。次のステップでは、そういったデータ構造をすべて削除します。利用中のデータ構造から無効になったデータ構造へのリンクは削除されます。このリンク解除操作を行うためにたどる必要があるデータ構造には、いくつかの内部JVMデータ構造が含まれています。たとえば、コード・キャッシュ(すべてのJITコンパイルされたコードを含む)、クラス・ローダーのデータ・グラフ、文字列表、シンボル表、プロファイル・データなどです。無効なデータ構造へのリンクを解除する操作が終了した後は、無効なデータ構造を再度たどって削除し、最終的にはメモリを再利用できるようにする操作が行われます。

現在に至るまで、すべてのJDKのGCでは、以上のすべてをStop-The-World操作の中で行ってきました。Javaアプリケーションの遅延問題の原因はそこにあります。低遅延GCにとって、この動作は問題です。そこでZGCでは、このすべてをJavaアプリケーションと同時に実行するようになっています。そのため、クラスのアンロードをサポートする代わりに遅延というペナルティを払うことはありません。実際のところ、同時クラス・アンロードを行うために導入されたメカニズムによって、遅延はさらに少なくなりました。現在、ガベージ・コレクションに伴うStop-The-Worldの一時停止時間は、アプリケーションのスレッド数にのみ比例するようになっています。このアプローチが一時停止時間に及ぼす大きな影響を示したのが図1です。

他のGCと比較したZGCの休止時間

図1:他のGCと比較した、ZGCの一時停止時間

現在、ZGCは、Linux/x86 64ビット・プラットフォームとJava 13以降のLinux/Aarchで試験運用版GCとして利用できます。次のコマンドライン・オプションを使うことで、ZGCを有効化することができます。

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

ZGCについての詳細は、OpenJDK Wikiをご覧ください。

 

G1の改善

組織によっては、ランタイム・システムで試験運用版GCを使用できないことがあります。そういう方にうれしいお知らせなのは、G1にもいくつかの改善が行われていることです。G1コレクタでは、ガベージ・コレクション・サイクルを複数の一時停止時間に分割します。

割り当てられたオブジェクトは当初、「Young」世代の一部と見なされます。複数回のガベージ・コレクション・サイクルを生き残ると、やがて「身分保障」を得て、「Old」と見なされるようになります。G1内の各領域には、1つの世代のオブジェクトしか含まれません。そのため、Young領域、Old領域と呼ぶことができます。

G1が一時停止時間の目標を満たすためには、目標とする一時停止時間内に行うことができる作業の量を特定し、時間切れになる前にその作業を終えることができなければなりません。G1には、適切なサイズの作業を特定するための一連の複雑な経験則が組み込まれています。この経験則により、必要な作業時間を巧みに予測しますが、経験則が常に正確とは限りません。さらに事態を複雑にしているのは、G1ではYoung領域の一部のみを回収することはできないという事実です。G1では、1回のガベージ・コレクション・パスでYoung領域全体を回収します。

Java 12では、G1の回収による一時停止を中断できる機能が追加され、この状況が改善されています。G1では、回収する領域数を経験則によって予測する際の正確さを追跡し、必要な場合に中断可能なガベージ・コレクションのみを続行します。具体的には、回収セット(1回のサイクルでガベージ・コレクションが行われる一連の領域)を、必須領域とオプション領域という2つのグループに分割して続行します。

必須領域は、GCサイクルで必ず回収されます。オプション領域は時間が許せば回収されますが、回収されずに時間切れになった場合、回収パスは中断されます。必須領域には、すべてのYoung領域に加え、一部のOld領域が含まれる場合があります。Old世代の領域が必須領域に追加される条件は、オブジェクトの退避を確実に続行できるようにする場合と、予測されている一時停止時間を使い切る場合の2つです。

追加する領域数を計算するための経験則では、回収セット候補内の領域数を-XX:G1MixedGCCountTargetの値で割った数が使用されて続行されます。G1では、Old世代の領域をさらに回収する時間があると予測した場合、他のOld領域も必須領域セットに追加します。この追加は、利用可能な一時停止時間の80%を使い切ると見込まれるまで行われます。

この作業の結果、G1では混合GCサイクルの中断も終了も可能ということになります。それにより、GCに起因する一時停止遅延は少なくなり、G1では高い確率で、一時停止時間の目標の達成頻度増加が可能になります。この改善点についての詳細は、JEP 344をご覧ください。

 

コミット済みの未使用メモリを即時返却

Javaに非常によく向けられる批判は、メモリを大量に消費するというものですが、もはやそのようなことはなくなります。JVMに必要以上のメモリが割り当てられているのは、コマンドライン・オプションによる指定のためであることもあります。しかし、メモリ関連のコマンドライン・オプションを指定していなければ、必要以上のメモリを割り当てているのはJVMでしょう。結局使われないRAMを割り当てるのはお金の無駄です。すべてのリソースが計測され、その量に応じて課金されるクラウド環境では、特にそうです。しかし、どうすればこの事態を解決し、Javaのリソース消費量を改善することができるのでしょうか。

一般的には、JVMが処理しなければならないワークロードは時間に応じて変化します。多くのメモリを必要とするときもあれば、必要としないときもあります。実際には、この点が影響することは多くありません。JVMは、起動時に大量のメモリを割り当て、必要ない場合でもそのメモリを確保し続ける傾向にあるからです。理想的には、他のアプリケーションやコンテナが使用できるように、未使用のメモリがJVMからオペレーティング・システムに返却されるようにすることもできます。Java 12ではこのような、未使用メモリの返却が可能になっています。

G1には、未使用メモリを解放する機能がすでに搭載されています。しかし、その解放が行われるのは、完全なガベージ・コレクション・パスの間のみです。多くの場合、完全なガベージ・コレクション・パスの実行頻度は低く、実行が好まれるものでもありません。Stop-The-Worldによる長時間のアプリケーション一時停止を伴う可能性があるからです。JDK 12のG1は、同時ガベージ・コレクション・パスの間に未使用メモリを解放できるようになりました。この機能は、ヒープがほぼ空の場合に特に有用です。ヒープがほぼ空の場合、GCサイクルがメモリを回収してオペレーティング・システムに返却する時間がかかる可能性があります。Java 12のG1では、メモリが即座にオペレーティング・システムへと確実に返却されるように、コマンドラインのG1PeriodicGCInterval引数で指定された期間にガベージ・コレクション・サイクルが発生しなかった場合に限り、同時ガベージ・コレクション・サイクルを呼び出そうとします。そして、この同時ガベージ・コレクション・サイクルの終了時に、メモリがオペレーティング・システムに返却されます。

この定期的な同時ガベージ・コレクション・パスによって不要なCPUオーバーヘッドが加わらないことが保証されるようにするため、システムの一部がアイドル状態であるときに限って実行されるようになっています。この同時実行サイクルを呼び出すか呼び出さないかを判断する基準となる測定値は、1分当たりのシステム負荷の平均値です。この値が、G1PeriodicGCSystemLoadThresholdで指定された値よりも低くなければなりません。

詳細については、JEP 346をご覧ください。

 

まとめ

本記事では、アプリケーションでのGCによる一時停止時間についての悩みを解消できるいくつかの方法を紹介しました。G1の改善は続くものの、ヒープ・サイズが増加し、一時停止時間が許容されにくくなるにつれて、ShenandoahやZGCなどの新しいGCによって、スケーラブルで一時停止時間の短い未来が実現することを覚えておいて損はありません。


Raoul-Gabriel Urma

Raoul-Gabriel Urma(@raoulUK):イギリスのデータ・サイエンティストや開発者の学習コミュニティをリードするCambridge SparkのCEO/共同創業者。若いプログラマーや学生のコミュニティであるCambridge Coding Academyの会長/共同創設者でもある。ベストセラーとなったプログラミング関連書籍『Java 8 in Action』(Manning Publications、2015年)の共著者として執筆に携わった。ケンブリッジ大学でコンピュータ・サイエンスの博士号を取得している。

 

Richard Warburton

Richard Warburton(@richardwarburto):Java Championであり、ソフトウェア・エンジニアの傍ら、講師や著述も行う。ベストセラーとなった『Java 8 Lambdas』(O'Reilly Media、2014年)の著者であり、Iteratr LearningとPluralsightで開発者の学習に貢献し、数々の講演やトレーニング・コースを実施している。ウォーリック大学で博士号を取得している。

Be the first to comment

Comments ( 0 )
Please enter your name.Please provide a valid email address.Please enter a comment.CAPTCHA challenge response provided was incorrect. Please try again.