【ミニマルな】 Java 仮想マシンを自作したい全ての方々へ
はじめに
この記事は,Java 仮想マシンを自作したい全ての方々へ の子記事のようななものです。
上記の記事では,JVM の実装に必要な知識をまとめましたが,実際に JVM を実装するのは大変なことです。
そこで,JVM の実装を 4段階にレベル分けして,それぞれの概要を述べました。
この記事では,その中で最も簡単な「実装レベル2: 基本的な機能を備えたミニマルな JVM」を実装するために必要な知識とリソースをまとめます。
なお,この記事も,JVM を自作するための詳細な手順やチュートリアルを提供するものではありません。
実装レベル2: 基本的な機能を備えたミニマルな JVM とは
実装レベル2 のJVM は,メソッド呼び出しや制御フローなどの基本的な機能を備えたミニマルな JVM で,この図の黄色の部分に相当します。
このレベルの JVM は,「レベル1: 命令を解釈して実行する超簡単な JVM」が持つ特徴や機能に加えて,以下のような特徴を持ちます。
-
全命令のサポート: Java バイトコードの全命令セットをサポートします。これにより,より複雑な Java プログラムを実行できます。
例えば,条件分岐やループなどの制御フロー命令もサポートします。 -
標準ライブラリのモック(一部): JVM は,標準ライブラリの一部をモック(簡易実装)します。これにより,基本的な入出力操作が可能になります。
具体的には,java.lang.System
クラスやjava.io.PrintStream
クラスの一部をモックします。 -
クラスの概念の導入: JVM は,クラスのロードと管理をサポートします。これにより,オブジェクト指向プログラミングが可能になります。
具体的には,クラスローダとリンク・初期化機構を実装します。 -
オブジェクトのサポート: JVM は,オブジェクトの生成と管理をサポートします。これにより,Java のオブジェクト指向の特性を活用できます。
具体的には,ヒープ領域とガベージコレクションの基礎を実装します。 -
フレームの管理: JVM は,メソッド呼び出しごとに新しいスタック・フレームを作成し,管理します。これにより,メソッドのローカル変数とオペランド・スタックを分離できます。
具体的には,スタック・フレームの生成と破棄を実装します。 -
メソッド呼び出しの実装: JVM は,メソッドの呼び出しと戻りをサポートします。これにより,コードの再利用と構造化が可能になります。
具体的には,メソッド領域とメソッド呼び出し機構を実装します。
これらを適切に実装すると,Java バイトコードを解釈して実行できるミニマルな JVM が完成し,以下のようなコードを実行できます。
public class Main {
public static void main(String[] args) {
int jvmLevel = getJVMLevel();
print("Hello, JVM Level " + jvmLevel + "!");
}
public static int getJVMLevel()
{
int a = 5;
int b = ~a;
int c = (a + b) >> 1;
int result = (c * c) - (a % 3);
result = -result;
result = result << 1;
return result;
}
public static void print(String message) {
System.out.println(message);
}
}
実装するべき追加のコンポーネント
実装レベル2 の JVM を実装するためには,レベル1 の JVM のもの加えて,以下のコンポーネントを実装する必要があります。
標準ライブラリ
JVM は,Java プログラムの実行に必要な最低限のライブラリである,標準ライブラリを提供する必要があります。
例えば System.out.println
のような標準出力をサポートするために,java.lang.System
クラスや java.io.PrintStream
クラスなどを実装します。
これらのクラスは,JVM に同梱しておき,ユーザの Java プログラムから利用できるようにします。
JVM の実装において,標準ライブラリをどのように提供するかは重要な設計上の決定事項です。
特に,世の中の多くの Java プログラムは,OpenJDK が提供している標準ライブラリに依存しているため,これらのクラスを適切に実装しないと,Java プログラムが正しく動作しません(詳しくは後述します)。
Java の標準ライブラリは存在しない
特に誤解されがちなのは,JVM や Java 言語自体が必ず実装しなければならない標準ライブラリは存在しないという点です。
JVM の仕様には,標準ライブラリに関する規定は一切含まれていません。なぜなら,標準ライブラリの大部分が JVM の実装に完全に依存しているものだからです。
例えば,OpenJDK は,Java の標準ライブラリとして java.base
モジュールを提供していますが,これはあくまでも Java 言語の1つの実装である OpenJDK だけに限った話であり,JVM の仕様には含まれていません。
そのため,JVM を自作する際には必要な標準ライブラリを自分で実装するか,既存のライブラリを流用する必要があります。
特に,既にある大半のクラス・ファイルを実行するためには,java.base
モジュールのクラスをある程度実装する必要があります。
これらのクラス・ファイルは,OpenJDK の標準ライブラリを前提とする javac
でコンパイルされています。そのため,文字列リテラルの表現に java.lang.String
クラスを使用したり,配列の生成に java.lang.reflect.Array
クラスを使用したりなどと,ほぼ全てのクラス・ファイルが java.base
モジュールのクラスに依存しています。
このように,多くのクラス・ファイルが java.base
モジュールに依存しているため,JVM を自作する際には,java.base
モジュールのクラスをある程度実装する必要があります。
今,あなたには3つの選択肢があります。
-
標準ライブラリを一から実装する: 必要なクラスを自分で実装します。例えば,
java.lang.System
クラスやjava.io.PrintStream
クラスなどを実装します。
しかしながら,Java の標準ライブラリは非常に大規模で複雑なため,この方法は非常に時間と労力がかかります。 - JDK を自作する: 独自の Java コンパイラと標準ライブラリ,およびそれらを動作させる独自 JVM を含むパッケージを作成します。これにより,JVM と標準ライブラリの両方を一貫して管理できます。
-
既存のライブラリを流用する: OpenJDK の標準ライブラリを利用する方法があります。具体的には,OpenJDK のインストール先にある
rt.jar
(Java 8 以前)やmodules
(Java 9 以降)をクラス・パスに含めることで,OpenJDK の標準ライブラリを利用できます。
しかしながら,OpenJDK の標準ライブラリを利用する場合は,JVM の実装に依存する部分を適切に処理するための追加の作業が必要になることを理解しておく必要があります。
JDK を自作する
JVM を自作する際には,標準ライブラリを一から実装するのは大変な作業です。
そのため,標準ライブラリを一から実装する代わりに,自作 JDK を作るという選択肢もあります。
自作 JDK は,独自の Java コンパイラと標準ライブラリ,およびそれらを動作させる独自 JVM を含むパッケージです。
これを作成することで,JVM と標準ライブラリの両方を一貫して管理でき,特定の用途に最適化された環境を提供できます。
欠点としては,既存の Java エコシステムとの互換性が失われる可能性があることです。
例えば,OpenJDK の標準ライブラリを前提とする大半の Java プログラムを実行できなくなりますが,一方で特定の用途に特化した軽量な環境を作成できるという利点もあります。
これも一種の JVM/JDK 実装の形態であり,JVM の実装に必要な知識を学ぶ上で有益な経験となることでしょう。
それでも,既存の Java プログラムを実行したい
既存の Java プログラムを実行したい場合は,OpenJDK の標準ライブラリを利用する方法があります。
具体的には,OpenJDK のインストール先にある rt.jar
(Java 8 以前)や modules
(Java 9 以降)をクラス・パスに含めることで,OpenJDK の標準ライブラリを利用できます。
ここで発生する大きな問題は,OpenJDK の JVM 実装に依存しているクラスやメソッドが多数存在しているということです。
例えば,java.lang.Object
にある wait()
メソッドや notify()
メソッドは,JVM のモニタ機構に依存しています。java.io.FileDescriptor
クラスも,標準入出力ストリームの基礎として,OS のファイル・ディスクリプタに依存しています。
これらのメソッドは,native
メソッドとして宣言されているのにも関わらず,実際には外部のライブラリに依存していません。
その代わりに,JVM の実装に組み込まれたネイティブ・コードがこれらのメソッドの動作を提供しています。
そのため,OpenJDK の標準ライブラリを利用する場合は,これらのクラスやメソッドの動作を再現する必要があります。 例えば,java.lang.Object.wait()
が呼び出された場合には,JVM のスレッドを適切に待機状態にするための仕組みを実装する必要があります。
OpenJDK の標準ライブラリを流用することで,既存の Java エコシステムとの互換性をある程度保てる一方で,JVM の実装に依存する部分を適切に処理するための追加の作業が必要になることを理解しておく必要があります。 どちらを選択するかは,JVM を自作する目的や対象とする Java プログラムの要件に依存します(私はこの方法で JVM を自作しました)。
VM ネイティブと標準ライブラリのモック
もし,あなたが OpenJDK の標準ライブラリを流用することにした場合には,このセクションの内容が少し役に立つかもしれません。
そうでない場合には,このセクションは読み飛ばしても問題ありません。
VM ネイティブとは,JVM の実装に組み込まれたネイティブ・コードで,Java の標準ライブラリの一部のメソッドの動作を提供するものです。
例えば,java.lang.Object
クラスの wait()
メソッドや notify()
メソッドは,それぞれ native
メソッドとして宣言されていますが,実際には JVM の実装に組み込まれたネイティブ・コードがこれらのメソッドの動作を提供しています。
Hello, World! を出力するために
ところで,JVM を作り始めてからの最初の目標は,System.out.println("Hello, World!");
を実行できるようにすることだと思います。
このためには,java.lang.System
クラスと java.io.PrintStream
クラスをうまく実装する必要があります。
java.lang.System
クラスは,標準出力ストリームを表す out
フィールドを持っています。
このフィールドは,java.io.PrintStream
クラスのインスタンスを参照しており,それに対して println
メソッドを呼び出すことで,文字列を標準出力に出力します。
このように,System.out
フィールドを適切に初期化するためには,本来 System.initPhase1()
というメソッドを最初に呼び出す必要があります。
しかし,これを呼び出してしまうと,膨大な量の VM ネイティブ(e.g. java.lang.Class
,sun.reflect.Reflection
,jdk.internal.misc.Unsafe
など)を実装しなければならなくなります。
標準ライブラリのモック
そこで,このレベルの JVM では,完全に標準ライブラリを動かすことを目標にせず,いったん System.out
フィールドを直接 PrintStream
のインスタンスで初期化することにします。
さらに, PrintStream
クラスの println
メソッドも,完全に動作するものを目指さず,いったんは文字列を受け取って標準出力に出力するだけの簡易的な実装にします。
このように,標準ライブラリの一部をモック(簡易実装)することで,System.out.println("Hello, World!");
を実行できるようになります。
これの実装には,以下のようにクラスのロード直後に呼び出されるフックを利用すると便利です。場合によっては,「ストラテジ・パターン」を利用すると良いでしょう。
class VMClass {
/* 中略 */
public void onClassLoaded() {
// クラスがロードされたときに呼び出すようにする。
if (this.className.equals("java/lang/System")) {
// System.out フィールドを PrintStream のインスタンスで初期化する
VMField outField = findField("out", "Ljava/io/PrintStream;");
if (outField != null) {
VMClass printStreamClass = VMClassLoader.getInstance().loadClass("java/io/PrintStream");
VMObject printStreamInstance = printStreamClass.newInstance();
outField.setStaticValue(printStreamInstance);
}
} else if (this.className.equals("java/io/PrintStream")) {
// PrintStream.println(String) メソッドを簡易実装する
VMMethod printlnMethod = findMethod("println", "(Ljava/lang/String;)V");
if (printlnMethod != null) {
printlnMethod.overrideImplementation((args) -> {
VMObject stringObj = (VMObject) args[0];
String message = stringObj.toString(); // VMObject から文字列を取得する
System.out.println(message); // Java の標準出力に出力する
});
}
}
}
public VMField findField(String name, String descriptor) {
// フィールド一覧から指定された名前とシグニチャのフィールドを探して返す
}
public VMMethod findMethod(String name, String descriptor) {
// メソッド一覧から指定された名前とシグニチャのメソッドを探して返す
}
}
クラス
クラスは,Java プログラムの基本的な構成要素の一つで,オブジェクト指向プログラミングの基礎となる概念です。 フィールド(属性)とメソッド(操作)を持ち,オブジェクトの設計図として機能します。
クラスの構造
クラスは,クラス・ファイルという JVM 仕様で厳格に定義されたバイナリ形式で保存されます。
これには実行時に必要な情報とデバッグ情報の両方が含まれています。
ここで,実行時に最低限必要な情報を列挙します:
-
定数プール: クラス内で使用される定数(文字列リテラル,数値リテラル,クラス名,メソッド名など)を格納するテーブルです。
特に,メソッドの名前やシグニチャは定数プールのエントリへの参照として格納されているため,適切に処理する必要があります。 - フィールド 一覧: クラスのフィールド(属性)の名前,型,修飾子などの情報です。
- メソッド 一覧: クラスのメソッドの名前,シグニチャ,修飾子,バイトコードなどの情報です。
- スーパークラス 宣言: クラスのスーパークラス(継承元クラス)の名前です。
- インターフェース 宣言: クラスが実装するインターフェースの名前です。
クラスを実装する時に注意するべきこと
クラスを実装するために注意するべきことは,以下の通りです。
-
インスタンスを表現するものとは別にする: クラスは,インスタンス(オブジェクト)を表現するものとは異なります。
クラスは,オブジェクトの設計図であり,あくまでもインスタンスはその設計図に基づいて生成される具体的なオブジェクトです。 例えば,java.lang.String
クラスは文字列オブジェクトの設計図であり,"Hello, World!"
はそのクラスに基づいて生成されたインスタンスです。
クラスをVMClass
のような構造体で表現し,一方絵インスタンスをVMObject
のような別の構造体で表現することが一般的です。 -
継承とインターフェースのサポート: クラスは,スーパークラスを継承したり,インターフェースを実装したりできます。
これにより,コードの再利用と多態性が可能になります。 例えば,java.util.ArrayList
クラスはjava.util.AbstractList
クラスを継承し,java.util.List
インターフェースを実装しています。 これらの関係を適切に管理するために,クラス・ローダがクラスの依存関係を解決する仕組みを実装する必要があります。
メソッドの呼び出し時に,スーパークラスやインターフェースのメソッドを適切に探索するための仕組みも必要です。 -
アクセス制御の実装: クラス,フィールド,メソッドには,
public
,protected
,private
などのアクセス修飾子があり,これらはアクセス制御を実現するために使用されます。
例えば,private
フィールドは同じクラス内からのみアクセス可能であり,protected
メソッドは同じパッケージ内またはサブクラスからアクセス可能です。 これらのルールを適切に実装し,アクセス違反が発生した場合にはIllegalAccessError
をスローする必要があります。 -
静的初期化子のサポート: クラスには,静的初期化子(
<clinit>
メソッド)を定義できます。 これは,クラスが初めてロードされたときに一度だけ実行される特別なメソッドです。
静的初期化子は,静的フィールドの初期化やその他のクラスレベルの設定を行うために使用されます。 例えば,java.lang.Math
クラスには,数学定数PI
やE
を初期化するための静的初期化子が含まれています。
クラスが初めてロードされたときに,静的初期化子を JVM が自動的に呼び出す仕組みを実装する必要があります。 -
静的フィールドのサポート: クラスには,静的フィールド(クラス変数)を定義できます。 これは,クラス全体で共有されるフィールドであり,インスタンスごとに異なる値を持つインスタンス・フィールドとは異なります。
ここで注意すべきなのが,static final
かつ値がコンパイル時に確定するプリミティブ型や文字列リテラルのフィールドは,その値の初期化に静的初期化子を使用しないことです。
例えば,java.lang.Integer
クラスには,public static final int MAX_VALUE = 2147483647;
というフィールドがありますが,これは静的初期化子を使わずにvalue
属性に直接値が格納されています。
クラス・ローダ
クラス・ローダは,クラス・ファイル(.class
ファイル)を読み込み,JVM 内で使用できる形式に変換するコンポーネントです。
特に,クラス・ファイル内の Java バイト・コードを解析して,クラス名やスーパークラス,インターフェース,フィールド,メソッドなどを抽出します。
OpenJDK が提供する JVM では,クラス・パスという仕組みを使用して,クラス・ファイルの検索と読み込みを行います。
クラス・パスとは,クラス・ファイルが格納されているディレクトリや JAR ファイルの一覧を指定するためのものです。
ユーザが JVM を起動する際に,-cp
オプションや CLASSPATH
環境変数を使用してクラス・パスを列挙しておくことで,JVM が探索すべき場所を指定できます。
なお,クラス・ファイルの探索の対象になるファイルは,必ずしも .class
ファイルとは限りません。
これ以外にも,クラス・ファイルのアーカイブである .jar
や zip
ファイル,或いは Java 9 以降で導入されたモジュール・システムの .jmod
ファイルなども含まれます。
モジュール・システムのサポート
OpenJDK による標準ライブラリを使用した Java 9 以降の JVM を実装する場合は,java.base
モジュールをサポートする必要があります(java.base.jmod
というファイル名で提供されています)。
Java 9 以降では,モジュール・システムが導入されました。
モジュール・システムは,クラスやパッケージを論理的な単位でグループ化し,依存関係を明示的に管理する仕組みです。
モジュール・ファイルの拡張子は .jmod
で,以下のような構造を持ちます。
module-name.jmod
├── classes/ # クラス・ファイルが格納されるディレクトリ
| └── com/example/MyClass.class # クラス・ファイルの例
├── conf/
│ └── module-info.class # モジュールの構成が記述されたクラス・ファイル
|── lib/ # ネイティブ・ライブラリが格納されるディレクトリ
└── ... # その他のリソース
このように,Java アーカイブ・ファイル(JAR)がクラス・ファイルを纏めるだけであるのに対して,モジュール・ファイル(JMOD)は論理的な構造を持ち,それ以外にもネイティブ・ライブラリや設定ファイルなどを含んでいる場合があります。
実装を簡単にするために,Java アーカイブ・ファイルとの処理をある程度共通化することも可能です。
そのためには,Java アーカイブ・ファイルを読み込む処理に,モジュール・ファイルを読み込む処理を追加するだけで済みます。具体的には,モジュール・ファイルが読み込まれた場合に,classes/
ディレクトリ内のクラス・ファイルを抽出して,クラス・ローダに渡すようにするなどの対応が考えられます。
クラス・ローダの設計上の注意点
クラス・ローダを設計する際には,以下の点に注意する必要があります。
-
クラスの識別: クラスは,完全修飾名(パッケージ名を含むクラス名)で一意に識別されます。ここで注意すべきは,クラス名に含まれるドット(
.
)は,JVM の内部表現ではスラッシュ(/
)で表現されることです。 例えば,java.lang.String
クラスは,クラス・ファイル内ではjava/lang/String
として表現されます。 - クラスのロード順序: クラス・ローダは,クラスのロード順序を適切に管理する必要があります。 例えば,スーパークラスが先にロードされていない場合は,サブクラスをロードできません。 そのため,クラス・ローダは依存関係を解決しながらクラスをロードする仕組みを実装する必要があります。
-
配列クラスの取り扱い: 配列クラスは,特別な扱いが必要です。 例えば,
int[]
クラスはint
クラスとは異なるクラスとして扱われます。 そのため,配列クラスをロードする際には,基礎となる要素型のクラスが既にロードされていることを確認する必要があります。さらに,これら配列クラスはランタイムで暗黙的に作成される上に,VM 内で認識されるクラス・オブジェクト(Class
オブジェクト)は同一のものを参照する必要があります。
そのため,通常のクラスと配列クラスを分けて管理し,配列クラスを生成するための専用の仕組みを実装すると良いでしょう。 - クラスの再ロード: JVM の仕様上,同じ名前のクラスを複数回ロードすることはできません。 そのため,クラス・ローダは,既にロードされたクラスを再度ロードしようとした場合には,既存のクラスを返すようにする必要があります。
スタック・フレームとメソッドの呼び出し
スタック・フレームとは,メソッドの呼び出しごとに作成されるデータ構造で,メソッドの実行に必要な情報を保持するものです。
これには,「ローカル変数配列」や「オペランド・スタック」,メソッドの戻り先アドレスなどが含まれます。
通常の JVM では,メソッドの呼び出しごとに新しいスタック・フレームが作成され,メソッドの実行が終了するとスタック・フレームが破棄されます。 スタック・フレームは,メソッドの実行中に必要な情報を保持し,メソッドの呼び出しと戻りを管理します。これらは,各スレッドに1つだけ存在する「Java スタック」というスタックに積まれます。
全てのメソッド呼び出しは,この Java スタックにスタック・フレームを積むことで実現されます。
スタック・フレームの実装
「レベル1: 命令を解釈して実行する超簡単な JVM」では,スタック・フレームという概念を持ち出さずに,単一のオペランド・スタックとローカル変数配列を持つだけの簡易的な JVM を実装しました。
このレベルの JVM では,これらをスタック・フレームという概念でまとめ,管理するようにします。
或るメソッドは,他のメソッドのスタック・フレームに直接干渉することはできません。
例えば,メソッド A がメソッド B を呼び出した場合,メソッド A のスタック・フレームはメソッド B のスタック・フレームの下に積まれます。 このとき,メソッド B が メソッド A のローカル変数配列やオペランド・スタックの内容を直接参照したり,変更したりすることはできません。
スタック・フレームを実装する際には,以下の点に注意する必要があります。
-
ローカル変数配列の作成: 各スタック・フレームは,メソッドのローカル変数を格納するための配列を持ちます。
ローカル変数配列は,メソッドの引数やローカル変数を格納するために使用されます。
この配列の大きさは,メソッドのCode
属性にあるmax_locals
フィールドで指定され,それが属するフレームの作成時に確保されます。この配列がランタイムで動的に拡張・縮小されることはありません。 -
ローカル変数配列への不正アクセスの防止: メソッドがローカル変数配列にアクセスする際には,インデックスが
0
からmax_locals - 1
の範囲内であることを確認する必要があります。もし,この範囲外のインデックスにアクセスしようとした場合には,VerifyError
をスローできます。さらに,指定されたインデックスにある要素が2ワード型(
long
やdouble
)の値を構成するもので,かつその要素が下位ビットの要素である場合には,VerifyError
をスローする必要があります。例えば,ローカル変数配列locals
が{int, long(下位ビット), long(上位ビット)}
のような状態であるときに,locals[1]
にアクセスしようとした場合には,VerifyError
をスローします。 -
オペランド・スタックの作成: 各スタック・フレームは,メソッドのオペランドを格納するためのスタックを持ちます。
オペランド・スタックは,メソッドの計算やデータの一時的な保存に使用されます。
このスタックの大きさも同様に,メソッドのCode
属性にあるmax_stack
フィールドでフレームの作成時に確保されます。ランタイムでの大きさの動的な変更もありません。
メソッドの呼び出し
メソッドを呼び出す際には,前述の通り,JVM は新しいスタック・フレームを作成し,Java スタックに積みます。
このとき,メソッドの引数は,呼び出し元のスタック・フレームのオペランド・スタックからポップされ,新しいスタック・フレームのローカル変数配列に格納されます。
どのように引数がローカル変数配列に格納されるかについては,メソッドが静的メソッドかインスタンス・メソッドかや,メソッドのシグニチャによって異なります。
-
静的メソッド: ローカル変数配列の0番地から順に引数が格納されます。
例えば,次のような静的メソッドを考えてみましょう。static void exampleStaticMethod(int a, long b, double c) { // メソッドの本体 }
この場合には,以下のように引数が格納されます。
-
1番地:
int a
-
int
型は 1 ワード(32 ビット)なので,インデックス 1 に格納されます。
-
-
2番地と3番地:
long b
-
long
型は 2 ワード(64 ビット)なので,インデックス 2 と 3 に格納されます。
-
-
4番地と5番地:
double c
-
double
型も 2 ワード(64 ビット)なので,インデックス 4 と 5 に格納されます。
-
(1ワードと2ワードについては,前の記事を参照してください。)
-
1番地:
-
インスタンス・メソッド: 最初の引数として
this
参照が 0番地 に格納されたあとに,引数が順に格納されます。
例えば,次のようなインスタンス・メソッドを考えてみましょう。void exampleInstanceMethod(int a, long b, double c) { // メソッドの本体 }
この場合には,以下のように引数が格納されます。
-
0番地:
this
-
this
参照は,メソッドが属するオブジェクトの参照です。
-
-
1番地:
int a
-
2番地と3番地:
long b
-
4番地と5番地:
double c
-
0番地:
これらの引数は,呼び出し元のスタック・フレームのオペランド・スタックからポップされ,新しいスタック・フレームのローカル変数配列に格納されます。
このとき,ポップした値がシグニチャで指定された型と一致しない,または適合しない場合には,VerifyError
をスローする必要があります。 例えば,int
型の引数に long
型の値を渡そうとした場合には,VerifyError
をスローします。
メソッドの呼び出しが完了すると,(戻り値がある場合には)戻り値が呼び出し元のスタック・フレームのオペランド・スタックにプッシュされ,呼び出し元のスタック・フレームが再びアクティブになります。 メソッドの呼び出しは invoke
系の命令で行われるため,その命令の実行結果として呼び出し元のオペランド・スタックに戻り値をプッシュします。
メソッドの探索
メソッドの呼び出し時には,呼び出すべきメソッドを探索する必要があります。 このセクションでは,メソッドの定義とその呼び出し命令の例とともに,メソッドの探索を実装する際に注意すべき点を説明します。
例えば,次のようなクラスがあるとします。
public class Parent {
public void greet() {
System.out.println("Hello from Parent");
}
public void farewell() {
System.out.println("Goodbye from Parent");
}
}
public class Child extends Parent {
@Override
public void greet() {
System.out.println("Hello from Child");
}
public void callSuperGreet() {
super.greet();
}
}
このとき Child
クラスの great
を呼び出す場合には,以下のような invokevirtual
命令が使用されます。
aload_0 // Child インスタンスをオペランド・スタックにプッシュ
invokevirtual Child->greet()V // Child.greet() メソッドを呼び出す
この命令では,Child
クラスの greet
メソッドを呼び出すことを想定しているため,そのまま Child
クラスのメソッド一覧から greet
メソッドを探索するだけで済みます。
通常のメソッド呼び出しでは,オペランド・スタックから取得したオブジェクトのクラスを基準にしてメソッドを探索します。
次に Child
クラスのインスタンスを用いて Parent
クラスの farewell
メソッドを呼び出す場合を考えます。
aload_0 // Child インスタンス
invokevirtual Parent->farewell()V // Parent.farewell() メソッドを呼び出す
この場合には,Child
クラスのメソッド一覧から farewell
メソッドを探索しても見つかりません。 このような場合には,対象クラスのスーパー・クラスを辿って farewell
メソッドを探索します。 このとき,Child
クラスのスーパークラスである Parent
クラスに farewell
メソッドが存在するため,それを呼び出します。
メソッドが見つかるまでこれを繰り返し,Object
クラスまで辿っても見つからなかった場合には,NoSuchMethodError
をスローします。
さらに,super
キーワードを使用してスーパークラスのメソッドを呼び出す場合も考慮する必要があります。 例えば,Child
クラスの callSuperGreet
メソッド内で super.greet()
を呼び出す場合です。
aload_0 // Child インスタンス
invokespecial Parent->greet()V // Parent.greet() メソッドを呼び出す
よく誤解されがちな点として,invokespecial
命令 はコンストラクタの呼び出しのみならず,スーパー・クラスのメソッド呼び出しにも使用される,ということです。
この例では,invokespecial
命令を使用して Parent
クラスの greet
メソッドを呼び出しています。この場合には,Child
クラスのスーパークラスである Parent
クラスから直接 greet
メソッドを探索し,それを呼び出します。
まとめ
さて,この記事では,「レベル2: 最低限の標準ライブラリとクラス・ローダを持つミニマルな JVM」を実装するために必要なコンポーネントとその設計上の注意点について説明しました。
このレベルの JVM を実装すると Hello, World!
を出力できるようになり,本格的な JVM の実装に向けた基礎が築けます。
フレームやクラス・ローダの実装は,次のレベルである「レベル3: ガベージコレクションとスレッドをサポートする本格的な JVM」の基礎となりますからね。
次の記事では,「レベル3: ガベージコレクションとスレッドをサポートする本格的な JVM」を実装するために必要なコンポーネントとその設計上の注意点について説明します。
この記事が,JVM を自作したい方々にとって有益なリソースとなり,JVM の理解と実装の一助となれば幸いです。
では,良いバイト・コード・ライフを!
Discussion