知らなくても困らない Javaクラスのバイトコードの読み方 (original) (raw)

普段使いでは困ることはないですが、昨今はバイトコードマニピュレーションによる黒魔術が謳歌しているため、知っていると役に立つ場合もあるバイトコードの最低限の読み方を説明します。

クラスファイルの中身

以下のような簡単なソースコードを考えましょう。

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 |..........| 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

なるほど、良くわかりませんね。

区分毎に色分けしてみましょう。

f:id:Naotsugu:20171102231005p:plain

それぞれ大雑把には以下の意味合いとなります。

図中の黄色の箇所がバイトコードの命令となっています。2a b7 00 01 b1 は具体的には以下のニーモックに対応します。

これらの詳細については後ほど見ていきます。

クラスファイルの構造

先に見たバイナリは、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)が色々と定義されます。

クラスファイルの構造体の中身を図示すると以下のようになります。

f:id:Naotsugu:20171102231032p:plain

ここで、水色のボックスが属性(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 #2 = Class #19
#3 = Class #20
#4 = Utf8 #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
#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
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 #2 = Class #19
#3 = Class #20
#4 = Utf8 #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
#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
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)Iint add(int x, int y) というメソッドシグネチャの型情報を表現するものになります。

さらに例を上げると #5 = Utf8 ()Vvoid m() というメソッドシグネチャの型情報を表現するものになります。[[I はint型の2次元配列です。読みにくいですが我慢しましょう。

オペランドスタック

もう一つ事前に知っておくべきものとして、Java仮想マシンの命令はオペランドスタックを介して実行されるということです。

Javaでは、スレッド毎に以下のようなメモリ領域が(Javaヒープとは別に)確保されます。

f:id:Naotsugu:20171102231116p:plain

メソッドの呼び出しが行われると、JVMスタックの中にFrameがPushされます。メソッドの終了時には現在のFrameがPopされて破棄されます。

Frameが積み上がってJVMスタックが一杯になると、おなじみの StackOverflow になります。

図中右側に示した Frame の中にはオペランドスタックがあり、仮想マシンの命令によりPushしたりPopしたりしながら処理が行われていきます。

アセンブラなどでは用途別に用意されたレジスタを介して命令が実行されますが、Javaはシングルスタックマシンなので、全て1つのスタックで処理をまかなっています。

整数の加算を例に図示すると以下のように動作します。

f:id:Naotsugu:20171102231131p:plain

このようにオペランドスタックへのPushとPopを繰り返しながら命令が実行されていきます。

コンストラクタの実行

さて、前置きが長くなりました。

今回の例のコンストラクタ部分を見ていきましょう。

public Class1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1
4: return LineNumberTable: line 1: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this LClass1;

頭の箇所はメソッド定義に関する記載です。

Code の中身が CodeAttribute 構造体の中身となります。

LineNumberTable はコード行との対応で、LocalVariableTable はメソッド内で宣言した変数の名前などが記録されています(主にデバッグ情報として利用されます)。

中ほどの以下は何でしょう。

  stack=1, locals=1, args_size=1

それぞれ以下の意味です。があまり気にしなくて大丈夫です。

なお、コンストラクタはコード上は引数無しですが、クラスファイル上では自身のオブジェクトを第一引数に取ります。これは他のメソッドでも同様です。

さてニーモック部分です。

0: aload_0 1: invokespecial #1
4: 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

こちらは先程の図で説明した通りです。

以上が処理の内容になります。細かく見ればとても簡単ですね。

invoke 系命令

この後、もう少し色々な例を見ていきます。

が、その前に invoke 系命令につて示しておきます。

なぜこんなに色々あるかと言うと、どのインスタンスのメソッドを呼び出すかのメソッドルックアップのやり方が異なるためです。

条件判断

もう少し例を見ていきましょう。

以下のコードを考えます。

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 frame_type = 64 stack = [ int ]

ニーモック部分を抜き出しました。

0: aload_1 1: ifnonnull 8 4: iconst_1 5: goto 9 8: iconst_0 9: ireturn

順に見ていきましょう。

8への分岐がなかった場合は

といった具合です。

もう一つ、今までと異なり StackMapTable 属性が出力されています。

この属性は Java6 で追加された属性で、ある時点でのローカル変数とスタックの値の型の情報が入っています。

「ある時点」とは分岐によるジャンプの発生時で、ここでの例だと、ifnonnullgoto の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 offset_delta = 4 locals = [ int, int ] frame_type = 250 offset_delta = 14

最初に以下のコードの箇所を見ていきます。

int sum = 0;

ニーモック部分の該当箇所は以下です。

0: iconst_0 1: istore_2

次に以下の部分です。

for(int i = 1; i <= num; i++)

i++ の前までの部分のニーモック部分です。

2: iconst_1 3: istore_3 4: iload_3 5: iload_1 6: if_icmpgt 19

合計の箇所に移ります。

sum += i;

ニーモックの該当箇所は以下となります。

9: iload_2 10: iload_3 11: iadd 12: istore_2

次にfor文のインクリメント部分です。

for(int i = 1; i <= num; i++)

i++ のニーモック部分です。

13: iinc 3, 1 16: goto 4

最後に if_icmpgt19 に分岐したら、

6: if_icmpgt 19 ... 19: iload_2 20: ireturn

以上で合計値が呼び出し元に返ります。

まとめ

簡単な例でクラスファイルの中身と Java仮想マシンの命令実行過程を見てみました。

オペコードはここで見たものの他にもたくさんありますが、https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html にあるオペコードの仕様を見れば、同じように読むことができます。