【超簡単な】 Java 仮想マシンを自作したい全ての方々へ
はじめに
この記事は,Java 仮想マシンを自作したい全ての方々へ の子記事のようななものです。
上記の記事では,JVM の実装に必要な知識をまとめましたが,実際に JVM を実装するのは大変なことです。
そこで,JVM の実装を 4段階にレベル分けして,それぞれの概要を述べました。
この記事では,その中で最も簡単な「実装レベル1: 命令を解釈して実行する超簡単な JVM」を実装するために必要な知識とリソースをまとめます。
なお,この記事も,JVM を自作するための詳細な手順やチュートリアルを提供するものではありません。
実装レベル1: 命令を解釈して実行する超簡単な JVM とは
実装レベル1 のJVM は,ただ命令を解釈して実行するだけの非常にシンプルな JVM で,この図の緑色の部分に相当します。
このレベルの JVM は,以下のような特徴を持ちます。
-
基本的な命令セットのサポート: Java バイトコードの基本的な命令セットをサポートします。例えば,算術演算や変数の読み書きなどです。
すべての命令を網羅的にサポートするわけではなく,目的に沿った基本的な命令について取り組みます。 -
命令を解釈して実行: JVM は,Java バイトコードを逐次的に読み取り,命令を解釈して実行します。
Java プログラムの動作を実際に模倣して,体系的な動作を獲得します。
そのために,命令デコーダ,プログラム・カウンタ,インタプリタを実装します。 -
状態を保持: JVM は,スタック・フレーム,ローカル変数,オペランド・スタックなどの状態を保持します。
これにより,Java プログラムの実行中に必要な情報を管理します。
このレベルの JVM では,後者2つ:ローカル変数とオペランド・スタックを実装します。
これらを適切に実装すると,Java バイトコードを解釈して実行できる超簡単な JVM が完成し,以下のようなコードを実行できます。
int a = 1 + 2;
double b = 3.0 * 4.0;
int c = (a ^ (24 >> 1)) & 0xFF;
実装するべきコンポーネント
JVM はいくつかの主要なコンポーネントで構成されており,実装レベル1 では,最低でも以下のコンポーネントを実装する必要があります。
オペランド・スタック
オペランド・スタックとは,命令の実行中にオペランドを一時的に保存するためのデータ構造です。これは,「フレーム」と呼ばれる別の領域の一部として存在します。
オペランド・スタックは,LIFO(Last In, First Out)方式で動作し,命令がオペランドをプッシュ(追加)したりポップ(削除)したりするために使用されます。例えば,算術演算命令は,オペランド・スタックから値をポップし,計算を行い,結果を再びスタックにプッシュします。
例えば,次のような命令列を考えてみましょう。
// スタックの中身:[ ]
iconst_2 // 定数 2 をスタックに積む
// スタックの中身:[2]
iconst_3 // 定数 3 をスタックに積む
// スタックの中身:[2, 3]
iadd // スタックから2つの値をポップして足し算する
// スタックの中身:[5]
この場合,iconst_2
命令はスタックに 2 をプッシュし,iconst_3
命令はスタックに 3 をプッシュします。次に,iadd
命令はスタックから 3 と 2 をポップし,それらを足し算して結果の 5 を再びスタックにプッシュします。
JVM 仕様に従うと,オペランド・スタックの1つの要素には 32 ビット(4 バイト)幅の値を格納できることになっています。
例えば,int
型や float
型,参照値(オブジェクトのアドレスなど)は 32 ビットで表現されるため,これらはオペランド・スタックに直接格納できます。
一方で,long
型や double
型は 64 ビット(8 バイト)幅の値を持つため,これらはオペランド・スタックに格納する際に,2 つの連続した要素を使用します。
最初の要素には値の下位 32 ビットが格納され,次の要素には上位 32 ビットが格納されます。
例えば,slot 0
に long
型の値 0x0123456789ABCDEF
を格納する場合は,次のようになります。
+------------+------------+
| slot 0 | slot 1 |
+------------+------------+
| 0x89ABCDEF | 0x01234567 |
+------------+------------+
ローカル変数配列
ローカル変数配列は,メソッド内で使用される変数を格納するための領域です。
各メソッドには独立したローカル変数配列があり,メソッドの引数やローカル変数がここに保存されます。
Java 言語では,メソッドの引数やローカル変数は,名前を使って参照されます。
しかしながら,JVM の内部ではこれらの変数はインデックス(整数値)を使って参照されるうえに,命名は保持されません。
例えば,次のような Java メソッドを考えてみましょう。
static int add(int a, int b) {
int sum = a + b;
return sum;
}
このメソッドでは,a
と b
は引数であり,sum
はローカル変数です。JVM バイトコードでは,これらは次のようにインデックスで参照されます。
static int add(int, int) {
iload_0 // 引数 a をローカル変数配列から読み出し
iload_1 // 引数 b をローカル変数配列から読み出し
iadd // これらを足し算
istore_2 // 結果をローカル変数配列のインデックス 2 に保存(sum)
iload_2 // ローカル変数 sum を読み出し
ireturn // 戻り値として返す
}
iload_0
命令はローカル変数配列のインデックス 0(引数 a
)を読み出し,iload_1
命令はインデックス 1(引数 b
)を読み出します。
istore
系も同様です。
命令デコーダ
命令デコーダは,JVM バイトコードを読み取り,命令を解釈するコンポーネントです。
JVM が読み込むバイト・コードは,(通常は)クラス・ファイルという形式で保存されており,命令デコーダはこのバイト・コードを逐次的に読み取ります。
クラス・ファイルの中身はこんな感じです。
このように,クラス・ファイルは CA FE BA BE
というマジック・ナンバーで始まり,その後にバージョン情報や定数プール,フィールド情報,メソッド情報などが続きます。
クラス・ファイルに関する詳細な情報は,JVM 仕様書のセクション 4を参照してください。
命令デコーダは,クラス・ファイルの中からメソッドのバイト・コードを抽出し,逐次的に読み取ります。 各命令は1バイトのオペコードで始まり,必要に応じて追加のオペランドが続きます。
追加のオペランドは,オペコード(命令を識別するための1バイトの値)に応じて,存在する場合としない場合があります。 例えば,iadd
命令はオペランドを持たず,単にスタックから2つの整数をポップして足し算します。 一方で,bipush
命令は1バイトのオペランドを持ち,そのオペランドをスタックにプッシュします。
それぞれの命令のオペランドの有無やサイズは,JVM 仕様書のセクション 6に詳しく記載されています。
もし,例えば簡単な命令デコーダを Python で実装する場合には,次のようなコードになります。
def decode_instruction(bytecode, pc):
opcode = bytecode[pc]
pc += 1 # オペコードを読み取ったので,プログラム・カウンタを1進める
if opcode == 0x60: # iadd 命令
return ('iadd', []), pc
elif opcode == 0x10: # bipush 命令
operand = bytecode[pc]
pc += 1 # オペランドを読み取ったので,プログラム・カウンタを1進める
return ('bipush', [operand]), pc
# 他の命令も同様に処理する
或いは,このコンポーネントの自作を省略して既存のライブラリを利用することもできます。
例えば,Python であれば,javatools,Java であれば,ASM や BCEL などがあります。
特に ASM は,JVM バイトコードの操作に特化した強力なライブラリであり,命令デコーダとしても利用できます。 次のように,ASM を使ってクラス・ファイルを読み込み,メソッドのバイトコードを取得できます。
void readClassFile(String classFilePath) throws IOException {
// クラス・ファイルを読み込み,メソッドのバイトコードを取得する
ClassReader classReader = new ClassReader(new FileInputStream(classFilePath));
ClassNode classNode = new ClassNode();
classReader.accept(classNode, 0);
// メソッドの一覧を取得して表示する
for (MethodNode method : classNode.methods) {
System.out.println("Method: " + method.name);
// メソッドのバイトコードを取得して表示する
InsnList instructions = method.instructions;
for (AbstractInsnNode insn : instructions) {
System.out.println(insn.getOpcode());
}
}
}
プログラム・カウンタ
プログラム・カウンタ(PC)は,現在実行中の命令の位置を示すインデックスです。
JVM は,命令デコーダを使用してバイト・コードを逐次的に読み取り,PC を更新しながら命令を実行します。
例えば,次のような命令列を考えてみましょう。
iconst_2 // 定数 2 をスタックに積む
iconst_3 // 定数 3 をスタックに積む
iadd // これらを足し算する
この場合,PC は最初に 0 に設定され,iconst_2
命令を実行した後に 1 に進みます。次に,iconst_3
命令を実行した後に 2 に進み,最後に iadd
命令を実行した後に 3 に進みます。
このように,インタプリタがどの命令を次に実行するべきかについて,PC を利用して管理します。
インタプリタ
インタプリタは JVM の中心的なコンポーネントであり,命令デコーダから取得した命令を解釈して実行します。
インタプリタは,PC を使用して現在の命令を追跡し,オペランド・スタックやローカル変数配列を操作します。
具体的には,まずプログラム・カウンタの値を読み取り,そのまま命令デコーダに渡して命令を取得します。
次に,取得した命令に基づいて,オペランド・スタックやローカル変数配列を操作します。
これを繰り返し,Java バイトコードのすべての命令を実行します。
例えば,簡単なインタプリタを Python で実装する場合には,次のようなコードになります。
def interpret(bytecode):
pc = 0 # プログラム・カウンタを初期化
stack = [] # オペランド・スタックを初期化
locals = [0] * 10 # ローカル変数配列を初期化(サイズは適宜調整)
while pc < len(bytecode):
instruction, pc = decode_instruction(bytecode, pc)
opcode, operands = instruction
if opcode == 'iadd':
b = stack.pop()
a = stack.pop()
stack.append(a + b)
elif opcode == 'bipush':
value = operands[0]
stack.append(value)
# 他の命令も同様に処理する
まとめ
この記事では,「実装レベル1: 命令を解釈して実行する超簡単な JVM」を実装するために必要な知識とリソースをまとめました。
このレベルの JVM は,基本的な命令セットのサポート,命令の解釈と実行,状態の保持などの特徴を持ちます。
さらに,実装するべき主要なコンポーネントとして,オペランド・スタック,ローカル変数配列,命令デコーダ,プログラム・カウンタ,インタプリタを紹介しました。
これらのコンポーネントを適切に実装することで,Java バイトコードを解釈して実行できる超簡単な JVM が完成します。
この記事が,JVM を自作したい方々にとって有益なリソースとなり,JVM の理解と実装の一助となれば幸いです。
では,よいバイト・コードライフを!
Discussion