Javaを使ってプログラムを開発したことがある人もない人も、どこかでJava Virtual Machine (JVM)について聞いたことがあるかもしれません。
JVMは、Javaエコシステムの中核であり、Javaベースのソフトウェアプログラムが「write once, run anywhere」のアプローチに従うことを可能にします。
JVMは当初、Javaだけをサポートするように設計されていました。
JVMは当初、Javaだけをサポートするように設計されていましたが、時を経て、Scala、Kotlin、Groovyなど、他の多くの言語がJavaプラットフォームに採用されました。
この記事では、JVMについて、その仕組みや構成要素を詳しくご紹介します。
JVMについてご紹介する前に、仮想マシン(VM)の概念を再確認しましょう。
仮想マシンは、物理的なコンピュータを仮想的に表現したものです。
仮想マシンは、物理的なコンピューターを仮想的に表現したもので、仮想マシンをゲストマシン、その上で動作する物理的なコンピューターをホストマシンと呼ぶことができます。
単一の物理的なマシンは、それぞれが独自のオペレーティングシステムとアプリケーションを持つ複数の仮想マシンを実行することができます。
Java仮想マシンとは
CやC++などのプログラミング言語では、まずコードがプラットフォーム固有のマシンコードにコンパイルされます。 このような言語をコンパイル言語といいます。
一方、JavaScriptやPythonなどの言語では、コンパイルすることなく、コンピュータが直接命令を実行します。
Javaはこの2つの技術を組み合わせて使用しています。 Javaのコードはまずバイトコードにコンパイルされ、クラスファイルが生成されます。 このクラスファイルは、Java仮想マシンによって、そのプラットフォームに合わせて解釈されます。
仮想マシンと同様に、JVMはホストマシン上に隔離された空間を作ります。 この空間は、マシンのプラットフォームやオペレーティング システムに関係なく、Java プログラムの実行に使用できます。
Java Virtual Machine Architecture
JVMは3つの異なるコンポーネントで構成されています。
- クラス ローダー
- ランタイム メモリ/データ エリア
- 実行エンジン
それぞれを詳しく見ていきましょう。
クラス ローダー
.java
.class
ファイルとしてバイト コードに変換されます。 このクラスをプログラムで使おうとすると、クラスローダがメインメモリにロードします。
最初にメモリにロードされるクラスは、通常、main()
メソッドを含むクラスです。
クラスのロードプロセスには、ロード、リンク、初期化の3つのフェーズがあります。
ロード
ロードでは、特定の名前のクラスやインターフェイスのバイナリ表現(バイトコード)を受け取り、そこから元のクラスやインターフェイスを生成します。
Javaには3つの組み込みクラス ローダーがあります。 拡張クラスローダーのスーパークラスであり、java.lang
java.net
java.util
java.io
などの標準的なJavaパッケージをロードします。
$JAVA_HOME/jre/lib/ext
ディレクトリに存在する標準的なJavaライブラリの拡張機能をロードします。 -classpath
-cp
コマンドラインオプションを追加することで変更することもできます。 JVMは、ClassLoader.loadClass()
方式でクラスをメモリにロードします。 完全修飾名に基づいてクラスをロードしようとします。
親クラス・ローダがクラスを見つけられない場合、子クラス・ローダに作業を委ねます。 最後の子クラス ローダーがクラスをロードできない場合は、NoClassDefFoundError
ClassNotFoundException
を投げます。
リンク
クラスがメモリに読み込まれた後、リンク処理が行われます。 クラスやインターフェイスのリンクには、プログラムのさまざまな要素や依存関係を結合することが含まれます。
リンクには次のような段階があります:
検証。 このフェーズでは、.class
ファイルを一連の制約やルールに照らし合わせて、構造的な正しさをチェックします。
例えば、コードが Java 11 を使用して構築されているにもかかわらず、Java 8 がインストールされているシステムで実行されている場合、検証フェーズは失敗します。
準備。 このフェーズでは、JVMはクラスやインターフェイスのスタティック・フィールドのメモリを割り当て、デフォルト値で初期化します。
例えば、クラスの中で次のような変数を宣言したとします。
private static final boolean enabled = true;
準備段階では、JVMは変数enabled
false
に設定します。
解決します。 このフェーズでは、シンボリック参照がランタイムの定数プールに存在する直接参照に置き換えられます。
例えば、他のクラスへの参照や他のクラスに存在する定数変数がある場合、それらはこのフェーズで解決され、実際の参照に置き換えられます。
初期化
初期化では、クラスやインターフェイスの初期化メソッド(<clinit>
と呼ばれる)を実行します。 これには、クラスのコンストラクタの呼び出し、静的ブロックの実行、すべての静的変数への値の割り当てなどが含まれます。
たとえば、先ほど次のコードを宣言したとき、
private static final boolean enabled = true;
準備段階では、変数 enabled
false
に設定されていました。
注意:JVMはマルチスレッドです。 複数のスレッドが同時に同じクラスを初期化しようとすることが起こりえます。 これは、同時性の問題につながります。 マルチスレッド環境でプログラムが正しく動作するように、スレッドセーフティーを処理する必要があります。
ランタイム データ エリア
ランタイム データ エリア内には 5 つのコンポーネントがあります。
それぞれを個別に見てみましょう。
メソッドエリア
ランタイムの定数プール、フィールド、メソッドのデータ、メソッドやコンストラクタのコードなど、クラスレベルのデータはすべてここに格納されます。
メソッド領域で利用可能なメモリがプログラムの起動に十分でない場合、JVMはOutOfMemoryError
をスローします。
例えば、次のようなクラス定義があるとします。
public class Employee { private String name; private int age; public Employee(String name, int age) { this.name = name; this.age = age; }}
このコード例では、name
age
などのフィールドレベルのデータやコンストラクタの詳細がメソッド領域に読み込まれます。
メソッド領域は仮想マシンの起動時に作成され、1つのJVMに1つのメソッド領域しかありません。
ヒープ領域
すべてのオブジェクトとそれに対応するインスタンス変数はここに格納されます。 これは、すべてのクラス インスタンスと配列のメモリが割り当てられるランタイム データ領域です。
例えば、次のようなインスタンスを宣言しているとします。
Employee employee = new Employee();
このコード例では、Employee
のインスタンスが作成され、ヒープ領域にロードされます。
注意: メソッド領域とヒープ領域は、複数のスレッドで同じメモリを共有しているため、ここに格納されたデータはスレッドセーフではありません。
スタック領域
JVMで新しいスレッドが作成されるたびに、別のランタイムスタックも同時に作成されます。 すべてのローカル変数、メソッドの呼び出し、および部分的な結果は、スタック領域に保存されます。
スレッドで行われている処理が、利用可能なものよりも大きなスタックサイズを必要とする場合、JVMはStackOverflowError
をスローします。
メソッドコールごとに、スタックフレームと呼ばれるスタックメモリに1つのエントリが作成されます。
スタックフレームは3つのサブパートに分かれています。
- ローカル変数 – 各フレームにはローカル変数と呼ばれる変数の配列があります。 すべてのローカル変数とその値はここに格納されます。
- オペランド スタック – 各フレームには、オペランド スタックと呼ばれる後入れ先出しのスタックがあります。 これは、中間処理を行うためのランタイム・ワークスペースとして機能します。 このスタックの最大深度はコンパイル時に決定されます。
- フレームデータ – メソッドに対応するすべてのシンボルがここに格納されます。
例えば、次のようなコードがあるとします。
double calculateNormalisedScore(List<Answer> answers) { double score = getScore(answers); return normalizeScore(score);}double normalizeScore(double score) { return (score – minScore) / (maxScore – minScore);}
このコード例では、answers
score
などの変数がLocal Variables配列に配置されています。
注意: スタック領域は共有されていないので、本質的にスレッドセーフです。
プログラムカウンター(PC)レジスタ
JVMは同時に複数のスレッドをサポートしています。 各スレッドには、現在実行中のJVM命令のアドレスを保持する独自のPCレジスターがあります。
ネイティブ メソッド スタック
JVM にはネイティブ メソッドをサポートするスタックがあります。 これらのメソッドは、CやC++など、Java以外の言語で書かれています。
実行エンジン
バイトコードがメイン メモリに読み込まれ、詳細がランタイム データ領域で利用可能になると、次のステップはプログラムを実行することです。 実行エンジンは、各クラスに存在するコードを実行することでこれを処理します。
ただし、プログラムを実行する前に、バイトコードを機械語の命令に変換する必要があります。
インタプリタ
インタプリタは、バイトコードの命令を1行ずつ読み込んで実行します。 一行ごとに実行するため、インタープリタは比較的遅くなります。
インタープリタのもう一つの欠点は、1つのメソッドが複数回呼び出されると、そのたびに新しい解釈が必要になることです。
JIT コンパイラ
JIT コンパイラはインタープリタの欠点を克服します。 実行エンジンは、まずインタープリタを使用してバイト コードを実行しますが、繰り返されるコードを見つけた場合は、JIT コンパイラを使用します。
JITコンパイラーは、バイトコード全体をコンパイルし、ネイティブマシンコードに変更します。 このネイティブマシンコードは、繰り返されるメソッド呼び出しに直接使用されるため、システムのパフォーマンスが向上します。
JITコンパイラーには、以下のコンポーネントがあります。
- 中間コードジェネレータ – 中間コードを生成する
- コードオプティマイザ – 中間コードをパフォーマンス向上のために最適化する
- ターゲットコードジェネレータ – 中間コードをネイティブマシンコードに変換する
- プロファイラ – ホットスポット(繰り返し実行されるコード)を見つける
インタープリタとJITコンパイラの違いをより理解するために、以下のようなコードがあるとします。
int sum = 10;for(int i = 0 ; i <= 10; i++) { sum += i;}System.out.println(sum);
インタープリタは、ループの反復ごとにsum
i
の値を追加して、メモリに書き戻します。 しかし、JIT コンパイラは、このコードにホットスポットがあることを認識し、最適化を行います。 スレッドの PC レジスターに sum
i
の値を追加し続けます。
注意:JITコンパイラーは、インタープリターがコードを1行ずつ解釈するよりも、コードをコンパイルする方が時間がかかります。
ガーベジ コレクタ
ガーベジコレクタ(GC)は、参照されないオブジェクトをヒープ領域から収集して削除します。 これは、ランタイムの未使用メモリを破壊することで自動的に再生するプロセスです。
ガーベジコレクションは、参照されないオブジェクトをヒープメモリから削除し、新しいオブジェクトのための空き領域を作るため、Javaのメモリ効率を高めます。
- マーク – このステップでは、GCはメモリ内の未使用のオブジェクトを識別します
- スイープ – このステップでは、GCは前のフェーズで識別されたオブジェクトを削除します
ガーベジコレクションは、一定の間隔でJVMによって自動的に行われるため、個別に処理する必要はありません。 また、System.gc()
を呼び出すことで起動することもできますが、実行は保証されていません。
JVM には 3 種類のガベージ コレクタがあります。
- シリアル GC – これは最もシンプルな GC の実装で、シングルスレッド環境で動作する小規模なアプリケーション向けに設計されています。 ガベージ コレクションのために 1 つのスレッドを使用します。 このGCが実行されると、アプリケーション全体が一時停止する「stop the world」イベントが発生します。 シリアル ガーベッジ コレクタを使用するための JVM の引数は、
-XX:+UseSerialGC
- 並列 GC – これは、JVM での GC のデフォルトの実装で、スループット コレクタとしても知られています。 ガベージコレクションに複数のスレッドを使用しますが、実行時にはアプリケーションを一時停止します。 Parallel Garbage Collector を使用するための JVM の引数は
-XX:+UseParallelGC
です。 - Garbage First (G1) GC – G1GC は、利用可能なヒープのサイズが大きい (4GB 以上) マルチスレッド アプリケーション用に設計されました。 G1GC はヒープを同じサイズの領域に分割し、複数のスレッドを使用してそれらをスキャンします。 G1GCは、最もゴミが多い領域を特定し、その領域に対して最初にガベージコレクションを行います。 G1ガベージコレクタを使用するためのJVMの引数は、
-XX:+UseG1GC
注意:ガベージコレクタには、CMS(Concurrent Mark Sweep)GCと呼ばれる別のタイプがあります。
Java Native Interface (JNI)
時には、ネイティブ(非Java)コード(例えば、C/C++)を使用する必要があります。 これは、ハードウェアと対話する必要がある場合や、Javaのメモリ管理やパフォーマンスの制約を克服する場合などがあります。 Javaは、Java Native Interface (JNI)を介して、ネイティブコードの実行をサポートしています。
JNIは、CやC++などの他のプログラミング言語のサポートパッケージを許可するためのブリッジの役割を果たします。
native
キーワードを使用して、メソッドの実装がネイティブ ライブラリによって提供されることを示すことができます。
ネイティブ・メソッド・ライブラリ
ネイティブ・メソッド・ライブラリとは、C、C++、およびアセンブリなどの他のプログラミング言語で書かれたライブラリです。 これらのライブラリは通常、.dll
.so
ファイルの形で存在しています。 これらのネイティブライブラリは、JNIを通じて読み込むことができます。
Common JVM Errors
- ClassNotFoundExcecption – クラス ローダーが
Class.forName()
ClassLoader.loadClass()
ClassLoader.findSystemClass()
を使用してクラスをロードしようとしているが、指定された名前のクラスの定義が見つからない場合に発生します。 - NoClassDefFoundError – コンパイラがクラスのコンパイルに成功したが、ランタイムでクラス ローダーがクラス ファイルを見つけられなかった場合に発生します。
- OutOfMemoryError – これは、JVM がメモリ不足のためにオブジェクトを割り当てられず、ガベージコレクタによってこれ以上のメモリが利用できなかった場合に発生します。
- StackOverflowError – スレッドの処理中にJVMが新しいスタックフレームを作成中にスペースがなくなった場合に発生します。
Conclusion
この記事では、Java仮想マシンのアーキテクチャとそのさまざまなコンポーネントについて説明しました。 多くの場合、私たちは JVM の内部機構を深く掘り下げたり、コードが動作している間に JVM がどのように動作しているかを気にしたりすることはありません。
何か問題が発生して、JVMを調整したり、メモリリークを修正したりする必要が生じたときに初めて、その内部構造を理解しようとします。
これは、バックエンドの職務において、ジュニアレベルでもシニアレベルでも、非常に人気のある面接の質問です。
ここまでお付き合いいただき、ありがとうございました。 この記事を気に入っていただけましたか? 私とはLinkedInでつながっていて、定期的にテクノロジーや人生について議論しています。 また、私の他の記事やYouTubeチャンネルもご覧になってください。 お楽しみください。