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で作っていきます。
(つづく)