普段使いでは困ることはないですが、昨今はバイトコードマニピュレーションによる黒魔術が謳歌しているため、知っていると役に立つ場合もあるバイトコードの最低限の読み方を説明します。
クラスファイルの中身
以下のような簡単なソースコードを考えましょう。
public class Class1 { public int add(int x, int y) { return x + y; } }
このソースコードをコンパイルして作成された Class1.class の中身のダンプを見てみます。
$ hexdump -C Class1.class 00000000 ca fe ba be 00 00 00 34 00 15 0a 00 03 00 12 07 |.......4........| 00000010 00 13 07 00 14 01 00 06 3c 69 6e 69 74 3e 01 00 |........<init>..| 00000020 03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 |.()V...Code...Li| 00000030 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 12 |neNumberTable...| 00000040 4c 6f 63 61 6c 56 61 72 69 61 62 6c 65 54 61 62 |LocalVariableTab| 00000050 6c 65 01 00 04 74 68 69 73 01 00 08 4c 43 6c 61 |le...this...LCla| 00000060 73 73 31 3b 01 00 03 61 64 64 01 00 05 28 49 49 |ss1;...add...(II| 00000070 29 49 01 00 01 78 01 00 01 49 01 00 01 79 01 00 |)I...x...I...y..| 00000080 0a 53 6f 75 72 63 65 46 69 6c 65 01 00 0b 43 6c |.SourceFile...Cl| 00000090 61 73 73 31 2e 6a 61 76 61 0c 00 04 00 05 01 00 |ass1.java.......| 000000a0 06 43 6c 61 73 73 31 01 00 10 6a 61 76 61 2f 6c |.Class1...java/l| 000000b0 61 6e 67 2f 4f 62 6a 65 63 74 00 21 00 02 00 03 |ang/Object.!....| 000000c0 00 00 00 00 00 02 00 01 00 04 00 05 00 01 00 06 |................| 000000d0 00 00 00 2f 00 01 00 01 00 00 00 05 2a b7 00 01 |.../........*...| 000000e0 b1 00 00 00 02 00 07 00 00 00 06 00 01 00 00 00 |................| 000000f0 01 00 08 00 00 00 0c 00 01 00 00 00 05 00 09 00 |................| 00000100 0a 00 00 00 01 00 0b 00 0c 00 01 00 06 00 00 00 |................| 00000110 42 00 02 00 03 00 00 00 04 1b 1c 60 ac 00 00 00 |B..........`....| 00000120 02 00 07 00 00 00 06 00 01 00 00 00 03 00 08 00 |................| 00000130 00 00 20 00 03 00 00 00 04 00 09 00 0a 00 00 00 |.. .............| 00000140 00 00 04 00 0d 00 0e 00 01 00 00 00 04 00 0f 00 |................| 00000150 0e 00 02 00 01 00 10 00 00 00 02 00 11 |.............| 0000015d
なるほど、良くわかりませんね。
区分毎に色分けしてみましょう。
それぞれ大雑把には以下の意味合いとなります。
- マジックナンバー(紺色)
ca fe ba be
固定でクラスファイルの識別として利用される
- マイナーバージョン・メジャーバージョン(青色)
34
は10進だと52で、これは Java8 となる
- コンスタントプールカウント(水色)
- 以降に続くコンスタントプールのエントリ数 + 1 の値となる
15
は10進で21なので、コンスタントプールは20エントリ存在するという意味
- コンスタントプール(赤)
- クラスファイル内から参照される様々な文字列定数が定義される
- アクセスフラグ, クラス・スーパークラスの識別(コンスタントプールの参照値),インターフェースカウントインターフェーステーブル(桃色)
- クラスの各種属性が定義される
- インターフェーステーブルは今回の例では無し
- フィールドカウント(橙色)
- 以降のフィールドテーブルのエントリ数
- 今回の例ではフィールドを定義していないので無し
- メソッドカウント(緑色)
- 以降のメソッドテーブルのエントリ数
- 今回の例ではデフォルトコンストラクタと
add
メソッドの2つ
- メソッドテーブル(黄緑色)
- 黄色の箇所がバイトコードの命令群となる(Code attribute の code配列の中身)
- クラスアトリビュート(藤色)
- クラスの属性情報(可変エントリ)
図中の黄色の箇所がバイトコードの命令となっています。2a b7 00 01 b1
は具体的には以下のニーモックに対応します。
- 42(0x2a)
aload_0
- 183(0xb7)
invokespecial
- 続くコンスタントプールのインデックス
01
のメソッドを実行
- 続くコンスタントプールのインデックス
- 177(0xb1)
return
これらの詳細については後ほど見ていきます。
クラスファイルの構造
先に見たバイナリは、JDK の中では ClassFile構造体として表現され、以下のように定義されています。
ClassFile { u4 magic; u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; u2 access_flags; u2 this_class; u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count]; }
cp_info
がコンスタントプールです。
field_info
の中にクラスのフィールドの情報が入っており、method_info
の中にメソッドに関する情報が入っています(前述の黄緑色の部分ですね)。
attribute_info
にはこのクラスの属性値(内部クラスの情報など)が色々定義されます。
field_info
の中やmethod_info
の中にもさらに入れ子になった属性(attributee)が色々と定義されます。
クラスファイルの構造体の中身を図示すると以下のようになります。
ここで、水色のボックスが属性(Attribute)を表しています。属性は任意の属性を追加定義できるようになっており、例えば Java5 で追加されたアノテーション用には RuntimeVisibleAnnotations
といった属性が追加されたりといった具合になります。
属性は必要なもののみが定義され、全てが全てクラスファイル中に存在するわけではありません。
ちなみにCode属性の中のcode配列(黄色のボックス)の中に先程見た2a b7 00 01 b1
といった命令が入っています。
これらの属性は属性毎に構造体として定義されており、バイナリ長もそれぞれになるので、クラスファイルのバイナリダンプを読むのはとても骨が折れる作業になります。
javap コマンド
バイナリダンプを読むのにうんざりしたら javap コマンドを使います。
Javap コマンドはクラスファイルを逆アセンブルして読みやすい形で表示することができます。
よく使うオプションは以下になります。
オプション | 説明 |
---|---|
-help -? | 使用方法のメッセージを出力する |
-c | クラスのメソッド毎に逆アセンブルしたニーモックを表示する |
-p -private | すべてのクラスとメンバーを表示する(指定しないとpublicのみ表示) |
-v -verbose | -c に加え、コンスタントプールや メソッドのスタックサイズ、locals と args の数などの詳細表示する |
まぁ、たいてい -v
しておけば事足ります。
$ javap -v Class1.class
さきほどのクラスファイルを Javap すると以下のような出力が得られます。
Classfile java/main/Class1.class Last modified 20XX/XX/XX; size 349 bytes MD5 checksum 0230ca9e6ca3777ff46cab466836bb65 Compiled from "Class1.java" public class Class1 minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #3.#18 // java/lang/Object."<init>":()V #2 = Class #19 // Class1 #3 = Class #20 // java/lang/Object #4 = Utf8 <init> #5 = Utf8 ()V #6 = Utf8 Code #7 = Utf8 LineNumberTable #8 = Utf8 LocalVariableTable #9 = Utf8 this #10 = Utf8 LClass1; #11 = Utf8 add #12 = Utf8 (II)I #13 = Utf8 x #14 = Utf8 I #15 = Utf8 y #16 = Utf8 SourceFile #17 = Utf8 Class1.java #18 = NameAndType #4:#5 // "<init>":()V #19 = Utf8 Class1 #20 = Utf8 java/lang/Object { public Class1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 1: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this LClass1; public int add(int, int); descriptor: (II)I flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=3 0: iload_1 1: iload_2 2: iadd 3: ireturn LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 4 0 this LClass1; 0 4 1 x I 0 4 2 y I } SourceFile: "Class1.java"
javap 出力の概要
Javap の出力内容を順番に見ていきましょう。
最初は対象クラスファイルのシステム情報です。
lassfile java/main/Class1.class Last modified 20XX/XX/XX; size 349 bytes MD5 checksum 0230ca9e6ca3777ff46cab466836bb65 Compiled from "Class1.java"
これはクラスファイルの中身というよりは、クラスファイル自体のシステム情報です。
続いてクラスファイルのバージョンやアクセスフラグなどの情報が続きます。
public class Class1 minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER
メジャーバージョンの定義は以下のようになります。
ACC_PUBLIC
(0x0001)はこのクラスがpublic宣言されていることを意味し、ACC_SUPER
(0x0020)は古いコンパイラにより生成されたクラスファイルには設定されないが、現在は全てこのフラグが設定される(下位互換用でinvokespecial 命令により起動された場合の振る舞いに影響)。
フラグはこの他に、ACC_FINAL
(0x0010)、 ACC_INTERFACE
(0x0200)、ACC_ABSTRACT
(0x0400) があります。
続いてコンスタントプールの情報です。
Constant pool: #1 = Methodref #3.#18 // java/lang/Object."<init>":()V #2 = Class #19 // Class1 #3 = Class #20 // java/lang/Object #4 = Utf8 <init> #5 = Utf8 ()V #6 = Utf8 Code #7 = Utf8 LineNumberTable #8 = Utf8 LocalVariableTable #9 = Utf8 this #10 = Utf8 LClass1; #11 = Utf8 add #12 = Utf8 (II)I #13 = Utf8 x #14 = Utf8 I #15 = Utf8 y #16 = Utf8 SourceFile #17 = Utf8 Class1.java #18 = NameAndType #4:#5 // "<init>":()V #19 = Utf8 Class1 #20 = Utf8 java/lang/Object
クラスファイル中では、ここで定義された文字列定数を #1
#2
といったインデックスで参照して様々な箇所から参照しています。
例えば return "Hello";
といったコードがあれば、Hello
という値がコンスタントプール内に定義されますし、外部クラスのメソッド呼び出しなどのクラス名やメソッド名などもここに定数として定義されます。
続いて method_info
の中身になります(今回はフィールドが無いのでfield_info
はありません)。
最初はコンパイラが追加したデフォルトコンストラクタの内容になります。
public Class1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 1: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this LClass1;
詳細は後ほど説明するので次にいきます。
続いてソースコードに定義した add
メソッドの内容になります。
public int add(int, int); descriptor: (II)I flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=3 0: iload_1 1: iload_2 2: iadd 3: ireturn LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 4 0 this LClass1; 0 4 1 x I 0 4 2 y I
こちらも後ほど。
最後がクラス自身の属性である attribute_info
になります。
SourceFile: "Class1.java"
今回は SourceFile
属性の内容だけですね。
先程見たクラスファイルのバイナリ出力の結果の最後の部分をもう一度見てみると、00 10
00 00 00 02
00 11
となっています。最初の 0x10 は10進で16なのでコンスタントプールの #16 = Utf8 SourceFile
を参照しています。
最後の 0x11 は10進で17なので、コンスタントプールの #17 = Utf8 Class1.java
を参照しており、結果、SourceFile 属性の値が Class1.java として出力されているという具合です。
ではバイトコード命令を読んでいきますが、その前に前提知識として 2つ程知っておかなければならないことがあります。
型とメソッドの読み方
最初に型の表記方法を覚えておく必要があります。
コンスタントプールでは型の表記を略式の記号で表現します。
以下のルールとなっています。
Type descriptor | 型 / 説明 |
---|---|
Z | boolean |
C | char |
B | byte |
S | short |
I | int |
F | float |
J | long |
D | double |
L |
reference / Object の場合 Ljava/lang/Object; となる |
[ | reference / 一次元配列で int配列の場合 [I となる |
[[ | reference / 二次元配列で Object配列の場合 [[java/lang/Object; となる |
上記型記号を使い、メソッドは以下のように表現されます。
メソッドシグネチャ | Method descriptor |
---|---|
void m(int i, float f) |
(IF)V |
int m(Object o) |
(Ljava/lang/Object;)I |
int[] m(int i, String s) |
(ILjava/lang/String;)[I |
Object m(int[] i) |
([I)Ljava/lang/Object; |
例えば、今回出力した javap のコンスタントプールにある#12 = Utf8 (II)I
は int add(int x, int y)
というメソッドシグネチャの型情報を表現するものになります。
さらに例を上げると #5 = Utf8 ()V
は void m()
というメソッドシグネチャの型情報を表現するものになります。[[I
はint型の2次元配列です。読みにくいですが我慢しましょう。
オペランドスタック
もう一つ事前に知っておくべきものとして、Java仮想マシンの命令はオペランドスタックを介して実行されるということです。
Javaでは、スレッド毎に以下のようなメモリ領域が(Javaヒープとは別に)確保されます。
メソッドの呼び出しが行われると、JVMスタックの中にFrameがPushされます。メソッドの終了時には現在のFrameがPopされて破棄されます。
Frameが積み上がってJVMスタックが一杯になると、おなじみの StackOverflow になります。
図中右側に示した Frame の中にはオペランドスタックがあり、仮想マシンの命令によりPushしたりPopしたりしながら処理が行われていきます。
アセンブラなどでは用途別に用意されたレジスタを介して命令が実行されますが、Javaはシングルスタックマシンなので、全て1つのスタックで処理をまかなっています。
整数の加算を例に図示すると以下のように動作します。
iload_1
でローカル変数配列 int 値をロードしてオペランドスタックにPushiload_2
でローカル変数配列 int 値をロードしてオペランドスタックにPushiadd
でオペランドスタックから値を2つPopして加算結果をオペランドスタックへPushireturn
カレントフレームのオペランドスタックから値をPopし、起動側フレームのオペランドスタックへ Push
このようにオペランドスタックへのPushとPopを繰り返しながら命令が実行されていきます。
コンストラクタの実行
さて、前置きが長くなりました。
今回の例のコンストラクタ部分を見ていきましょう。
public Class1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 1: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this LClass1;
頭の箇所はメソッド定義に関する記載です。
- descriptor はメソッドシグネチャで
()V
つまり、void m()
となる - flags は
ACC_PUBLIC
で、このメソッドは public 定義
Code の中身が CodeAttribute 構造体の中身となります。
LineNumberTable
はコード行との対応で、LocalVariableTable はメソッド内で宣言した変数の名前などが記録されています(主にデバッグ情報として利用されます)。
中ほどの以下は何でしょう。
stack=1, locals=1, args_size=1
それぞれ以下の意味です。があまり気にしなくて大丈夫です。
stack
このメソッドの実行で必要なスタックの深さlocals
ローカル変数テーブルに予約する必要があるローカル変数スロットの数arg_size
この処理で参照するパラメータの数
なお、コンストラクタはコード上は引数無しですが、クラスファイル上では自身のオブジェクトを第一引数に取ります。これは他のメソッドでも同様です。
さてニーモック部分です。
0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return
aload_0
ローカル変数配列[0] (自身のオブジェクト)をオペランドスタックにプッシュaload_0
の先頭文字a
は参照を表すiload
であれば int 、dload
であれば double といった具合
invokespecial
により親クラスのコンストラクタを呼び出す#1
はコンスタントプールの当該スロットの参照(java/lang/Object."<init>":()V
)
return
で戻り値なしでメソッドを抜ける
先頭の数字は当該命令が先頭から何バイト目に当たるかを示しています。
加算メソッド
次に加算メソッドの命令を見てみましょう。
public int add(int, int); descriptor: (II)I flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=3 0: iload_1 1: iload_2 2: iadd 3: ireturn LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 4 0 this LClass1; 0 4 1 x I 0 4 2 y I
形はコンストラクタの場合と同じですね。
ニーモック部分を見ます。
0: iload_1 1: iload_2 2: iadd 3: ireturn
こちらは先程の図で説明した通りです。
iload
ローカル変数配列から int 値をロード_1
はローカル変数配列のインデックスを表す- インデックスで示されたローカル変数の値をオペランドスタックへプッシュ
LocalVariableTable
の項目にあるように、インデックス0 の引数は自身のクラスオブジェクト- インデックス1 が引数の x
iload
ローカル変数配列から int 値をロード- インデックスの2で示されたローカル変数の値をオペランドスタックへプッシュ
iadd
オペランドスタックから値を2つPopして加算結果をオペランドスタックへプッシュireturn
メソッドから int をリターン
以上が処理の内容になります。細かく見ればとても簡単ですね。
invoke 系命令
この後、もう少し色々な例を見ていきます。
が、その前に invoke 系命令につて示しておきます。
invokevirtual
- インスタンスメソッドを呼び出すinvokespecial
- インスタンス初期化メソッド、プライベートメソッド、スーパークラスのインスタンスメソッドを呼び出すinvokestatic
- クラスメソッドを呼び出すinvokeinterface
- インターフェイスメソッドを呼び出すinvokedynamic
- 呼び出し先の対象をプログラマチックに動的に変更できる
なぜこんなに色々あるかと言うと、どのインスタンスのメソッドを呼び出すかのメソッドルックアップのやり方が異なるためです。
条件判断
もう少し例を見ていきましょう。
以下のコードを考えます。
public boolean isNull(Object object) { return object == null; }
javap のメソッド部分は以下のようになります。
public boolean isNull(java.lang.Object); descriptor: (Ljava/lang/Object;)Z flags: ACC_PUBLIC Code: stack=1, locals=2, args_size=2 0: aload_1 1: ifnonnull 8 4: iconst_1 5: goto 9 8: iconst_0 9: ireturn LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 10 0 this LClass1; 0 10 1 object Ljava/lang/Object; StackMapTable: number_of_entries = 2 frame_type = 8 /* same */ frame_type = 64 /* same_locals_1_stack_item */ stack = [ int ]
ニーモック部分を抜き出しました。
0: aload_1 1: ifnonnull 8 4: iconst_1 5: goto 9 8: iconst_0 9: ireturn
順に見ていきましょう。
aload_1
ローカル変数配列[1]をオペランドスタックにプッシュ- 対象は
isNull(Object object)
の引数のオブジェクトの参照(実オブジェクトはヒープ中に存在)
- 対象は
ifnonnull
オペランドスタックからポップした値が null でなければ 8へ分岐- そうでなければ次の命令に進む
iconst_0
int 定数0
をオペランドへプッシュireturn
int 値をオペランドスタックからポップし、呼び出し元フレームのスタックにプッシュ
8への分岐がなかった場合は
といった具合です。
もう一つ、今までと異なり StackMapTable
属性が出力されています。
この属性は Java6 で追加された属性で、ある時点でのローカル変数とスタックの値の型の情報が入っています。
「ある時点」とは分岐によるジャンプの発生時で、ここでの例だと、ifnonnull
と goto
の2つですね。
goto
の時点で、stack = [ int ]
となっており、スタックには int 型変数が入っているはずだということになります。クラスファイルの検証用途で利用されます。
for ループ
最後にもう一つ、ループ処理を見てみましょう。
以下のコードを考えます。
public int sum(int num) { int sum = 0; for(int i = 1; i <= num; i++) { sum += i; } return sum; }
javap のメソッド部分は以下のようになります。
public int sum(int); descriptor: (I)I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=2 0: iconst_0 1: istore_2 2: iconst_1 3: istore_3 4: iload_3 5: iload_1 6: if_icmpgt 19 9: iload_2 10: iload_3 11: iadd 12: istore_2 13: iinc 3, 1 16: goto 4 19: iload_2 20: ireturn LineNumberTable: line 3: 0 line 4: 2 line 5: 9 line 4: 13 line 7: 19 LocalVariableTable: Start Length Slot Name Signature 4 15 3 i I 0 21 0 this LClass1; 0 21 1 num I 2 19 2 sum I StackMapTable: number_of_entries = 2 frame_type = 253 /* append */ offset_delta = 4 locals = [ int, int ] frame_type = 250 /* chop */ offset_delta = 14
最初に以下のコードの箇所を見ていきます。
int sum = 0;
ニーモック部分の該当箇所は以下です。
0: iconst_0 1: istore_2
iconst_0
int 定数0
をオペランドスタックへプッシュistore_2
オペランドスタックからポップしてローカル変数配列[2]にストア- ローカル変数配列[0] は this、ローカル変数配列[1] はメソッド引数が入っている
次に以下の部分です。
for(int i = 1; i <= num; i++)
i++
の前までの部分のニーモック部分です。
2: iconst_1 3: istore_3 4: iload_3 5: iload_1 6: if_icmpgt 19
iconst_1
int 定数1
をオペランドスタックへプッシュistore_3
オペランドスタックからポップしてローカル変数配列[3]にストアiload_3
ローカル変数配列[3]をオペランドスタックにプッシュint i = 1
の1
がオペランドスタックに入る
iload_1
ローカル変数配列[1]をオペランドスタックにプッシュ- これはメソッドの引数
num
の値 - この時点でオペランドスタックは 「i」「num」の値が積まれている
- これはメソッドの引数
if_icmpgt
オペランドスタックから値を2つポップし、value1 > value2 の場合19
へ分岐
合計の箇所に移ります。
sum += i;
ニーモックの該当箇所は以下となります。
9: iload_2 10: iload_3 11: iadd 12: istore_2
iload_2
ローカル変数配列[2]をオペランドスタックにプッシュ- これはローカル変数
sum
の値
- これはローカル変数
iload_3
ローカル変数配列[3]をオペランドスタックにプッシュ- これはローカル変数
i
の値
- これはローカル変数
iadd
オペランドスタックから値を2つPopして加算結果をオペランドスタックへプッシュsum + i
の結果がオペランドスタックに置かれる
istore_2
オペランドスタックからポップしてローカル変数配列[2]にストア- 現在までの合計値がローカル変数配列[2] に入る
次にfor文のインクリメント部分です。
for(int i = 1; i <= num; i++)
i++
のニーモック部分です。
13: iinc 3, 1 16: goto 4
最後に if_icmpgt
で 19
に分岐したら、
6: if_icmpgt 19 ... 19: iload_2 20: ireturn
iload_2
ローカル変数配列[2]をオペランドスタックにプッシュ- これはローカル変数
sum
の値
- これはローカル変数
ireturn
int 値をオペランドスタックからポップし、呼び出し元フレームのスタックにプッシュ
以上で合計値が呼び出し元に返ります。
まとめ
簡単な例でクラスファイルの中身と Java仮想マシンの命令実行過程を見てみました。
オペコードはここで見たものの他にもたくさんありますが、https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html にあるオペコードの仕様を見れば、同じように読むことができます。