メインコンテンツにスキップ

3件の投稿件の投稿が「jvm」タグ付き

すべてのタグを見る

JavaからHello Worldお出力するまで 3

· 19分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

前の章では、Javaをコンパイルし、バイトコードの構造を調べました。この章では、JVMが「Hello World」コードブロックをどのように実行するかを探ります。

第3章: JVM上でJavaを実行する

  • クラスローダー
  • Java仮想マシン
  • Javaネイティブインターフェース
  • JVMメモリロードプロセス
  • Hello Worldとメモリアリアの相互作用

クラスローダー

Javaクラスがメモリにロードされ、初期化されるタイミング、場所、方法を理解するためには、まずJVMのクラスローダーを見てみる必要があります。

クラスローダーは、コンパイルされたJavaクラスファイル(.class)を動的にロードし、それをJVMのメモリアリアであるランタイムデータエリアに配置します。

クラスローダーによるクラスファイルのロードプロセスは、以下の3つのステージで構成されます:

  1. ロード: クラスファイルをJVMメモリに取り込む。
  2. リンク: クラスファイルを検証して使用可能にするプロセス。
  3. 初期化: クラスファイルを適切な値で初期化する。

重要なのは、クラスファイルは一度にすべてメモリにロードされるのではなく、アプリケーションが必要とするタイミングで動的にメモリにロードされるということです。

多くの人が誤解しているのは、クラスやクラス内の静的メンバーがメモリにロードされるタイミングです。多くの人は、ソースが実行されるとすぐにすべてのクラスと静的メンバーがメモリにロードされると誤解しています。しかし、静的メンバーは、クラスがメモリに動的にロードされ、そのクラス内のメンバーが呼び出されたときにのみメモリにロードされます。

verboseオプションを使用すると、メモリへのロードプロセスを観察できます。

java -verbose:class VerboseLanguage

image

VerboseLanguageクラスが「Hello World」が印刷される前にロードされていることがわかります。

情報

Java 1.8とJava 21では、コンパイル結果からログ出力形式が異なります。バージョンが進むにつれて最適化が行われ、コンパイラの動作が若干変わるため、バージョンを確認することが重要です。この記事では、デフォルトバージョンとしてJava 21を使用し、他のバージョンについては別途指定します。

ランタイムデータエリア

ランタイムデータエリアは、プログラムの実行中にデータが保存される空間です。共有データエリアとスレッドごとのデータエリアに分かれています。

共有データエリア

JVM内には、JVM内で実行される複数のスレッド間でデータを共有できるエリアがいくつかあります。これにより、さまざまなスレッドが同時にこれらのエリアにアクセスできます。

ヒープ

VerboseLanguageクラスのインスタンスが存在する場所

ヒープエリアは、Javaオブジェクトや配列が作成されるときに割り当てられる場所です。JVMが起動するときに作成され、JVMが終了するときに破棄されます。

Java仕様によると、この空間は自動的に管理されるべきです。この役割はガベージコレクタ(GC)と呼ばれるツールによって実行されます。

JVM仕様にはヒープのサイズに制約はありません。メモリ管理もJVMの実装に任されています。しかし、ガベージコレクタが新しいオブジェクトを作成するための十分なスペースを確保できない場合、JVMはOutOfMemoryエラーをスローします。

メソッドエリア

メソッドエリアは、クラスやインターフェースの定義を保存する共有データエリアです。ヒープと同様に、JVMが起動するときに作成され、JVMが終了するときに破棄されます。

クラスのグローバル変数や静的変数はこのエリアに保存され、プログラムの開始から終了までどこからでもアクセス可能です。(= ランタイム定数プール)

具体的には、クラスローダーがクラスのバイトコード(.class)をロードし、それをJVMに渡します。JVMはオブジェクトの作成やメソッドの呼び出しに使用されるクラスの内部表現を生成します。この内部表現は、クラスやインターフェースのフィールド、メソッド、コンストラクタに関する情報を収集します。

実際、JVM仕様によると、メソッドエリアは「どのようにあるべきか」の明確な定義がないエリアです。これは論理的なエリアであり、実装によってはヒープの一部として存在することもあります。単純な実装では、GCや圧縮を行わずにヒープの一部として存在することもあります。

ランタイム定数プール

ランタイム定数プールはメソッドエリアの一部であり、クラスやインターフェースの名前、フィールド名、メソッド名へのシンボリック参照を含みます。JVMはランタイム定数プールを使用して、参照の実際のメモリアドレスを見つけます。

バイトコードを解析するときに見たように、定数プールはクラスファイルの中にありました。実行時には、クラスファイル構造の一部であった定数プールが読み取られ、クラスローダーによってメモリにロードされます。

文字列定数プール

「Hello World」文字列が保存される場所

前述のように、ランタイム定数プールはメソッドエリアの一部です。しかし、ヒープにも定数プールがあり、これを文字列定数プールと呼びます。

new String("Hello World")を使用して文字列を作成すると、その文字列はオブジェクトとして扱われ、ヒープで管理されます。例を見てみましょう:

String s1 = "Hello World";
String s2 = new String("Hello World");

コンストラクタ内で使用される文字列リテラルは文字列プールから取得されますが、newキーワードは新しい一意の文字列の作成を保証します。

0: ldc           #7                  // String Hello World
2: astore_1
3: new #9 // class java/lang/String
6: dup
7: ldc #7 // String Hello World
9: invokespecial #11 // Method java/lang/String."<init>":(Ljava/lang/String;)V
12: astore_2
13: return

バイトコードを調べると、invokespecial命令を使用して文字列が「作成」されていることがわかります。

invokespecial命令は、オブジェクトの初期化メソッドが直接呼び出されることを意味します。

なぜ文字列定数プールはメソッドエリアのランタイム定数プールとは異なり、ヒープに存在するのでしょうか?🤔

  • 文字列は非常に大きなオブジェクトに属します。また、どれだけの文字列が作成されるか予測が難しいため、未使用の文字列をクリーンアップしてメモリ空間を効率的に使用するプロセスが必要です。これは、文字列定数プールがヒープに存在する必要があることを意味します。
    • スタックに保存すると、スペースを見つけるのが難しくなり、文字列の宣言が失敗する可能性があります。
    • スタックサイズは通常、32ビットシステムで約320kb〜1MB、64ビットシステムで1MB〜2MBです。
  • 文字列は不変として管理されます。変更することはできず、常に新しく作成されます。既に作成された文字列を再利用することで、メモリ空間を節約します(インターン)。しかし、アプリケーションのライフサイクル中に未使用(到達不能)な文字列が蓄積する可能性があります。メモリを効率的に利用するためには、参照されていない文字列をクリーンアップする必要があり、これもGCの影響下にある必要があります。

結論として、文字列定数プールはGCの影響下にあるため、ヒープに存在する必要があります。

文字列比較操作は、完全一致のために長さがNの場合、N回の操作が必要です。一方、プールを使用すると、equals比較は参照をチェックするだけで済み、コストはO(1)O(1)です。

newを使用して文字列を作成することで、文字列定数プール外の文字列を文字列定数プールに移動することができます。

String greeting = new String("Hello World");
greeting.intern(); // 定数プールを使用

// これで、SCP内の文字列リテラルとの比較が可能になります。
assertThat(greeting).isEqualTo("Hello World"); // true

これは過去にはメモリを節約するためのトリックとして提供されていましたが、現在では必要ありませんので、文字列はリテラルとして使用するのが最善です。

要約すると:

  1. 数値には最大値がありますが、文字列はその性質上、最大サイズが不明確です。
  2. 文字列は非常に大きくなる可能性があり、他の型に比べて作成後に頻繁に使用される可能性が高いです。
  3. 自然に高いメモリ効率が求められます。これを実現しながら使いやすさを向上させるためには、グローバルに参照可能であるべきです。
  4. スタック内のスレッドごとのデータエリアに配置すると、他のスレッドによって再利用できず、サイズが大きい場合は割り当てスペースを見つけるのが難しくなります。
  5. 共有データエリア+ヒープ内に配置するのが合理的ですが、JVMレベルで不変として扱う必要があるため、ヒープ内に専用の定数プールを作成して別々に管理します。
ヒント

コンストラクタ内の文字列リテラルは文字列定数プールから取得されますが、newキーワードは独立した文字列の作成を保証します。その結果、文字列定数プール内の文字列とヒープ内の文字列の2つが存在します。

スレッドごとのデータエリア

共有データエリアに加えて、JVMは個々のスレッドのデータを別々に管理します。JVMは実際にかなり多くのスレッドの同時実行をサポートしています

PCレジスタ

各JVMスレッドにはPC(プログラムカウンタ)レジスタがあります。

PCレジスタは、CPUが命令の実行を続けるために現在の命令の位置を保存します。また、次に実行される命令のメモリアドレスを保持し、命令の実行を最適化するのに役立ちます。

PCの動作はメソッドの性質によって異なります:

  • 非ネイティブメソッドの場合、PCレジスタは現在実行中の命令のアドレスを保存します。
  • ネイティブメソッドの場合、PCレジスタは未定義の値を保持します。

PCレジスタのライフサイクルは基本的にスレッドのライフサイクルと同じです。

JVMスタック

各JVMスレッドには独自のスタックがあります。JVMスタックはメソッド呼び出し情報を保存するデータ構造です。各メソッド呼び出しごとにスタックに新しいフレームが作成され、そのフレームにはメソッドのローカル変数と戻り値のアドレスが含まれます。プリミティブ型の場合はスタックに直接保存され、ラッパー型の場合はヒープに作成されたインスタンスへの参照を保持します。これにより、intやdouble型はIntegerやDoubleに比べてわずかにパフォーマンスが優れています。

JVMスタックのおかげで、JVMはプログラムの実行をトレースし、必要に応じてスタックトレースを記録できます。

  • これはスタックトレースとして知られています。printStackTraceはその一例です。
  • 単一の操作が複数のスレッドを横断するwebfluxのイベントループのようなシナリオでは、スタックトレースの重要性を理解するのが難しいかもしれません。

スタックのメモリサイズと割り当て方法はJVMの実装によって決定できます。通常、スレッドが開始されるときに約1MBのスペースが割り当てられます。

JVMメモリ割り当てエラーはスタックオーバーフローエラーを引き起こす可能性があります。しかし、JVMの実装がJVMスタックサイズの動的拡張を許可し、拡張中にメモリエラーが発生した場合、JVMはOutOfMemoryエラーをスローすることがあります。

ネイティブメソッドスタック

ネイティブメソッドはJava以外の言語で書かれたメソッドです。これらのメソッドはバイトコードにコンパイルできないため(Javaではないため、javacを使用できません)、別のメモリアリアが必要です。

  • ネイティブメソッドスタックはJVMスタックと非常に似ていますが、ネイティブメソッド専用です。
  • ネイティブメソッドスタックの目的は、ネイティブメソッドの実行を追跡することです。

JVMの実装は、ネイティブメソッドスタックのサイズとメモリブロックの操作方法を決定できます。

ネイティブメソッドスタックに起因するメモリ割り当てエラーの場合、スタックオーバーフローエラーが発生します。しかし、ネイティブメソッドスタックのサイズを増やす試みが失敗した場合、OutOfMemoryエラーが発生します。

結論として、JVMの実装はネイティブメソッドの呼び出しをサポートしないことを決定でき、そのような実装はネイティブメソッドスタックを必要としないことを強調しています。

Javaネイティブインターフェースの使用については別の記事で取り上げます。

実行エンジン

ロードとストレージのステージが完了すると、JVMはクラスファイルを実行します。これには3つの要素が含まれます:

  • インタープリタ
  • JITコンパイラ
  • ガベージコレクタ

インタープリタ

プログラムが開始されると、インタープリタはバイトコードを1行ずつ読み取り、マシンが理解できる機械語に変換します。

インタープリタは一般的に遅いです。なぜでしょうか?

コンパイルされた言語は、実行前のコンパイルプロセス中にプログラムの実行に必要なリソースや型を定義できます。しかし、インタープリタ言語では、必要なリソースや変数の型は実行時までわからないため、最適化が難しくなります。

JITコンパイラ

JIT(Just In Time)コンパイラは、インタープリタの欠点を克服するためにJava 1.1で導入されました。

JITコンパイラは、バイトコードを実行時に機械語にコンパイルし、Javaアプリケーションの実行速度を向上させます。頻繁に実行される部分(ホットコード)を検出してコンパイルします。

JIT関連の動作を確認する際には、以下のキーワードを使用できます。

-XX:+PrintCompilation: JIT関連のログを出力します -Djava.compiler=NONE: JITを無効にします。パフォーマンスの低下を観察できます。

ガベージコレクター ガベージコレクターは重要なコンポーネントであり、別のドキュメントに記載されていますので、今回は省略します。

GCの最適化は一般的ではありません。 しかし、GC操作による500ms以上の遅延が発生する場合があり、高トラフィックやキャッシュの厳しいTTLを扱うシナリオでは、500msの遅延が重大な問題となることがあります。

結論

Javaは間違いなく複雑な言語です。

面接では、次のような質問をよく受けます。

Javaについてどのくらい詳しいですか?

これで、もっと自信を持って答えられるようになるでしょう。

えっと…🤔 ちょうど「Hello World」くらいです。

参考文献

JavaからHello Worldお出力するまで 1

· 16分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

プログラミングの世界では、常に「Hello World」という文を出力することから始まります。それはまるで不文律のようです。

# hello.py
print("Hello World")
python hello.py
// Hello World

Python?素晴らしい。

// hello.js
console.log("Hello World");
node hello.js
// Hello World

JavaScript?悪くない。

public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java
java VerboseLanguage
// Hello World

しかし、Javaはまるで別の世界から来たように感じます。クラス名がファイル名と一致しなければならないことさえまだ触れていません。

publicとは何か、classとは何か、staticとは何か、voidmainString[]、そしてSystem.out.printlnを経て、ようやく文字列「Hello World」にたどり着きます。さあ、別の言語を学びましょう。1

単に「Hello World」を出力するだけでも、Javaはかなりの背景知識を要求します。なぜJavaはこんなにも冗長なプロセスを必要とするのでしょうか?

このシリーズは3つの章に分かれています。目標は、2つの単語「Hello World」を出力するために裏で何が起こっているのかを詳しく探ることです。各章の具体的な内容は以下の通りです:

  • 第1章では、Hello Worldを出発点とする理由を紹介します。
  • 第2章では、コンパイルされたクラスファイルとコンピュータがJavaコードを解釈し実行する方法を検討します。
  • 最後に、JVMがpublic static void mainをロードして実行する方法とその動作原理を探ります。

3つの章の内容を組み合わせることで、ようやく「Hello World」の概念を理解することができます。かなり長い旅ですが、深呼吸して始めましょう。

第1章. なぜ?

JavaでHello Worldを出力する前に、いくつかの「なぜ」の瞬間を考慮する必要があります。

なぜクラス名はファイル名と一致しなければならないのか?

より正確には、publicクラスの名前がファイル名と一致しなければならないのです。なぜでしょうか?

Javaプログラムはコンピュータに直接理解されるものではありません。JVMという仮想マシンがプログラムの実行を助けます。Javaプログラムをコンピュータで実行可能にするためには、いくつかのステップを経てJVMが解釈できる機械語に変換する必要があります。最初のステップは、コンパイラを使用してプログラムをJVMが解釈できるバイトコードに変換することです。変換されたバイトコードはJVM内部のインタープリタを通じて機械語に翻訳され、実行されます。

コンパイルプロセスを簡単に見てみましょう。

public class Outer {
public static void main(String[] args) {
System.out.println("This is Outer class");
}

private class Inner {
}
}
javac Outer.java
Permissions Size User   Date Modified Name
.rw-r--r-- 302 haril 30 Nov 16:09 Outer$Inner.class
.rw-r--r-- 503 haril 30 Nov 16:09 Outer.class
.rw-r--r-- 159 haril 30 Nov 16:09 Outer.java

Javaはコンパイル時に各クラスごとに.classファイルを生成します

さて、JVMはプログラムを実行するためにmainメソッドを見つける必要があります。どうやってmainメソッドを見つけるのでしょうか?

なぜmain()を見つける必要があるのか?もう少し待ってください。

もしJavaファイル名がパブリッククラス名と一致しない場合、Javaインタープリタはmainメソッドを見つけるためにすべてのクラスファイルを読み込む必要があります。ファイル名がパブリッククラス名と一致している場合、Javaインタープリタは解釈する必要のあるファイルをよりよく特定できます。

1000クラスが含まれるJava1000というファイルを想像してみてください。1000クラスの中からmain()がどこにあるのかを特定するために、インタープリタはすべてのクラスファイルを調べる必要があります。

しかし、ファイル名がパブリッククラス名と一致している場合、main()により迅速にアクセスでき(mainはパブリッククラスに存在するため)、すべてのロジックがmain()から始まるため、他のクラスにも簡単にアクセスできます。

なぜpublicでなければならないのか?

JVMはクラス内のmainメソッドを見つける必要があります。クラスの外部からクラスにアクセスするJVMがクラス内のメソッドを見つけるためには、そのメソッドがpublicでなければなりません。実際、アクセス修飾子をprivateに変更すると、mainpublicとして宣言するように指示するエラーメッセージが表示されます。

Error: Main method not found in class VerboseLanguage, please define the main method as:
public static void main(String[] args)

なぜstaticでなければならないのか?

JVMはpublic main()メソッドを見つけました。しかし、このメソッドを呼び出すためには、まずオブジェクトを作成する必要があります。JVMはこのオブジェクトを必要とするのでしょうか?いいえ、JVMはただmainを呼び出すだけでよいのです。staticとして宣言することで、JVMは不要なオブジェクトを作成する必要がなくなり、メモリを節約できます。

なぜvoidでなければならないのか?

mainメソッドの終了はJavaの実行の終了を意味します。JVMはmainの戻り値を使用することができないため、戻り値の存在は意味がありません。したがって、voidとして宣言するのが自然です。

なぜmainという名前でなければならないのか?

mainというメソッド名は、JVMがアプリケーションの実行エントリーポイントを見つけるために設計されたものです。

「設計」という言葉は大げさに聞こえますが、実際にはmainという名前のメソッドを見つけるようにハードコーディングされています。もし見つける名前がmainではなくharilだった場合、harilという名前のメソッドを探していたでしょう。もちろん、Javaの作成者たちはmainを選んだ理由があったのでしょうが、それがすべてです。

mainClassName = GetMainClassName(env, jarfile);
mainClass = LoadClass(env, classname);

// Find the main method
mainID = (*env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V");

jbject obj = (*env)->ToReflectedMethod(env, mainClass, mainID, JNI_TRUE);

なぜargsが必要なのか?

これまで、main()String[] argsについては触れていませんでした。なぜこの引数が必要で、なぜ省略するとエラーが発生するのでしょうか?

public static void main(String[] args)はJavaアプリケーションのエントリーポイントであるため、この引数はJavaアプリケーションの外部から渡される必要があります。

標準入力のすべての型は文字列として入力されます。

これがargsが文字列配列として宣言される理由です。考えてみれば納得です。Javaアプリケーションが実行される前に、カスタムオブジェクトタイプを直接作成できますか?🤔

では、なぜargsが必要なのでしょうか?

外部から内部に引数を簡単に渡すことで、Javaアプリケーションの動作を変更することができます。このメカニズムは、Cプログラミングの初期からプログラムの動作を制御するために広く使用されてきました。特にシンプルなアプリケーションにとって、この方法は非常に効果的です。Javaはこの広く使用されている方法を採用しただけです

String[] argsを省略できない理由は、Javaがエントリーポイントとしてpublic static void main(String[] args)のみを許可しているためです。Javaの作成者たちは、argsを宣言して使用しない方が、省略を許可するよりも混乱が少ないと考えたのです。

System.out.println

最後に、出力に関連するメソッドについて話し始めることができます。

もう一度言いますが、Pythonではprint("Hello World")でした。2

Javaプログラムはオペレーティングシステム上で直接実行されるのではなく、JVMという仮想マシン上で実行されます。これにより、Javaプログラムはオペレーティングシステムに関係なくどこでも実行できるようになりますが、オペレーティングシステムが提供する特定の機能を使用するのが難しくなります。これが、JavaでCLIを作成したり、OSのメトリクスを収集したりするのが難しい理由です。

しかし、限られたOS機能を活用する方法(JNI)があり、Systemはこの機能を提供します。主な機能のいくつかは次のとおりです:

  • 標準入力
  • 標準出力
  • 環境変数の設定
  • 実行中のアプリケーションを終了し、ステータスコードを返す

Hello Worldを出力するために、Systemの標準出力機能を使用しています。

実際、System.out.printlnの流れを追うと、nativeキーワードが付いたwriteBytesメソッドに出会い、この操作をCコードに委譲し、標準出力に転送することがわかります。

// FileOutputStream.java
private native void writeBytes(byte b[], int off, int len, boolean append)
throws IOException;

nativeキーワードが付いたメソッドの呼び出しは、Java Native Interface(JNI)を通じて動作します。これは後の章で取り上げます。

String

Javaの文字列は少し特別です。いや、かなり特別なようです。文字列は別のメモリ空間に割り当てられ、特別に扱われていることがわかります。なぜでしょうか?

文字列の次の特性に注目することが重要です:

  • 非常に大きくなる可能性がある。
  • 比較的頻繁に再利用される。

したがって、文字列は一度作成されたら再利用することに重点を置いて設計されています。大きな文字列データがメモリ内でどのように管理されるかを完全に理解するには、後で取り上げるトピックの理解が必要です。ここでは、メモリ空間の節約の原則について簡単に触れておきます。

まず、Javaで文字列がどのように宣言されるかを見てみましょう。

String greeting = "Hello World";

内部的には次のように動作します:

文字列はString Constant Poolに作成され、不変の特性を持っています。一度文字列が作成されると変更されず、新しい文字列を作成する際にConstant Poolに同じ文字列が見つかると、それが再利用されます。

次の章では、JVMスタック、フレーム、ヒープについて取り上げます。

もう一つの文字列の宣言方法はインスタンス化です。

String greeting = new String("Hello World");

この方法は内部動作に違いがあるため、あまり使用されません。以下のように動作します。

newキーワードを使用せずに文字列を直接使用すると、String Constant Poolに作成され、再利用されます。しかし、newキーワードでインスタンス化すると、Constant Poolには作成されません。これにより、同じ文字列が複数回作成され、メモリ空間が無駄になる可能性があります。

まとめ

この章では、次の質問に答えました:

  • なぜ.javaファイル名はクラス名と一致しなければならないのか?
  • なぜpublic static void main(String[] args)でなければならないのか?
  • 出力操作の流れ
  • 文字列の特性とその作成および使用の基本原則

次の章では、Javaコードを自分でコンパイルし、バイトコードがどのように生成されるか、そのメモリアリアとの関係などを探ります。

参考文献

Footnotes

  1. Life Coding Python

  2. Life Coding Python

ガベージコレクションの理解

· 12分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

JVMにおけるガベージコレクション(GC)のトピックについて掘り下げてみましょう。

GCとは?

JVMのメモリは複数の領域に分かれています。

image

ヒープ領域は、newなどの操作で作成されたオブジェクトや配列が格納される場所です。ヒープ領域で作成されたオブジェクトや配列は他のオブジェクトから参照されることがあります。GCはまさにこのヒープ領域で行われます。

Javaプログラムが終了せずに実行を続けると、メモリにデータが蓄積され続けます。GCはこの問題を解決します。

どうやって解決するのでしょうか?JVMは到達不能なオブジェクトをGCの対象として識別します。どのオブジェクトが到達不能になるかを理解するためには、以下のコードを見てみましょう。

public class Main {
public static void main(String[] args) {
Person person = new Person("a", "すぐに参照されなくなる");
person = new Person("b", "参照が維持される");
}
}

personが最初に初期化されると、作成されたaは次の行でbに再割り当てされ、到達不能なオブジェクトになります。次のGCでaはメモリから解放されます。

ストップ・ザ・ワールド

image

ザ・ワールド!時よ止まれ! - ジョジョの奇妙な冒険

アプリケーションの実行を停止してGCを行います。「ストップ・ザ・ワールド」イベントが発生すると、GCを実行しているスレッド以外のすべてのスレッドが一時停止します。 GC操作が完了すると、一時停止していたタスクが再開されます。使用されるGCアルゴリズムに関係なく、「ストップ・ザ・ワールド」イベントは発生し、GCのチューニングは通常、この一時停止状態の時間を短縮することを目的としています。

警告

Javaではプログラムコード内で明示的にメモリを解放することはありません。オブジェクトをnullに設定して解放することは大きな問題ではありませんが、System.gc()を呼び出すとシステムのパフォーマンスに大きな影響を与える可能性があり、絶対に使用すべきではありません。さらに、System.gc()は実際にGCが発生することを保証しません。

GCが発生する2つの領域

Javaでは開発者が明示的にメモリを解放しないため、ガベージコレクタが不要になった(ガベージ)オブジェクトを識別して削除する役割を担います。ガベージコレクタは2つの主要な仮定に基づいて動作します:

  • ほとんどのオブジェクトはすぐに到達不能になる。
  • 古いオブジェクトから若いオブジェクトへの参照は非常に少ない。

ほとんどのオブジェクトはすぐに到達不能になる

for (int i = 0; i < 10000; i++) {
NewObject obj = new NewObject();
obj.doSomething();
}

このループ内で使用される10,000個のNewObjectインスタンスは、ループ外では必要ありません。これらのオブジェクトがメモリを占有し続けると、他のコードを実行するためのリソースが徐々に減少します。

古いオブジェクトから若いオブジェクトへの参照は非常に少ない

以下のコードスニペットを考えてみましょう。

Model model = new Model("value");
doSomething(model);

// modelはもう使用されない

最初に作成されたmodeldoSomething内で使用されますが、その後はあまり使用されることはありません。再利用される場合もありますが、GCはそのようなケースが稀であるという仮定のもとで設計されています。Oracleの統計を見ると、ほとんどのオブジェクトは作成されてからすぐにGCによってクリーンアップされることがわかります。

image

この仮定は弱い世代仮説として知られています。この仮説の利点を最大限に活用するために、HotSpot VMは物理的なスペースを2つの主要な領域に分けています:Young GenerationとOld Generationです。

image

  • Young Generation: この領域には主に新しく作成されたオブジェクトが格納されます。ほとんどのオブジェクトはすぐに到達不能になるため、多くのオブジェクトがYoung Generationで作成され、消滅します。この領域からオブジェクトが消滅すると、Minor GCがトリガーされます。
  • Old Generation: Young Generationで到達不能にならずに生き残ったオブジェクトはOld Generationに移動されます。この領域は通常、Young Generationよりも大きく、GCの頻度も少なくなります。この領域からオブジェクトが消滅すると、Major GC(またはFull GC)がトリガーされます。

Young Generationの各オブジェクトには、Minor GCを生き延びるたびにインクリメントされる年齢ビットがあります。この年齢ビットがMaxTenuringThresholdという設定を超えると、オブジェクトはOld Generationに移動されます。ただし、年齢ビットが設定を超えなくても、Survivorスペースに十分なメモリがない場合、オブジェクトはOld Generationに移動されることがあります。

情報

Permanentスペースは、作成されたオブジェクトのアドレスが格納される場所です。クラスローダーがロードされたクラスやメソッドに関するメタ情報を格納するために使用されます。Java 7以前では、ヒープ内に存在していました。

GCの種類

Old Generationが満杯になるとGCがトリガーされます。異なるGC方法を理解することで、関連する手続きを理解するのに役立ちます。

Serial GC

-XX:+UseSerialGC

Serial GCを理解するためには、まずMark-Sweep-Compactアルゴリズムを理解する必要があります。このアルゴリズムの最初のステップは、Old Generation内の生存オブジェクトを識別することです(Mark)。次に、ヒープの前から後ろまでスイープし、生存オブジェクトだけを保持します(Sweep)。最後のステップでは、オブジェクトが連続して積み重なるようにヒープを前から埋めていき、オブジェクトがあるセクションとないセクションに分けます(Compaction)。

警告

Serial GCはメモリとCPUコアが限られたシステムに適しています。ただし、Serial GCを使用するとアプリケーションのパフォーマンスに大きな影響を与える可能性があります。

Parallel GC

-XX:+UseParallelGC

  • Java 8のデフォルトGC

基本的なアルゴリズムはSerial GCと似ていますが、Parallel GCはYoung GenerationでのMinor GCを複数のスレッドで実行します。

Parallel Old GC

-XX:+UseParallelOldGC

  • Parallel GCの改良版

名前が示すように、このGC方法はOld Generationに関連しています。ParallelGCがYoung Generationのみで複数のスレッドを使用するのに対し、Parallel Old GCはOld Generationでも複数のスレッドを使用してGCを実行します。

CMS GC(Concurrent Mark Sweep)

このGCは、アプリケーションスレッドとGCスレッドが同時に実行されることで「ストップ・ザ・ワールド」時間を最小限に抑えるように設計されています。GCターゲットを識別するための多段階プロセスのため、他のGC方法と比較してCPU使用率が高くなります。

最終的に、CMS GCはJava 9から非推奨となり、Java 14で完全に廃止されました

G1GC(Garbage First)

-XX:+UseG1GC

  • CMS GCを置き換えるためにJDK 7でリリース
  • Java 9以降のデフォルトGC
  • 4GB以上のヒープメモリが必要で、「ストップ・ザ・ワールド」時間が約0.5秒で許容される場合に推奨(小さなヒープの場合は他のアルゴリズムが推奨されます)

G1GCは完全に再設計されたGC方法であり、新しいアプローチが必要です。

Q. G1GCが後のバージョンでデフォルトとなっていることを考えると、以前のCMSと比較しての利点と欠点は何ですか?

  • 利点
    • G1GCはスキャン中にコンパクションを行い、「ストップ・ザ・ワールド」時間を短縮します。
    • 追加の「ストップ・ザ・ワールド」ポーズなしで空きメモリスペースを圧縮する能力を提供します。
    • 文字列重複排除の最適化
    • サイズ、カウントなどのチューニングオプション
  • 欠点
    • Full GC中はシングルスレッドで動作します。
    • 小さなヒープサイズのアプリケーションでは頻繁にFull GCイベントが発生する可能性があります。

Shenandoah GC

-XX:+UseShenandoahGC

  • Java 12でリリース
  • Red Hatによって開発
  • CMSのメモリ断片化問題とG1のポーズ問題に対処
  • 強力な並行性と軽量なGCロジックで知られ、ヒープサイズに関係なく一貫したポーズ時間を保証

image

ZGC

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

  • Java 15でリリース
  • 大規模メモリサイズ(8MBから16TB)の低レイテンシ処理用に設計
  • G1のリージョンに似たZPagesを使用しますが、ZPagesは2MBの倍数で動的に管理されます(大きなオブジェクトに対応するためにリージョンサイズを動的に調整)
  • ZGCの主要な利点の1つは、ヒープサイズに関係なく「ストップ・ザ・ワールド」時間が10msを超えないことです

image

結論

さまざまなGCタイプが利用可能ですが、ほとんどの場合、デフォルトのGCを使用するだけで十分です。GCのチューニングには多大な労力が必要であり、GCログやヒープダンプの分析などのタスクが含まれます。GCログの分析については別の記事で取り上げます。

参考文献