Javaのデバッグをしていて、ステップ実行中にステップインを繰り返したらソースコードのないところに行き当たったことがあるだろう。あるいはEclipseでF3キーでクラスやメソッド・フィールドの宣言元を辿っていってソースコードのないところに行き当たったことがあるだろう。
Eclipseの場合、"Class File Editor"というものが開く。そこにはJavaのバイトコードのニーモニックがズラズラと並んでいて、「これは読めないや、ワケが分からない」と投げ出してしまったりしていないだろうか。
怖がることはない。ちょっとコツを掴めばすぐに読めるようになる。
Class File Editorの開き方
自前のJavaクラスの場合、ビルドして出来上がったclassファイルを開く必要がある。"Package Explorer"だとclassファイルは隠されていて見えないのでWindow -> Show View -> Navigatorを選択し、"Navigator"を表示させる。あとはJavaプロジェクトを作った際に指定した出力先を探せばclassファイルがみつかるはずだ。
classファイルがどこだか分からないなら"Package Explorer"でプロジェクト名を選択、右クリックでBuild Path -> Configure Build Pathを選択する。Java Build Pathを選び、Sourceタブを選択しよう。下にDefalut output folderが表示されているはずだ。特に凝った設定をしていなければここにclassファイルは出力される。
classファイルをダブルクリックして開けば"Class File Editor"と題されたビューが表示される。
ところで、classファイルを選択して右クリックした場合に出てくるメニューでOpen Withを選択した場合は"Class File Viewer"というのが候補として出てくる。しかし、開くと"Class File Editor"となってる。この食い違いは謎だが、気にしないことにしよう。
クラスのメンバを確認する
"Class File Editor"を初めて見ると、第一感で拒絶反応を起こし、読みもせずに閉じてしまうかもしれない。しかし、落ち着いて見れば、特別な知識がなくても読める部分がある。フィールドやメソッドといったクラスのメンバだ。
package sample; public class Hoge { public static String staticField; public String instanceField; public static void staticMethod() {} public void instanceMethod() {} }
このHogeクラスをコンパイルして作られるclassファイルをClass File Editorで見てみよう。まずはコメントすぐ下の宣言だけを読めばいい。
// Compiled from Hoge.java (version 1.6 : 50.0, super bit) public class sample.Hoge { // Field descriptor #6 Ljava/lang/String; public static java.lang.String staticField; // Field descriptor #6 Ljava/lang/String; public java.lang.String instanceField; // Method descriptor #9 ()V // Stack: 1, Locals: 1 public Hoge(); 0 aload_0 [this] 1 invokespecial java.lang.Object() [11] 4 return Line numbers: [pc: 0, line: 3] Local variable table: [pc: 0, pc: 5] local: this index: 0 type: sample.Hoge // Method descriptor #9 ()V // Stack: 0, Locals: 0 public static void staticMethod(); 0 return Line numbers: [pc: 0, line: 6] // Method descriptor #9 ()V // Stack: 0, Locals: 1 public void instanceMethod(); 0 return Line numbers: [pc: 0, line: 7] Local variable table: [pc: 0, pc: 1] local: this index: 0 type: sample.Hoge }
クラスの宣言、フィールド、メソッドが書かれているのがわかるだろう。そのクラスがどんな形をしているのかを見るだけなら"Class File Editor"で眺めるだけでわかる。ソースを追いかけていて、こうしたインターフェース部分さえ分かればいいというのであれば、すでに読める状態にあるんだ。読めると思えば読める。
ところでpublic Hoge();の部分。Javaのソースコードでは書いていないのに存在している。これは暗黙に作られるデフォルトコンストラクタってヤツ。その辺を理解していないならまずJavaの仕様を理解しよう。
メソッドの中身
さて、クラスの概観は特別な知識がなくても読めた。ここからメソッドの中を読もう。ごく単純なサンプルとしてHelloWorldを作って"Class File Editor"で覗いてみよう。
public class HelloWorld { public static void main(String[] args) { System.out.println("HelloWorld!"); } }
mainメソッド部分だけを抜粋する。
public static void main(java.lang.String[] args); 0 getstatic java.lang.System.out : java.io.PrintStream [16] 3 ldc <String "HelloWorld!"> [22] 5 invokevirtual java.io.PrintStream.println(java.lang.String) : void [24] 8 return Line numbers: [pc: 0, line: 5] [pc: 8, line: 6] Local variable table: [pc: 0, pc: 9] local: args index: 0 type: java.lang.String[]
左側の数字はバイトコードのインデックス。0からスタートするのだけど、命令によってバイト数が違うので飛びとびの値になる。この数字は"Line numbers:"で照合するとjavaファイルの行番号がわかる。[pc: 0, line: 5]とあるので0の部分がjavaファイルの5行目だ。javacでコンパイルする際に-g:noneのオプションをつけると、この行番号対照表が生成されなくなるのでスタックトレースなどで行番号が表示されなくなる。
getstatic, ldc, invokevirtual, returnらがJavaのバイトコードの命令で、ニーモニックと呼ばれる。ニーモニックは人間が覚えやすいように命令に付けられた名前で、バイトコードとしての実体はそれぞれ 0xB2, 0x12, 0xB6, 0xB1となる。だけど、直接バイナリエディタでclassファイルを解析しようというケースでもないかぎり、あまりこの値は関係ない。
メソッド呼び出しを捉える
実際の動作を正確に読むには、ちゃんと命令を読まなくてはならないのだけど、いきなりそこまで読める必要はない。重要なのはinvoke系の命令だ。
ニーモニック | 動作 |
---|---|
invokevirtual | インスタンスのメソッド呼び出し |
invokeinterface | インターフェースのメソッド呼び出し |
invokestatic | staticメソッドの呼び出し |
invokespecial | コンストラクタの呼び出し |
ざっくりこんな感じ。
5 invokevirtual java.io.PrintStream.println(java.lang.String) : void [24]
であれば、java.io.PrintStreamクラスの println(java.lang.String) : void を呼び出すよ、ということ。
引数の渡し方
メソッドを呼ぶには引数を渡さなくてはならない。JavaVMの場合、値の受け渡しはスタックを使う。
スタックというのはLIFOとか呼ばれたりするけども、イメージとしては本とかを積み上げるイメージ。順番に積み上げて、上から順番に取る。詳しくはWikipediaでも読んでくれ。情報工学的には基礎なのでここでは解説は省く。
メソッド呼び出しをする場合は、引数のぶんだけスタックにデータを積み上げ、メソッドを呼び出す。メソッドの中ではスタックからデータを取り出す。そんな感じで動く。こうしたデータをスタックでやり取りするマシンはスタックマシンと呼ばれる。僕らがよく使っている8086互換CPU、つまるところPentiumとかCore2とかはレジスタで計算を行うのでレジスタマシンって呼ばれてる。
というわけで、メソッド呼び出しの手前で、スタックに引数を積み上げているので、そこを確認すれば引数に何を渡しているのかはわかる。
3 ldc <String "HelloWorld!"> [22] 5 invokevirtual java.io.PrintStream.println(java.lang.String) : void [24]
ldcという命令の細かい動きはおいておくとして、とにかくどうやら"HelloWorld!"という文字列をスタックに積んでからinvokevirtual命令でprintlnメソッドを呼んでいるということがわかる。
インスタンスメソッド
インスタンスメソッドの場合は、隠れた1番目の引数としてインスタンスが渡される。
public class Foo { public void hoge() { bar(); } public void bar() { } }
というコードに対し、hoge()メソッドのバイトコードは以下のようになる。
// Method descriptor #6 ()V // Stack: 1, Locals: 1 public void hoge(); 0 aload_0 [this] 1 invokevirtual sample.Foo.bar() : void [15] 4 return Line numbers: [pc: 0, line: 5] [pc: 4, line: 6] Local variable table: [pc: 0, pc: 5] local: this index: 0 type: sample.Foo
1:invokevirtual の手前で0:aload_0 [this]という命令を呼んでいる。aload_0命令の詳細はおいておいて、[this]ってのを見ておけばいい。引数がある場合も見てみよう。
public class Foo { public void hoge() { bar(3, "str"); } public void bar(int i, String s) { } }
と、barの引数にint型とString型を加えてみた。ニーモニックは
// Method descriptor #6 ()V // Stack: 3, Locals: 1 public void hoge(); 0 aload_0 [this] 1 iconst_3 2 ldc <String "str"> [15] 4 invokevirtual sample.Foo.bar(int, java.lang.String) : void [17] 7 return Line numbers: [pc: 0, line: 5] [pc: 7, line: 6] Local variable table: [pc: 0, pc: 8] local: this index: 0 type: sample.Foo
となって、4:invokevirtual の手前で、0:aload_0でthisをスタックに積み、1:iconst_3で定数3をスタックに積み、2:ldc で文字列を積んでいるのがわかる。
戻り値
ここまで戻り値がvoidのメソッドばかり作っていた。returnという命令が書かれているのはすでに気付いているだろう。戻り値を返す場合はどうか。
public int bar() { return 3; }
// Method descriptor #15 ()I // Stack: 1, Locals: 1 public int bar(); 0 iconst_3 1 ireturn Line numbers: [pc: 0, line: 5] Local variable table: [pc: 0, pc: 2] local: this index: 0 type: sample.Foo
0:iconst_3でスタックに定数3を積んで、ireturn命令を呼び出している。このireturn命令はboolean, byte, short, char, int型の戻り値のメソッドで用いられる。呼び出し元ではinvokevirtual の手前でスタックにインスタンスと引数を積む。invokevirtual の実行後、スタックからインスタンスと引数が消え、代わりにこの戻り値が積まれた状態になる。
ireturn命令以外の型の場合は代わりにareturn命令が使われる。
変数への格納
戻り値がスタックに積まれるところまで分かった。通常は戻り値を変数に格納する。これはどうなるのか。
public class Foo { public void hoge() { int value = bar(); } public int bar() { return 3; } }
// Method descriptor #6 ()V // Stack: 1, Locals: 2 public void hoge(); 0 aload_0 [this] 1 invokevirtual sample.Foo.bar() : int [15] 4 istore_1 [value] 5 return Line numbers: [pc: 0, line: 5] [pc: 5, line: 6] Local variable table: [pc: 0, pc: 6] local: this index: 0 type: sample.Foo [pc: 5, pc: 6] local: value index: 1 type: int
4:istore_1命令でvalueというローカル変数に代入している。storeとついている命令はスタックから変数への代入と思えばいい。型によってstoreの手前にiとかlとかfとかdとか付くが型によって命令が異なる程度の話。ざっくり動きを読めればいいいいや程度なら細かいことは気にしなくても大丈夫。
じゃあ、istore_1の_1って何よって話なんだが、これはローカル変数のインデックスを表す。VM中では変数を配列で管理していて、この変数はインデックス1、この変数はインデックス2みたいにしているんだ。
単なるistore命令ってのがあってスタックに変数のインデックスを積んでから呼び出すのだけど、手前の数個がよく使われるので楽できるようにistore_0, istore_1, istore_2, istore_3という命令があらかじめ用意されている。istore命令でいちいちスタックにインデックスを積む代わりにistore_1を呼べば一発でインデックス1の変数に値を格納できる。
aload_0の_0とかも同じ。
まとめ
この稿では簡単なバイトコードニーモニックの読み方を解説した。
- クラスの定義は簡単に読める
- メソッドの中身を読むときはまずinvoke系の命令を探す
- invoke系命令の前にインスタンスと引数がスタックに積まれているはず
- invoke系命令の後に戻り値を変数に格納しているはず
これぐらいを抑えておけば、ざっくりした動きぐらいは読めるようになるだろう。
参考資料
オンラインでJava仮想マシン仕様を読むことができる。ただし、これは英語。
Java SE Specifications
書籍でなら日本語訳が発売されている。
Amazon CAPTCHA
Wikipediaにも簡易な解説があるので役立つだろう。
Java仮想マシン - Wikipedia
最速マスターではない理由
僕はジョークとパロディが好きだからエントリにJava変態文法最速マスター - プログラマーの脳みそなんてタイトルをつけたけど、「最速」ってのは誇大広告的で嫌いなフレーズだ。そんなわけで、最速ではないJavaバイトコードの読み方を書いた。もちろん、僕はこのエントリにエンジニアとしての意地を詰め込んでいて、必要最小限の知識・技能を手早く理解できるように工夫してある。出来れば「最速」と評価されたい気持ちもある。ただ、ここが世界の最速の到達点で、これ以上世界が進化しないなんてつまらないじゃないか。
それではみなさん、楽しいデバッグライフを :-)