JVMのガベージコレクション:アルゴリズム概要と実装手法

ガベージコレクションの概要

1. JavaとC++言語の違いは、ガベージコレクション技術とメモリ動的割り当てにあります。C++言語にはガベージコレクション機能がなく、プログラマが手動で収集する必要があります。

2. ガベージコレクションはJava言語の副産物ではありません。1960年に最初にメモリ動的割り当てとガベージコレクション技術を使用したLisp言語が誕生しました。

3. ガベージコレクションに関する三つの古典的な質問:

  • どのメモリを回収すべきか?
  • いつ回収するか?
  • どのように回収するか?

4. ガベージコレクションメカニズムはJavaの特徴的な機能であり、開発効率を大幅に向上させます。現在では、ガベージコレクションは現代言語の標準装備となっています。長期間の発展を経ても、Javaのガベージコレクションメカニズムは進化を続けています。異なるサイズのデバイスや異なる特性を持つアプリケーションシナリオにより、ガベージコレクションには新たな課題が提示されています。これは面接のホットトピックでもあります。

大手企業の面接問題

アリババグループ

1. どのようなガベージコレクタを知っていますか?それぞれの利点と欠点を説明してください。特にCMSとG1について詳しく説明してください。

2. JVM GCアルゴリズムにはどのようなものがありますか?現在のJDKバージョンではどのような回収アルゴリズムが採用されていますか?

3. G1コレクタの回収プロセスについて説明してください。GCとは何か?なぜGCが必要なのか?

4. GCの二つの判定方法とは?CMSコレクタとG1コレクタの特徴

百度

1. GCアルゴリズムについて説明し、世代別回収についても述べてください。

2. ガベージ収集戦略とアルゴリズム

天猫

1. JVM GCの原理、JVMがメモリを回収する方法

2. CMSの特徴、ガベージコレクションアルゴリズムにはどのようなものがありますか?それぞれの利点と欠点、共通の欠点は何か?

滴滴出行

1. Javaのガベージコレクタにはどのようなものがありますか?G1の応用シーンを説明してください。通常、ガベージコレクタをどのように組み合わせて使用していますか?

京東

1. どのようなガベージコレクタを知っていますか?それぞれの利点と欠点を説明してください。特にCMSとG1について詳しく説明してください。

2. 原理、プロセス、利点と欠点を含めてください。ガベージコレクションアルゴリズムの実装原理

アリババクラウド

1. ガベージコレクションアルゴリズムについて説明してください。

2. どのような状況でガベージコレクションがトリガーされますか?

3. 適切なガベージコレクションアルゴリズムを選択する方法は?

4. JVMにはどのような種類のガベージコレクタがありますか?

ByteDance

1. 一般的なガベージコレクションアルゴリズムにはどのようなものがありますか?それぞれの利点と欠点は?

2. System.gc()とRuntime.gc()は何を行うのですか?

3. Java GCメカニズム?GC Rootsにはどのようなものがありますか?

4. Javaオブジェクトの回収方法、回収アルゴリズム。

5. CMSとG1を知っていますか?CMSが解決する問題を説明し、回収プロセスを述べてください。

6. CMS回収時に何回停止しますか?なぜ二回停止するのですか?

ガベージとは何か?

1. ガベージとは実行中のプログラムでどのポインタからも参照されていないオブジェクトを指し、このオブジェクトは回収対象のガベージです。

2. 英語表現:An object is considered garbage when it can no longer be reached from any pointer in the running program.

3. メモリ内のガベージを適切にクリーンアップしない場合、これらのガベージオブジェクトが占有するメモリ空間はアプリケーション終了まで保持され続けます。他のオブジェクトがこのスペースを使用できません。さらにメモリオーバーフローを引き起こす可能性があります。

なぜGCが必要なのか?

GCを学習する前に、なぜGCが必要なのかを理解する必要があります。

1. 高級言語において、基本的な認識はガベージコレクションを行わない場合、メモリはいずれ使い果たされるということです。メモリ領域を継続的に割り当てて回収しないことは、ごみを生産し続けるだけで掃除しないようなものです。

2. 使われなくなったオブジェクトの解放に加え、ガベージコレクションはメモリ内の断片もクリアできます。断片整理により、使用中のヒープメモリをヒープの一方に移動し、JVMが新しいオブジェクトに整理されたメモリを割り当てられるようにします

3. アプリケーションが処理するビジネスがますます巨大で複雑になり、ユーザー数が多くなるにつれて、GCがないとアプリケーションの正常な動作を保証できません。STWを頻繁に引き起こすGCは実際の要求に追いつかないので、GCの最適化が常に試みられています。

初期のガベージコレクション

1. 初期のC/C++時代、ガベージコレクションは基本的に手動で行われていました。開発者はnewキーワードでメモリを確保し、deleteキーワードでメモリを解放していました。例えば以下のコード:

  MemoryBlock* pBlock = new BaseGroupMemoryBlock();
  // 登録失敗時、deleteでオブジェクトが占有するメモリ領域を解放
  if(pBlock->registerFunction(DestroyFlag) != SUCCESS_CODE) {
      delete pBlock;
  }

2. この方法はメモリ解放のタイミングを柔軟に制御できますが、開発者にとって頻繁なメモリ確保と解放の管理負荷を与えます。あるメモリ領域がプログラマのコーディング問題により回収され忘れた場合、メモリリークが発生し、ガベージオブジェクトは永遠にクリアできず、システムの実行時間が延びるにつれてガベージオブジェクトの消費メモリが増加し、最終的にはメモリオーバーフローを引き起こしてアプリケーションがクラッシュします。

3. ガベージコレクションメカニズムがあれば、上記コードは以下のように変化する可能性があります

  MemoryBlock* pBlock = new BaseGroupMemoryBlock(); 
  pBlock->registerFunction(DestroyFlag);

4. 現在、Javaだけでなく、C#、Python、Rubyなどの言語も自動ガベージコレクションの思想を使用しており、将来の傾向でもあります。このような自動メモリ割り当てとガベージ回収方式は、現代の開発言語に必須の標準となっています。

Javaガベージコレクションメカニズム

自動メモリ管理

公式サイト紹介:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html

自動メモリ管理の利点

1. 自動メモリ管理により、開発者がメモリの割り当てと回収に手動で参加する必要がなくなり、メモリリークとメモリオーバーフローのリスクを低下させます。

2. ガベージコレクタがなければ、Javaもcppと同じく、さまざまなダングリングポインタ、ワイルドポインタ、リーク問題に頭を悩ませることになります。

3. 自動メモリ管理メカニズムにより、プログラマは重たいメモリ管理から解放され、ビジネス開発に集中できます。

自動メモリ管理に対する懸念

1. Java開発者にとって、自動メモリ管理はブラックボックスのようなものであり、「自動」に過度に依存すると災害になる可能性があります。最も深刻なのはJava開発者がメモリオーバーフロー発生時の問題特定と解決能力が低下することです。

2. そのため、JVMの自動メモリ割り当てとメモリ回収の原理を理解することが非常に重要です。JVMがメモリをどのように管理しているかを理解すれば、OutOfMemoryErrorが発生した際に迅速にエラーログから問題を特定し解決できます。

3. 各種メモリオーバーフロー、メモリリーク問題の調査が必要な際、ガベージコレクションがシステムのより高い並列処理量のボトルネックとなる際には、これらの「自動化」技術に対して必要な監視と調整を行う必要があります。

どの領域の回収を気にすべきか?

1. ガベージコレクタは若い世代を回収することも、古い世代を回収することも、スタック全体とメソッド領域を回収することもできます。

2. その中で、Javaヒープはガベージコレクタの作業重点です

3. 回収回数に関して:

  1. 若い世代は頻繁に収集
  2. 古い世代は比較的少なく収集
  3. 基本的にはPerm領域(メタスペース)は収集しない

ガベージコレクション関連アルゴリズム

マーク段階:参照カウンティングアルゴリズム

マーク段階の目的

ガベージマーク段階:主にオブジェクトの生存を判断するため

1. ヒープ内にはほぼすべてのJavaオブジェクトインスタンスが格納されています。GCがガベージコレクションを実行する前に、まずメモリ内で生存しているオブジェクトとすでに死亡したオブジェクトを区別する必要があります。死亡したとマークされたオブジェクトのみ、GCがガベージコレクションを実行する際にその占有メモリを解放します。このプロセスをガベージマーク段階と呼ぶことができます。

2. では、JVMではどのように死亡オブジェクトをマークしているのでしょうか?簡単には、あるオブジェクトが他の生存オブジェクトによってさらに参照されていない場合、死亡と宣言できます。

3. オブジェクト生存の判断方法には一般的に二つあります:参照カウンティングアルゴリズム到達可能性分析アルゴリズム

参照カウンティングアルゴリズム

1. 参照カウンティングアルゴリズム(Reference Counting)は比較的単純で、各オブジェクトに対して整数型の参照カウンタプロパティを保持します。オブジェクトが参照されている状況を記録するために使用されます。

2. オブジェクトAについて、他のオブジェクトがAを参照するたびに、Aの参照カウンタは1増えます。参照が無効になると、参照カウンタは1減ります。オブジェクトAの参照カウンタが0になった場合、オブジェクトAはこれ以上使用できないとし、回収可能とします。

3. 利点:実装が簡単で、ガベージオブジェクトの識別が容易。判定効率が高く、回収に遅延がない。

4. 欠点:

  1. カウンタを保存するための別フィールドが必要で、ストレージ容量のオーバーヘッドが発生。
  2. 毎回の代入でカウンタを更新する必要があり、加算と減算操作が伴い、時間オーバーヘッドが発生。
  3. 参照カウンティングには重大な問題があり、循環参照に対処できません。これは致命的な欠陥であり、Javaのガベージコレクタでこの種のアルゴリズムが使用されていない理由です。

循環参照

pのポインタが切断されたとき、内部の参照がループを形成し、カウンタは1のままとなり、回収できません。これが循環参照であり、メモリリークを引き起こします。

証明:Javaは参照カウンティングアルゴリズムを使用していない

/**
 * -XX:+PrintGCDetails
 * 証明:Javaは参照カウンティングアルゴリズムを使用していない
 */
public class ReferenceCountingGC {
    // このメンバー変数の唯一の役割は少しメモリを占有すること
    private byte[] largeBuffer = new byte[5 * 1024 * 1024];//5MB

    Object linkToOther = null;

    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();

        objA.linkToOther = objB;
        objB.linkToOther = objA;

        objA = null;
        objB = null;
        // 明示的にガベージコレクションを実行
        // ここでGCが発生し、objAとobjBは回収されるのか?
        System.gc();

    }
}
  • もし誤ってobjA.linkToOtherobjB.linkToOtherをnullに設定すると、Javaヒープ内の二つのメモリブロックは依然として相互参照を維持し、回収できません。

GCを実行しない場合

以下のコードをコメントアウトし、GCが実行されないようにします

        System.gc();//この行のコメントアウト

Heap
 PSYoungGen      total 38400K, used 14234K [0x00000000d5f80000, 0x00000000d8a00000, 0x0000000100000000)
  eden space 33280K, 42% used [0x00000000d5f80000,0x00000000d6d66be8,0x00000000d8000000)
  from space 5120K, 0% used [0x00000000d8500000,0x00000000d8500000,0x00000000d8a00000)
  to   space 5120K, 0% used [0x00000000d8000000,0x00000000d8000000,0x00000000d8500000)
 ParOldGen       total 87552K, used 0K [0x0000000081e00000, 0x0000000087380000, 0x00000000d5f80000)
  object space 87552K, 0% used [0x0000000081e00000,0x0000000081e00000,0x0000000087380000)
 Metaspace       used 3496K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

Process finished with exit code 0

GCを実行する

コメントアウトを解除します

[GC (System.gc()) [PSYoungGen: 13569K->808K(38400K)] 13569K->816K(125952K), 0.0012717 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 808K->0K(38400K)] [ParOldGen: 8K->670K(87552K)] 816K->670K(125952K), [Metaspace: 3491K->3491K(1056768K)], 0.0051769 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 38400K, used 333K [0x00000000d5f80000, 0x00000000d8a00000, 0x0000000100000000)
  eden space 33280K, 1% used [0x00000000d5f80000,0x00000000d5fd34a8,0x00000000d8000000)
  from space 5120K, 0% used [0x00000000d8000000,0x00000000d8000000,0x00000000d8500000)
  to   space 5120K, 0% used [0x00000000d8500000,0x00000000d8500000,0x00000000d8a00000)
 ParOldGen       total 87552K, used 670K [0x0000000081e00000, 0x0000000087380000, 0x00000000d5f80000)
  object space 87552K, 0% used [0x0000000081e00000,0x0000000081ea7990,0x0000000087380000)
 Metaspace       used 3498K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

Process finished with exit code 0

1. ログから明確にGCが実行されたことがわかります

2. 参照カウンティングアルゴリズムを使用していた場合、これら二つのオブジェクトは回収できませんでした。しかし、今これらのオブジェクトが回収されたことから、Javaが参照カウンティングアルゴリズムを使用していないことがわかります。

まとめ

1. 参照カウンティングアルゴリズムは多くの言語のリソース回収選択肢ですが、人工知能によりより注目されているPythonなど、参照カウンティングとガベージコレクションメカニズムの両方をサポートしています。

2. どちらが最適かはシナリオによるため、大規模実践ではスループットを向上させるために参照カウンティングメカニズムのみを保持する試みがあります。

3. Javaは参照カウンティングを選ばなかったのは、循環参照関係を処理するのが難しいという基本的な問題があるためです。

4. Pythonは循環参照をどう解決するのか?

  • 手動解除:よく理解できます。適切なタイミングで参照関係を解除します。
  • 弱参照weakrefの使用:weakrefはPythonが提供する標準ライブラリで、循環参照を解決するために設計されています。

マーク段階:到達可能性分析アルゴリズム

到達可能性分析アルゴリズム:ルート検索アルゴリズム、トレース型ガベージコレクションとも呼ばれます

1. 参照カウンティングアルゴリズムに比べ、到達可能性分析アルゴリズムは実装が簡単で実行効率が高いという特徴を持つだけでなく、重要なのはこのアルゴリズムが参照カウンティングアルゴリズムにおける循環参照の問題を効果的に解決し、メモリリークの発生を防ぐことです。

2. 参照カウンティングアルゴリズムに比べ、ここでの到達可能性分析はJava、C#が選択したものであり、この種のガベージコレクションはトレース型ガベージコレクション(Tracing Garbage Collection)と呼ばれます。

到達可能性分析の実装アイデア

  • 「GCRoots」ルートセットとは、必ずアクティブな参照のグループです
  • 基本的な考え方は以下の通りです:

1. 到達可能性分析アルゴリズムはルートオブジェクトセット(GCRoots)を起点として、ルートオブジェクトセットが接続するターゲットオブジェクトへの到達可能性を上から下へ検索します

2. 到達可能性分析アルゴリズムを使用後、メモリ内の生存オブジェクトはすべてルートオブジェクトセットに直接または間接的に接続されます。検索経路は参照チェーン(Reference Chain)と呼ばれます。

3. ターゲットオブジェクトに参照チェーンが接続されていない場合、到達不能であるとし、そのオブジェクトは死亡したことを意味し、ガベージオブジェクトとしてマークできます。

4. 到達可能性分析アルゴリズムでは、ルートオブジェクトセットに直接または間接的に接続されるオブジェクトのみが生存オブジェクトです。

GC Rootsにはどのような要素が含まれますか?

1. バーチャルマシンスタックで参照されるオブジェクト

  • 例:各スレッドで呼び出されるメソッドで使用されるパラメータ、ローカル変数など。(myps:ローカル変数テーブルに配置される基本型以外の参照はすべて該当します)

2. ネイティブメソッドスタック内のJNI(通常のネイティブメソッド)で参照されるオブジェクト

3. メソッド領域でクラス静的プロパティが参照するオブジェクト(myps:静的プロパティなのでクラス情報に属し、Java6では永続的に配置され、Java7以降はヒープに配置されました)

  • 例:Javaクラスの参照型静的変数

4. メソッド領域で定数が参照するオブジェクト

  • 例:文字列定数プール(StringTable)内の参照

5. すべての同期ロックsynchronizedが保持するオブジェクト

6. Java仮想マシン内部の参照。

  • 基本データ型に対応するClassオブジェクト、いくつかの常駐する例外オブジェクト(例:NullPointerException、OutOfMemoryError)、システムクラスローダー

7. Java仮想マシン内部状況を反映するJMXBean、JVMTIで登録されたコールバック、ネイティブコードキャッシュなど。

1. 要するに、ヒープ領域の周辺、例えば:仮想マシンスタック、ネイティブメソッドスタック、メソッド領域、文字列定数プールなどでヒープ領域を参照するものはすべてGC Rootsとして到達可能性分析に使用できます。

2. これらの固定GC Rootsセットに加え、ユーザが選択するガベージコレクタおよび現在回収中のメモリ領域に応じて、他のオブジェクトが「一時的」に追加され、完全なGC Rootsセットを構成することもあります。例えば:世代別収集と部分回収(PartialGC)。

  • Javaヒープの特定の領域のみを対象としたガベージコレクション(例:典型的には新生代のみ)の場合、メモリ領域が仮想マシンの実装詳細であり、独立した閉鎖的なものではないことを考慮する必要があります。この領域のオブジェクトが他の領域のオブジェクトによって参照されている可能性があるため、関連する領域オブジェクトもGC Rootsセットに追加して考慮する必要があります。そうでないと到達可能性分析の正確性を保証できません。

便利なテクニック

ルートはスタック方式で変数とポインタを格納するため、あるポインタがヒープメモリ内のオブジェクトを保持しているが自分自身がヒープメモリ内に存在しない場合、それはルートです。

注意点

1. 到達可能性分析アルゴリズムを使用してメモリの回収可能性を判断する場合、分析作業は一貫性のあるスナップショットで実行する必要があります。この条件が満たされないと分析結果の正確性は保証できません。

2. これはGC実行時に「Stop The World」が必要な重要な理由です。ほとんど停止しないとされるCMSコレクタでも、ルートノードの列挙時には必ず停止する必要があります

オブジェクトのファイナライゼーションメカニズム

finalize()メソッドメカニズム

オブジェクト破棄前のコールバック関数:finalize()

1. Java言語はオブジェクト終了(finalization)メカニズムを提供し、開発者がオブジェクトが破棄される前のカスタム処理ロジックを提供できるようにしています。

2. ガベージコレクタがオブジェクトへの参照がないことを検出した際、つまりこのオブジェクトをガベージコレクションする前に、常にこのオブジェクトのfinalize()メソッドを呼び出します。

3. finalize()メソッドはサブクラスでオーバーライド可能で、オブジェクトが回収される際のリソース解放に使用されます。通常このメソッドでファイル、ソケット、データベース接続のクローズなどのリソース解放とクリーンアップ作業を行います。

Objectクラスのfinalize()ソースコード

// オーバーライドされることを想定
protected void finalize() throws Throwable { }

1. あるオブジェクトのfinalize()メソッドを主動的に呼び出すべきではありません。ガベージコレクションメカニズムに任せることです。理由は以下の通り:

  1. finalize()中でオブジェクトが復活する可能性があります。
  2. finalize()メソッドの実行時間は保証されていません。完全にGCスレッドによって決定され、極端な場合はGCが発生しない限りfinalize()メソッドは実行機会を得ません。
  3. 酷いfinalize()はGCのパフォーマンスに深刻な影響を与える可能性があります。例えばfinalizeが無限ループの場合

2. 機能的に言えば、finalize()メソッドはC++のデストラクタと似ていますが、Javaはガベージコレクタに基づく自動メモリ管理メカニズムを採用しているため、finalize()メソッドはC++のデストラクタとは本質的に異なります

3. finalize()メソッドはfinalizeスレッドに対応しており、優先度が低いため、このメソッドを主動的に呼び出しても直ちに回収されるわけではありません。

生存か死か?

finalize()メソッドの存在により、仮想マシン内のオブジェクトは一般的に三つの可能な状態にあります。

1. すべてのルートノードからあるオブジェクトにアクセスできない場合、そのオブジェクトは使用されなくなったことを示します。一般的に、このオブジェクトは回収される必要があります。しかし実際には、「死」が確定しているわけではなく、これらは一時的に「執行猶予」段階にあります。到達不能なオブジェクトは特定の条件下で自分自身を「復活」させる可能性があります。このような場合、直ちに回収するのは不合理です。そのため、仮想マシン内のオブジェクトがとり得る三つの状態を定義します。以下:

  1. 到達可能な:ルートノードからこのオブジェクトに到達可能。
  2. 復活可能な:オブジェクトのすべての参照が解放されたが、finalize()で復活する可能性あり。
  3. 到達不能な:オブジェクトのfinalize()が呼び出され、復活しなかったため、到達不能な状態に入った。到達不能なオブジェクトは復活できない。finalize()は一度しか呼び出されない

2. 上記3つの状態は、finalize()メソッドの存在により区別されます。オブジェクトが到達不能な場合にのみ回収可能です。

具体的なプロセス

オブジェクトobjAが回収可能かどうかを判定するには、少なくとも二回のマークプロセスを経る必要があります:

1. オブジェクトobjAからGC Rootsに参照チェーンがない場合、最初のマークを実施します。

2. 篩選し、このオブジェクトがfinalize()メソッドを実行する必要があるかどうかを判断します

  1. オブジェクトobjAがfinalize()メソッドをオーバーライドしていない、またはfinalize()メソッドが仮想マシンによって既に呼び出された場合、仮想マシンは「実行の必要なし」とみなします。objAは到達不能と判定されます。
  2. オブジェクトobjAがfinalize()メソッドをオーバーライドし、まだ実行されていない場合、objAはF-Queueキューに挿入され、仮想マシンが自動的に作成した低優先度のFinalizerスレッドがそのfinalize()メソッドの実行をトリガーします。
  3. finalize()メソッドはオブジェクトが死亡を逃れる最後の機会です。その後、GCはF-Queueキュー内のオブジェクトに二回目のマークを実施します。objAがfinalize()メソッドで参照チェーン上のいずれかのオブジェクトと接続を確立した場合、二回目のマーク時にobjAは「回収予定」セットから除外されます。その後、オブジェクトが再び参照が存在しない状態になります。この場合、finalize()メソッドは再度呼び出されず、オブジェクトは直接到達不能な状態になります。つまり、オブジェクトのfinalize()メソッドは一度しか呼び出されません。

JVisual VMでFinalizerスレッドを確認

コードデモンストレーション:finalize()メソッドでオブジェクトを復活

CanReliveObjクラスのfinalize()メソッドをオーバーライドし、そのfinalize()メソッドを呼び出す際にobjを現在のクラスオブジェクトthisに設定します。

/**
 * Objectクラスのfinalize()メソッドをテスト、つまりオブジェクトのfinalizationメカニズム。
 *
 */
public class CanReliveObj {
    public static CanReliveObj obj;//クラス変数、GC Rootに属する


    //このメソッドは一度しか呼び出されない
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("現在のクラスでオーバーライドされたfinalize()メソッドを呼び出す");
        obj = this;//現在回収対象のオブジェクトがfinalize()メソッド内で参照チェーン上のオブジェクトobjと接続を確立
    }


    public static void main(String[] args) {
        try {
            obj = new CanReliveObj();
            // オブジェクトが最初に自分自身を救済
            obj = null;
            System.gc();//ガベージコレクタを呼び出す
            System.out.println("第1回gc");
            // Finalizerスレッドの優先度が低いため、2秒停止して待機
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("objは死んでいる");
            } else {
                System.out.println("objは生きている");
            }
            System.out.println("第2回gc");
            // 以下のコードは上記と完全に同じだが、今回は自己救済は失敗
            obj = null;
            System.gc();
            // Finalizerスレッドの優先度が低いため、2秒停止して待機
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("objは死んでいる");
            } else {
                System.out.println("objは生きている");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

finalize()メソッドをコメントアウトした場合

 //このメソッドは一度しか呼び出されない
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("現在のクラスでオーバーライドされたfinalize()メソッドを呼び出す");
        obj = this;//現在回収対象のオブジェクトがfinalize()メソッド内で参照チェーン上のオブジェクトobjと接続を確立
    }

出力結果:

第1回gc
objは死んでいる
第2回gc
objは死んでいる

コメントアウトされたfinalize()メソッドを有効にする

出力結果:

第1回gc
現在のクラスでオーバーライドされたfinalize()メソッドを呼び出す
objは生きている
第2回gc
objは死んでいる

最初の自己救済は成功しましたが、finalize()メソッドは一度しか実行されるため、二回目の自己救済は失敗しました。

MATとJProfilerのGC Rootsトレース

MATの紹介

1. MATはMemory Analyzer Toolの略称で、Javaヒープメモリ解析器として強力な機能を備えています。メモリリークの検出とメモリ消費状況の表示に使用されます。

2. MATはEclipseベースで開発され、無料のパフォーマンス分析ツールです。

3. http://www.eclipse.org/mat/でMATをダウンロードして使用できます

1. JVisualVMは強力ですが、メモリ分析ではMATの方が使いやすいです。

2. このセクションは主にGC Rootsが何であるかをリアルタイムで分析するためで、dumpファイルが必要になります。

dumpファイルの取得方法

方法一:コマンドラインでjmapを使用

方法二:JVisualVMを使用

1. 捕獲したheap dumpファイルは一時ファイルであり、JVisualVMを閉じると自動的に削除されます。保持するには、ファイルとして保存する必要があります。heap dumpを捕獲するには以下の方法があります。

2. 操作手順は以下の通りです

dumpキャプチャ例

JVisualVMでheap dumpをキャプチャ

コード:

  • numListとbirthは最初のメモリスナップショットをキャプチャする際にGC Rootsです。
  • その後、numListとbirthをnullに設定し、対応する参照オブジェクトが回収され、二回目のメモリスナップショットをキャプチャする際にはGC Rootsではなくなります。
public class GCRootsDemo {
    public static void main(String[] args) {
        List<Object> numberList = new ArrayList<>();
        Date creationTime = new Date();

        for (int i = 0; i < 100; i++) {
            numberList.add(String.valueOf(i));
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("データ追加完了、操作してください:");
        new Scanner(System.in).next();
        numberList = null;
        creationTime = null;

        System.out.println("numberList、creationTimeはnullに設定されました:");
        new Scanner(System.in).next();

        System.out.println("終了");
    }
}

ヒープメモリスナップショットのキャプチャ方法

1. 最初のステップを実行し、停止してこのステップのdumpファイルを生成します

2. 【ヒープダンプ】をクリック

3. 右クリック → 名前を付けて保存

4. コマンドを入力し、プログラムを続行

5. 二回目のヒープメモリスナップショットをキャプチャします

MATでヒープメモリスナップショットを表示

1. MATを開き、File → Open Fileを選択し、先ほどの二つのdumpファイルを開きます。最初のdumpファイルを開いてください

Open Heap DumpでもOKです

2. Java Basics → GC Rootsを選択

3. 最初のヒープメモリスナップショットをキャプチャした際、GC Rootsには定義した二つのローカル変数が含まれており、型はそれぞれArrayListとDateで、合計21個

この画像の分類/カテゴリ化の次元は講義中のGC Rootsの分類次元と異なり、Eclipseの次元であり、以下の画像の分類を参考にしてください:

4. 二回目のdumpファイルを開くと、二回目のメモリスナップショットをキャプチャした際、二つのローカル変数参照のオブジェクトが解放されたため、これらのローカル変数はGC Rootsではなくなりました。Total Entries = 19からもわかります(GC Rootsが二つ減りました)

JProfiler GC Rootsトレース

【尚硅谷宋紅康JVM完全教程(Java仮想マシン詳解)】 【05:25にジャンプ】 https://www.bilibili.com/video/BV1PJ411n7xZ/?p=145&share_source=copy_web&vd_source=55f99462ec0c8405d00e6ebecca29a3a&t=325

1. 実際の開発では、すべてのGC Rootsを確認することは稀です。(ps:すべてを確認すると非常に遅くなる可能性があります)。一般的にはある一つまたはいくつかのオブジェクトのGC Rootがどれであるかを確認し、このプロセスはGC Rootsトレースと呼ばれます。

2. 以下ではJProfilerを使用してGC Rootsトレースのデモンストレーションを行います

引き続き以下のコードを使用します

public class GCRootsDemo {
    public static void main(String[] args) {
        List<Object> numberList = new ArrayList<>();
        Date creationTime = new Date();

        for (int i = 0; i < 100; i++) {
            numberList.add(String.valueOf(i));
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("データ追加完了、操作してください:");
        new Scanner(System.in).next();
        numberList = null;
        creationTime = null;

        System.out.println("numberList、creationTimeはnullに設定されました:");
        new Scanner(System.in).next();

        System.out.println("終了");
    }
}

1.

2.

緑色に変わったことが確認でき、動的な変化を確認できます

3. オブジェクトを右クリックし、「Show Selection In Heap Walker」を選択し、特定のオブジェクトを個別に表示します

4. 「Incoming References」を選択し、GC Rootsの源を追跡します

「Show Paths To GC Roots」をクリックし、ポップアップ画面でデフォルト設定を選択します

JProfilerによるOOM分析

ここでは簡単に説明し、後の章で詳細に説明します

/**
 * -Xms8m -Xmx8m 
 * -XX:+HeapDumpOnOutOfMemoryError このパラメータの意味はプログラムがOOMを発生したときに現在のプロジェクトディレクトリにdumpファイルを生成すること
 */
public class HeapOOMExample {
    byte[] buffer = new byte[1 * 1024 * 1024];//1MB

    public static void main(String[] args) {
        ArrayList<HeapOOMExample> list = new ArrayList<>();

        int counter = 0;
        try{
            while(true){
                list.add(new HeapOOMExample());
                counter++;
            }
        }catch (Throwable e){
            System.out.println("counter = " + counter);
            e.printStackTrace();
        }
    }
}

プログラム出力ログ

com.example.HeapOOMExample
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid14608.hprof ...
java.lang.OutOfMemoryError: Java heap space
	at com.example.HeapOOMExample.<init>(HeapOOMExample.java:12)
	at com.example.HeapOOMExample.main(HeapOOMExample.java:20)
Heap dump file created [7797849 bytes in 0.010 secs]
counter = 6

このdumpファイルを開く

1. この巨大オブジェクトを見る

2. main()スレッドで問題のあるコードを特定する

クリーンアップ段階:マーク-スイープアルゴリズム

ガベージクリーンアップ段階

  • メモリ内の生存オブジェクトと死亡オブジェクトを区別できた後、GCの次のタスクはガベージコレクションを実行し、不要なオブジェクトが占有するメモリスペースを解放し、新しいオブジェクトにメモリを割り当てるのに十分な空きメモリを確保します。現在、JVMで比較的一般的な三つのガベージコレクションアルゴリズムは

1. マーク-スイープアルゴリズム(Mark-Sweep)

2. コピーイングアルゴリズム(Copying)

3. マーク-コンパクトアルゴリズム(Mark-Compact)

背景

マーク-スイープアルゴリズム(Mark-Sweep)は非常に基本的かつ一般的なガベージコレクションアルゴリズムで、このアルゴリズムはJ.McCarthyらが1960年に提案しLisp言語に適用されました。

実行プロセス

ヒープ内の有効メモリ空間(available memory)が枯渇した際、プログラム全体を停止(「stop the world」とも呼ばれる)し、その後二つの作業を実行します。第一にマーク、第二にクリーンアップです。

1. マーク:Collectorは参照ルートノードから開始し、参照されているすべてのオブジェクトをマークします。通常はオブジェクトのヘッダで到達可能オブジェクトとして記録されます。

  • 注意:マークされるのは参照されているオブジェクト、つまり到達可能オブジェクトであり、クリーンアップ対象のガベージオブジェクトではありません。

2. クリーンアップ:Collectorはヒープメモリを先頭から末尾まで線形的に走査し、ヘッダで到達可能オブジェクトとしてマークされていないオブジェクトを回収します。

マーク-スイープアルゴリズムの欠点

1. マーククリーンアップアルゴリズムの効率は高くありません

2. GC実行中にアプリケーション全体を停止する必要があり、ユーザーエクスペリエンスが悪いです

3. この方法でクリーンアップされた空きメモリは連続しておらず、内部断片が発生し、空きリストを維持する必要があります

注意:クリーンアップとは?

ここでいうクリーンアップは本当にnullに設定するわけではなく、クリーンアップ対象のオブジェクトアドレスを空きアドレスリストに保存します。次に新しいオブジェクトをロードする際、ガベージの位置のスペースが十分かどうかを判断し、十分であればそこに格納します(つまり既存のアドレスを上書きします)。

空きリストについてはオブジェクトのメモリ割り当て時に触れました:

1. メモリが整っている場合

  • ポインタクラッシュ方式でメモリ割り当てを行う

2. メモリが整っていない場合

  • 仮想マシンは空きリストを維持する必要があります
  • 空きリストでメモリを割り当てる

クリーンアップ段階:コピーイングアルゴリズム

背景

1. マーク-スイープアルゴリズムのガベージコレクション効率の欠点を解決するため、M.L.Minskyは1963年に有名な論文「シリアル二次記憶装置を使用するLisp言語ガベージコレクタ」(A LISP Garbage Collector Algorithm Using Serial Secondary Storage)を発表しました。M.L.Minskyはこの論文で記述されたアルゴリズムをコピーイング(Copying)アルゴリズムと呼ばれ、M.L.Minsky自身がLisp言語の実装バージョンに導入することに成功しました。

核心思想

生存しているメモリ空間を二つのブロックに分け、常にそのうち一つだけを使用します。ガベージコレクション時は使用中のメモリ内の生存オブジェクトを未使用のメモリブロックにコピーし、その後使用中のメモリブロック内のすべてのオブジェクトをクリーンアップし、二つのメモリの役割を交換して最終的にガベージコレクションを完了します。

新生代ではコピーイングアルゴリズムが使用され、Eden領域とS0領域の生存オブジェクトをS1領域に全体コピーします。

コピーイングアルゴリズムの利点と欠点

利点

1. マークとクリーンアッププロセスがなく、実装が簡単で実行効率が高い

2. コピー後はスペースの連続性が保証され、「断片」問題が発生しません。

欠点

1. このアルゴリズムの欠点は明らかで、二倍のメモリスペースが必要です。

2. G1のように多数のregionに分割するGCでは、移動ではなくコピーすることで、GCがregion間のオブジェクト参照関係を維持する必要があり、メモリ使用量や時間オーバーヘッドも小さくありません

コピーイングアルゴリズムの応用シナリオ

1. システム内のガベージオブジェクトが多い場合、コピーイングアルゴリズムの効果は理想的ではありません。コピーイングアルゴリズムでは生存オブジェクトのコピー量は大きくなく、効率が高いです

【尚硅谷宋紅康JVM完全教程(Java仮想マシン詳解)】 【11:25にジャンプ】 https://www.bilibili.com/video/BV1PJ411n7xZ/?p=148&share_source=copy_web&vd_source=55f99462ec0c8405d00e6ebecca29a3a&t=685

2. 古い世代では多くのオブジェクトが生存しているため、コピーされるオブジェクトが多く、効率が低くなります

3. 新生代では、通常のアプリケーションのガベージコレクションでは、通常70% - 99% のメモリスペースを回収できます。回収のコストパフォーマンスは非常に高いです。そのため、現在の商用仮想マシンではこの収集アルゴリズムを使用して新生代を回収しています。

クリーンアップ段階:マーク-コンパクトアルゴリズム

マーク-コンパクト(またはマーク-整理、Mark - Compact)アルゴリズム

背景

1. コピーイングアルゴリズムの効率性は生存オブジェクトが少なく、ガベージオブジェクトが多い前提に基づいています。この状況は新生代で頻繁に発生しますが、古い世代では、より一般的なのはほとんどのオブジェクトが生存していることです。依然としてコピーイングアルゴリズムを使用する場合、生存オブジェクトが多いため、コピーのコストも高くなります。したがって、古い世代のガベージコレクション特性に基づいて、他のアルゴリズムを使用する必要があります。

2. マーク-スイープアルゴリズムは古い世代で使用できますが、実行効率が低く、メモリ回収後にメモリ断片が発生するため、JVM設計者はこの基礎の上に改善を加える必要があります。マーク-コンパクト(Mark-Compact)アルゴリズムが生まれました。

3. 1970年頃、G.L.Steele、C.J.Chene、D.s.Wiseなどの研究者がマーク-コンパクトアルゴリズムを発表しました。多くの現代のガベージコレクタでは、マーク-コンパクトアルゴリズムまたはその改良版が使用されています。

実行プロセス

1. 第一段階はマーク-スイープアルゴリズムと同様に、ルートノードからすべての参照オブジェクトをマークします

2. 第二段階ではすべての生存オブジェクトをメモリの一方に圧縮し、順序に並べます。その後、境界外のすべてのスペースをクリーンアップします。

マーク-コンパクトアルゴリズムとマーク-スイープアルゴリズムの比較

1. マーク-コンパクトアルゴリズムの最終効果は、マーク-スイープアルゴリズム実行後にメモリ断片整理を実行したものと等しく、マーク-スイープ-コンパクト(Mark-Sweep-Compact)アルゴリズムとも呼ばれます。

2. 両者の本質的な違いは、マーク-スイープアルゴリズムが非移動型回収アルゴリズムであるのに対し、マーク-コンパクトは移動型です。回収後の生存オブジェクトを移動するかどうかは利点と欠点が共存するリスクのある決断です。

3. マークされた生存オブジェクトが整理され、メモリアドレス順に並べられ、マークされていないメモリがクリーンアップされることがわかります。これにより、新しいオブジェクトにメモリを割り当てる際、JVMはメモリの開始アドレスを保持するだけで済みます。これは空きリストを維持するよりも多くのオーバーヘッドを削減できます。

マーク-コンパクトアルゴリズムの利点と欠点

利点

1. マーク-スイープアルゴリズムのメモリ領域分散の欠点を排除し、新しいオブジェクトにメモリを割り当てる際、JVMはメモリの開始アドレスを保持するだけで済みます。

2. コピーイングアルゴリズムのメモリ半減の高コストを排除します。

欠点

1. 効率の面では、マーク-整理アルゴリズムはコピーイングアルゴリズムより低いです。

2. オブジェクトを移動する際、オブジェクトが他のオブジェクトによって参照されている場合、参照アドレスの調整も必要です(HotSpot仮想マシンはハンドルプール方式ではなく直接ポインタを使用するため)

3. 移動プロセス中、ユーザーアプリケーションを完全に停止する必要があります。つまり:STW

ガベージコレクションアルゴリズムまとめ

三つのクリーンアップ段階のアルゴリズムを比較

1. 効率の面では、コピーイングアルゴリズムは名実ともにNo.1ですが、メモリを非常に浪費します。

2. 上記の三つの指標をできるだけバランスよく考慮するため、マーク-整理アルゴリズムは比較的スムーズですが、効率面では満足できず、コピーイングアルゴリズムよりマーク段階が一つ多く、マーク-スイープよりメモリ整理段階が一つ多いです。

マーク-スイープ マーク-整理 コピーイング
速度 中程度 最遅 最速
スペースオーバーヘッド 少ない(ただし断片が蓄積する) 少ない(断片が蓄積しない) 通常は生存オブジェクトの2倍のスペースが必要(断片が蓄積しない)
オブジェクト移動 しない する する

世代別収集アルゴリズム

Q:最適なアルゴリズムはないのですか?

A:いいえ、最適なアルゴリズムはありません。最も適したアルゴリズムだけがあります

なぜ世代別収集アルゴリズムを使用するのか

1. 上記すべてのアルゴリズムにおいて、他のアルゴリズムを完全に置き換えるアルゴリズムはなく、それぞれ独自の利点と特徴を持っています。世代別収集アルゴリズムが登場しました。

2. 世代別収集アルゴリズムは以下の事実に基づいています:異なるオブジェクトのライフサイクルは異なります。したがって、異なるライフサイクルのオブジェクトには異なる収集方式を採用し、回収効率を向上させることができます。一般的にはJavaヒープを新生代と古い世代に分け、それぞれの世代の特徴に応じて異なる回収アルゴリズムを使用し、ガベージコレクションの効率を高めます。

3. Javaプログラムの実行中、大量のオブジェクトが生成され、そのうち一部のオブジェクトはビジネス情報に関連しています:

  • HttpリクエストのSessionオブジェクト、スレッド、Socket接続など、これらはビジネスと直接関連しており、ライフサイクルが長いです。
  • 他にも、プログラム実行中に生成される一時変数が主にあり、これらのオブジェクトのライフサイクルは短くなります。例えば:Stringオブジェクトは不変クラスの特性により、システムが大量のこれらのオブジェクトを生成し、一部のオブジェクトは一度使用して回収されます。

現在、ほぼすべてのGCは世代別収集アルゴリズムを使用してガベージコレクションを実行しています

HotSpotでは、世代別概念に基づき、GCが使用するメモリ回収アルゴリズムは新生代と古い世代それぞれの特徴を結合する必要があります。

1. 新生代(Young Gen)

  • 新生代の特徴:古い世代に比べて相対的に領域が小さく、オブジェクトのライフサイクルが短く、生存率が低く、回収が頻繁です。
  • このような状況では、コピーイングアルゴリズムの回収整理が最も速くなります。コピーイングアルゴリズムの効率は現在の生存オブジェクトの大きさにのみ関係するため、新生代の回収に適しています。また、コピーイングアルゴリズムのメモリ利用率が低い問題は、hotspotの二つのsurvivorの設計により緩和されます。

2. 古い世代(Tenured Gen)

  • 古い世代の特徴:領域が大きく、オブジェクトのライフサイクルが長く、生存率が高く、新生代ほど頻繁には回収しません。
  • このような状況では、生存率が高いオブジェクトが多く存在するため、コピーイングアルゴリズムは明らかに適しません。一般的にはマーク-スイープまたはマーク-スイープとマーク-コンパクトの混合実装です。
  • Mark段階のオーバーヘッドは生存オブジェクトの数に比例します。
  • Sweep段階のオーバーヘッドは管理領域の大きさに比例します。
  • Compact段階のオーバーヘッドは生存オブジェクトの数に比例します。

3. HotSpotのCMS回収器を例に挙げると、CMSはMark-Sweepに基づいており、オブジェクトの回収効率が非常に高いです。断片問題については、CMSはMark-Compactアルゴリズムに基づくSerial Old回収器を補償措置として採用しています:メモリ回収が不良(断片によるConcurrent Mode Failure)の場合、Serial Oldを使用してFull GCを実行し、古い世代メモリの整理を行います。

4. 世代別の思想は既存の仮想マシンで広く使用されています。ほぼすべてのガベージコレクタは新生代と古い世代を区別しています。

インクリメンタル収集アルゴリズムとパーティショニングアルゴリズム

インクリメンタル収集アルゴリズム

上記既存のアルゴリズムでは、ガベージコレクションプロセス中、アプリケーションソフトウェアは「Stop the World」状態になります。Stop the World状態では、アプリケーションのすべてのスレッドが一時停止し、ガベージコレクションの完了を待つ間、すべての正常な作業が中断されます。ガベージコレクション時間が長すぎると、アプリケーションは長時間一時停止し、ユーザーエクスペリエンスやシステムの安定性に深刻な影響を与えます。この問題を解決するため、リアルタイムガベージコレクションアルゴリズムの研究によりインクリメンタル収集(Incremental Collecting)アルゴリズムが誕生しました。

インクリメンタル収集アルゴリズムの基本思想

1. すべてのガベージを一度に処理すると、システムが長時間停止するため、ガベージコレクションスレッドとアプリケーションスレッドを交互に実行できます。各回、ガベージコレクションスレッドはメモリ空間の小さな領域だけを収集し、その後アプリケーションスレッドに切り替えます。これを繰り返し、ガベージコレクションが完了するまで続けます。

2. 一般的に言って、インクリメンタル収集アルゴリズムの基礎は従来のマーク-スイープとコピーイングアルゴリズムです。インクリメンタル収集アルゴリズムはスレッド間の衝突を適切に処理することで、ガベージコレクションスレッドがマーク、クリーンアップ、またはコピー作業を段階的に完了できるようにします

インクリメンタル収集アルゴリズムの欠点

このような方法では、ガベージコレクションプロセス中にアプリケーションコードを断続的に実行するため、システムの停止時間を短縮できます。しかし、スレッド切り替えとコンテキスト切り替えのオーバーヘッドにより、ガベージコレクションの全体的なコストが上昇し、システムのスループットが低下します。

パーティショニングアルゴリズム

主にG1回収器を対象としています

1. 一般的に、同じ条件下ではヒープ空間が大きいほど、一度のGCに必要な時間が長くなり、GCによる停止も長くなります。GCによる停止時間をより良く制御するために、大きなメモリ領域を複数の小さなブロックに分割し、目標停止時間に基づいて、いくつかの小区間を適切に回収し、ヒープ全体ではなく、一度のGCによる停止を減少させます。

2. 世代アルゴリズムはオブジェクトのライフサイクルの長さに基づいて二つの部分に分割しますが、パーティショニングアルゴリズムはヒープ全体を連続した異なる小区間に分割します。各小区間は独立して使用され、独立して回収されます。このアルゴリズムの利点は、一度にいくつの小区間を回収するかを制御できることです。

最後に

注意してください、これらは基本的なアルゴリズムのアイデアであり、実際のGC実装プロセスははるかに複雑です。現在も発展中の最先端GCはすべて複合アルゴリズムであり、並列処理と同時処理を備えています。

タグ: JVM garbage-collection java-memory-management gc-algorithms mark-sweep

7月4日 22:48 投稿