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

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」くらいです。

参考文献