【超簡単な】 Java 仮想マシンを自作したい全ての方々へ

に公開

はじめに

この記事は,Java 仮想マシンを自作したい全ての方々へ の子記事のようななものです。
上記の記事では JVM の実装に必要な知識をまとめましたが,実際に JVM を実装するのは大変なことです。
そこで,JVM の実装を 4段階にレベル分けして,それぞれの概要を述べました。

この記事では,その中で最も簡単な「実装レベル1: 命令を解釈して実行する超簡単な JVM」を実装するために必要な知識とリソースをまとめます。
なお,この記事も,JVM を自作するための詳細な手順やチュートリアルを提供するものではありません。

実装レベル1: 命令を解釈して実行する超簡単な JVM とは

実装レベル1 のJVM は,ただ命令を解釈して実行するだけの非常にシンプルな JVM で,この図の緑色の部分に相当します。

JVM レベル1

このレベルの 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 0long 型の値 0x0123456789ABCDEF を格納する場合は,次のようになります。

+------------+------------+
|   slot 0   |   slot 1   |
+------------+------------+
| 0x89ABCDEF | 0x01234567 |
+------------+------------+

ローカル変数配列

ローカル変数配列とは,メソッド内で使用される変数を格納するための領域のことです。
各メソッドには独立したローカル変数配列があり,メソッドの引数やローカル変数がここに保存されます。

例えば,次のような Java メソッドを考えてみましょう。

static int add(int a, int b) {
    int sum = a + b;
    return sum;
}

このメソッドでは,ab は引数であり,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 であれば,ASMBCEL などがあります。

特に 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());
    }
  }
}

閑話:命令のニーモニックについて

JVM の命令一覧は,JVM 仕様書のセクション 6に記載されています。
通常,命令はオペコード(1 バイトの数値)で識別されますが,仕様書では理解しやすくするためにニーモニック(mnemonic)と呼ばれる文字列で表現されています。
例えば,オペコード 0x60iadd というニーモニックで表されます。

仕様書を読み進めると或ることに気がつくかもしれません。
例えば,int 型を扱う命令には iloadistoreiadd などがありますが,long 型を扱う命令には lloadlstoreladd などがあります。
このように,特定の型に特化した命令には,識別しやすいように命令名の先頭に型を示す文字が付与されているのです。

これをまとめると,次のような対応表が得られます。

データ型 先頭の文字
byte b bipush, baload, bastore
char c caload, castore
short s sipush, saload, store
int i iload, istore, iadd
float f fload, fstore, fadd
long l lload, lstore, ladd
double d dload, dstore, dadd
参照値 a aload, astore, aastore

「あれ,boolean 型は?」と思うかもしれませんが,boolean 型は JVM バイトコード上では int 型として扱われます。
boolean 型を特別に扱う命令は存在せず,byte 型と同様に bipushbaloadbastore などの命令を用いて 1(true)や 0(false)を操作することで表現します。

なお,newarray(配列を生成する)命令では,boolean 型の配列を生成するために T_BOOLEAN という定数をオペランドに受け取ります。

このことは,仕様書のセクション 2.3.4. The boolean type に記載されています。

閑話:直交している命令と直交していない命令

先程は,扱う値の型に特化した命令,つまり iloadfload のように,整数型や浮動小数点型など特定のデータ型専用の命令を紹介しました。
しかし,JVM の命令セットには,これ以外にも型に依存しない汎用的な命令が存在します。これらの命令は,スタックにある値が何であろうと同じように動作するため,命令を再利用しやすくなっています。

例えば pop 命令は,オペランド・スタックの最上位の値をポップ(削除)しますが,その値が int 型であろうと float 型であろうと関係なく動作します。同様に dup 命令も,スタックのトップにある値を複製しますが,その値の型によらず同じように動作します(ただし,long 型や double 型のように 2 ワード幅の値に対しては dup2 命令を使用します)。

もしも,型ごとに専用の pop 命令が存在したとすると,ipopfpopdpop などといった命令が必要になり,命令セットが非常に肥大化してしまうことが予想されます。これは,仕様の策定者にとっても,JVM を実装する開発者にとっても,望ましいことではありません。そのため, JVM は敢えてこのようにしていないのです。

これは設計上の意図によるもので,JVM の命令セットは型に依存している命令(これを「直交していない命令」と呼びます)と,型に依存しない命令(これを「直交している命令」と呼びます)の両方を組み合わせて構成されています。前者は,コンパイル時に方が明確な場合に最適なパフォーマンスを提供し,後者は命令セットの簡潔さと再利用性を向上させます。
特に JVM のオペコードは 1 バイトに制限されているため,最大でも 256 個の命令しか定義できません。そのため,直交している命令を導入することで,限られた命令セットの中で多様な操作を実現しています。

要するに,JVM の命令セットは型に特化した命令と汎用的な命令**を併用したハイブリッド構造を取ることによって,コンパクトかつ効率的に設計されているのです。

このことは,JVM 仕様書のセクション 2.11.1. Types and Java Virtual Machine Instructionsに記載されています。

プログラム・カウンタ

プログラム・カウンタ(PC)は,現在実行中の命令の位置を示すインデックスです。
JVM は,命令デコーダを使用してバイト・コードを逐次的に読み取り,PC を更新しながら命令を実行します。
例えば,次のような命令列を考えてみましょう。

0: bipush 7   // 定数 7 をスタックに積む
2: iconst_3   // 定数 3 をスタックに積む
3: iadd       // スタックから2つの値をポップして足し算する

この場合,プログラム・カウンタの値は次のように遷移します。

  • 最初,PC は 0 に設定されます。
  • bipush 7 命令を実行すると,PC は 2 に更新されます。
    bipush 命令は,そのオペランドとして 1 バイトを持つため,命令全体の長さは 2 バイトだからです。
  • 次に,iconst_3 命令を実行すると,PC は 3 に更新されます。

このように,PC は命令の長さに応じて逐次更新され,JVM が次に実行すべき命令を指し示します。
PC の値はインタプリタによって管理され,命令デコーダに渡されて命令を取得するために使用されます。

インタプリタ

インタプリタは JVM の中心的なコンポーネントであり,命令デコーダから取得した命令を解釈して実行します。
インタプリタは プログラム・カウンタ を使用して現在の命令を追跡し,オペランド・スタックやローカル変数配列を操作します。

具体的には,まずプログラム・カウンタの値を読み取り,そのまま命令デコーダに渡して命令を取得します。
次に,取得した命令に基づいて,オペランド・スタックやローカル変数配列を操作します。
これを繰り返し,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 の理解と実装の一助となれば幸いです。

では,よいバイト・コードライフを!

参考文献&リンク集

GitHubで編集を提案

Discussion