JavaでJVM言語を作ってみる(2) - バイトコード解析
前回の続きです。
足し算プログラム"Adder"を、直接バイトコードを出力して作ります。
その前に、JVMの機械語について少し触れてみたいと思います。
"Adder"は、mainとaddの2つのstatic関数だけのクラスで成っています。
まずは、Javaで同様のプログラムを書いてみます。
最初は一部を単純にするために限定バージョンにします。
// バージョン1 class Adder { static int add(int... a) { int r = 0; for (int i : a) { r += i; } return r; } public static void main(String... args) { System.out.println(add(8, 5)); } }
コンパイルして実行すると、"13"と出力されます。
これをバイトコードレベルでどうなっているのかを解析してみましょう。
$ javap -c Adder.class
Compiled from "Adder.java"
class Adder {
Adder();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
static int add(int...);
Code:
0: iconst_0
1: istore_1
2: aload_0
3: astore_2
4: aload_2
5: arraylength
6: istore_3
7: iconst_0
8: istore 4
10: iload 4
12: iload_3
13: if_icmpge 33
16: aload_2
17: iload 4
19: iaload
20: istore 5
22: iload_1
23: iload 5
25: iadd
26: istore_1
27: iinc 4, 1
30: goto 10
33: iload_1
34: ireturn
public static void main(java.lang.String...);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iconst_2
4: newarray int
6: dup
7: iconst_0
8: bipush 8
10: iastore
11: dup
12: iconst_1
13: iconst_5
14: iastore
15: invokestatic #3 // Method add:([I)I
18: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
21: return
}クラスAdderのメソッドの定義が出力されました。上から順に、暗黙のデフォルトコンストラクタ、addメソッド、mainメソッドとなっています。
ひとつずつ見ていきます。命令コードの詳細については、Java仮想マシン - Wikipediaなどを参照してください。
- デフォルトコンストラクタ
Adder();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return JVMはスタックマシンで、スタックに積まれた値で計算を行います。スタックへは定数やローカル変数の値を積む(loadする)ことができます。スタックの先頭の値をローカル変数へ保存(storeする)こともできます。メソッド呼び出しの無限ループなどでこのスタックが溢れると、StackOverFlowになります。
このコンストラクタについては、暗黙のsuper()でObjectクラスのコンストラクタを呼んでいるだけです。"aload_0"は変数0から(スタックに)参照値(参照型の値)を取り出す命令で、ここではthisを取り出しています。"invokespecial"は特殊インスタンスメソッド(ここではインスタンス初期化メソッド)の呼び出し命令です。
つまり、thisを引数にして特殊インスタンスメソッドjava/lang/Object.
- addメソッド
当然ながら、コンストラクタより少し複雑になっています。# の後ろは私が入れたコメントです。
# 変数0: 参照型 パラメータ a
# 変数1: int total
# 変数2: 参照型 無名(パラメータaのコピー?)
# 変数3: int 無名(a.lengthの値)
# 変数4: int 無名(配列カウンタ)
# 変数5: int i
static int add(int...);
Code:
0: iconst_0
1: istore_1 # 定数0 → stack → 変数1(total)
2: aload_0
3: astore_2 # パラメータa → stack → 変数2
4: aload_2
5: arraylength
6: istore_3 # 変数2 → stack → 配列の長さ取得 → stack → 変数3
7: iconst_0
8: istore 4 # 定数0 → stack → 変数4
10: iload 4 # 変数4 → stack
12: iload_3 # 変数3 → stack
13: if_icmpge 33 # stack → v1, stack → v2; if v1(変数3) > v2(変数4) then goto 33
16: aload_2 # 変数2 → stack
17: iload 4 # 変数4 → stack
19: iaload # stack → v1, stack → v2; v1[v2] (変数4[変数2]) → 変数5
20: istore 5
22: iload_1 # 変数1(total) → stack
23: iload 5 # 変数5(i) → stack
25: iadd # stack → v1, stack → v2; v1 + v2 (total + i) → stack
26: istore_1 # stack → 変数1(total)
27: iinc 4, 1 # 変数4 += 1
30: goto 10 # goto 10
33: iload_1 # 変数1 → stack → return
34: ireturn まず、ローカル変数について。Javaソース上では、a,total,iの3個しか宣言していませんが、無名の変数があと3つあります。
これはおそらく、
int 変数3 = a.length; for (int 変数4 = 0; 変数4 < 変数3; 変数4++) { // ... }
というコードに変換されたんだと思います。
(変数2はパラメータだからだと思いますが、これは今回は無視します。)
その他、いくつか抜粋して説明します。
命令コードは、名前が型+命令(+"_")+番号という体系のものがあります。
たとえば、iconst_0は、型i=int、命令const=定数をスタックに積む、番号=定数の値で「intの定数0をスタックに積む」ことを示しています。
同様に、aload_2は、型a=参照型、命令load=変数の値をスタックに積む、番号=変数の番号で「変数2から参照型の値を取り出しスタックに積む」ことを示しています。
コメントの「→ stack」は、スタック(の先頭)に積む、「stack →」はスタック(の先頭)から取り出すことを示しています。
命令セットを単純化するため、ひとつの操作で出来ることが限られています。total=0を行うには、まず定数0をスタックに積み、スタックから変数1に取り出すとしなければなりません。
この辺りを踏まえてコメントも合わせて読んでいただければ、コードの意味が分かってくるのではないかと思います。
- mainメソッド
public static void main(java.lang.String...);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iconst_2 # 定数2 → stack
4: newarray int # new int[2] → stack (intarray)
6: dup # copy stack(intarray[]) → stack
7: iconst_0 # 定数0 → stack
8: bipush 8 # int 8 → stack
10: iastore # stack x 3 → intarray[0]
11: dup # copy stack(intarray[]) → stack
12: iconst_1 # 定数1 → stack
13: iconst_5 # 定数5 → stack
14: iastore # stack x 3 → intarray[1]
15: invokestatic #3 // Method add:([I)I
18: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
21: return # return voidこちらはローカル変数を使用していないため、スタックマシンならではの動きになっています。
スタックの状態をトレースしてみましょう。
アドレス15を実行する時点で、スタックの先頭がintarray、2番目が"Field java/lang/System.out:Ljava/io/PrintStream;"になり、"Method add:([I)I"が実行された直後(アドレス18)では、スタックの先頭がintの13、2番目が"Field java/lang/System.out:Ljava/io/PrintStream;"になるはずです。
順番が逆のように思われるかもしれませんが、これは、スタックだけで計算が済む場合にローカル変数が不要になるので、合理的なしくみと言えます。
Java言語がどのようなマシンコードに変換され、このコードでJVMがどのように動いているかが少し分かってきました。
JVMをスタックマシンとして操作するイメージが沸いてくれば、直接JVMのマシンコード、すなわちバイトコードをJava言語のソースコード無しで生成するのはそれほど難しくないのかも知れません。
それに、Java言語とは関係なくバイトコードを生成できるということは、Java言語ではできない、もしくはまだサポートされていない機能を独自に実装できるかもしれないということです。例えば文字列のswitchは、APIやJVMを変えることなく実現可能でした(Java SE 7 (1) - 文字列switchのからくり参照)。
そう考えてみると、もっと面白くなりそうです。
次回は、"Adder"のクラスファイルをjavacを使わずにBCELで作っていきます。
(つづく)