前回、スタックマシーンとしてのJVMの基本的な動作を紹介しました。
プリミティブ型の加算という簡単な処理ですが、これが分かればより複雑なバイトコードでも理解できるはずです。
そこで、今回はもう少し複雑な例としてオブジェクトの生成やメソッドコールについて紹介します。
命令セット
今までバイトコードのオペコード(命令、もしくはインストラクション)について簡単な説明しかしていなかったので、ここで紹介しましょう。
とはいってもすべてを紹介するのは大変ですし、おぼえても意味はないので、主に使われるオペコードについて見ていきます。
また、if文やfor文、switch式などの制御構造に関するバイトコードは次回紹介します。
オペコードの詳細な定義はJVMSのChapter 6にあります。
リンクはJava 24のJVMSです。JVMSも毎バージョンごとにアップデートされているので、なるべく最新を参照するようにしてください。
オペコードの構成
現在定義されているオペコードは200種類ほどあります。しかし、それらがすべて違う動作を表しているわけではありません。動作は同じだけど型が違う、動作は同じだけどローカル変数配列のインデックスが違うなどがあります。
型について接頭辞、インデックスなどは接尾辞で表します。接尾辞はインデックス以外にも繰り返し回数などがあり、オペコードによって意味が異なります。一方の接頭辞は常に型を表します。接頭辞の一覧を次に示します。
接頭辞 | 型 |
---|---|
i | int |
l | long |
s | short |
b | byte |
c | char |
f | float |
d | double |
z | boolean |
a | 参照 |
オペランドスタックやローカル変数配列にはプリミティブ型の値か参照しか入れることができません。このため、オブジェクトを扱う場合はヒープに存在するオブジェクトへの参照として扱われます。
もしかしたら、今後導入が予定されているValue Classであれば、直接オペランドスタックに積めるようになるかもしれません。
とはいうものの、バイトコードが増えるわけではなく、あくまでも最適化された場合に限るはずです。
たとえば、前回使用したオペランドのiload_1は、接頭辞がi、接尾辞が_1です。つまり、int型のloadで、ローカル変数配列のインデックス1からのロードということを表しています。
主なオペコード
オペコードが本体と接頭辞、接尾辞から構成されることが分かったところで、主なオペコードについて見ていきましょう。
スタック操作
オペランドスタックに対して何らかの操作を行うオペコードです。オペランドスタックと書くと長くなるので、以下では単にスタックと表記します。
- ldc: コンスタントプールの定数をスタックに積む
- const: インデックスで指定された値をスタックに積む (型、接尾辞あり)
- bipush/sipush: byte値、short値をスタックに積む
- pop: スタックから値を取り除く
- dup: スタック末尾の要素を複製
- swap: スタック末尾の2データを入れ替え
ldcはロードコンスタントのことですね。コンスタントプールは定数で書き換えることはないため、ストアはないです。
constは接尾辞で値を指定します。int型の1であれば iconst_1 となります。接尾辞は-1(m1)から5まであり、これを超える範囲の場合、次に紹介するbipush/sipushを使用します。
bipush/sipushもconstに似ていますが、byte値もしくはshort値を直接スタックに積みます。他の型はありません。
整数型の変数で、byteもしくはshortの範囲に収まるものはbipush/sipushが使われます。shortを超える範囲の場合、コンスタントプールにある定数を使用してldcが使われます。
ローカル変数
オペランドスタックとローカル変数配列とのやり取りに関するオペコードです。
- load: ローカル変数配列の値をスタックに積む (型、接尾辞あり)
- store: スタックから値を取り出し、ローカル変数配列に置く (型、接尾辞あり)
接尾辞のインデックスは0, 1, 2しかないため、それ以上の配列インデックスは iload 5 のようにオペコードの後に指定します。
算術演算
四則演算などの算術演算を行うオペコードです。
- add: 加算 (型あり)
- sub: 減算 (型あり)
- mul: 乗算 (型あり)
- div: 除算 (型あり)
- rem: 除算の余り (型あり)
- neg: 符号反転 (型あり)
- iinc: インクリメント
インクリメントを行うiincはint型だけです。long型の場合はladdが使われます。
論理演算
AND/OR/XORとシフト演算を行うオペコードです。
- iand/land: 論理積
- ior/lor: 論理和
- ixor/lxor: 排他論理和
- ishl/lshl: 左シフト
- ishr/lshr: 右シフト (符号は維持)
- iushr/lushr: 右シフト
論理演算はint型とlong型に対してのみ存在します。byte/short/charに対してint型のオペコードが使われます。
オブジェクト関連
オブジェクトの生成などで使用するオペコードです。
- new: インスタンス生成
- getfield: インスタンス変数の値をスタックに積む
- putfield: スタックから値を取り出し、インスタンス変数に代入
- getstatic: クラス変数の値をスタックに積む
- putstatic: スタックから値を取り出し、クラス変数に代入
newはインスタンスを生成するだけで、コンストラクターのコールは含まれていません。
コンストラクターのコールは、メソッドコールのバイトコードで行います。具体的な例は後述します。
getfield/putfieldは接頭辞による型の指定はありませんが、オペコードの後に指定します。getstatic/putstaticでも同じです。
配列関連
配列の生成や配列からの値の取り出し、代入などで使用するオペコードです。
- newarray: プリミティブ型を要素にとる配列の生成
- anewarray: 参照型を要素にとる配列の生成
- multianewarray: 多次元配列の生成
- aload: 配列のインデックスの要素をスタックに積む (型あり)
- astore: スタックから値を取り出し、配列のインデックスの要素に代入 (型あり)
- arraylength: 配列の長さをスタックに積む
Javaでは配列はオブジェクトの一種、つまり参照型として表されます。しかし、配列を扱うためのバイトコードも提供されています。
loadやstoreに接頭辞のaがついているのは、配列が参照型だからです。さらに要素の型をその前に接頭辞として付加します。たとえば、int型の配列から値を取り出すときはialoadになります。
メソッドコール
メソッドコールを行うバイトコードはメソッドの種類により5種類提供されています。
- invokevirtual: インスタンスメソッド
- invokeinterface: インタフェースメソッド
- invokestatic: クラスメソッド
- invokespecial: コンストラクター、プライベートメソッド、スーパークラスのインスタンスメソッド
- invokedynamic: コールするメソッドを実行時に決定させるメソッドコール
- return: メソッドから返る。(型あり)
メソッドの種類によってオペコードを変えますが、メソッド種類ごとにスタックに積んでおく値も異なります。たとえば、インスタンスメソッドであればコールするメソッドのオブジェクトが必要ですが、クラスメソッドであれば必要ありません。
特殊なのがinvokedynamicです。invokedynamicでは初回コール時にbootstrapという指定されたメソッドをコールし、ターゲットとなるメソッドを同定させてから、メソッドコールを行います。
2回目以降は同定したメソッドコールを直接行います。
invokedynamicに関しては、YujiSoftwareさんとJJUG CCCでセッションを行ったので、その資料をご参照ください。
メソッドから返るのがreturnです。int型の値を返すのであればがireturnになりますが、返り値がない場合はreturnのまま使用します。
残りは次回に回しましょう。
前回は算術演算を行うメソッドだけだったので、今回はその残りの部分の動作を見ていきます。
オブジェクト生成、メソッドコール
前章でオペコードにどのようなものがあるか紹介したので、実際にこれらのオペコードを使っていく様子を見てみましょう。
題材は前回と同じ足し算を行うだけのAdderクラスです。
public class Adder { public int add(int x, int y) { int z = x + y; return z; } public void static main(String... args) { Adder adder = new Adder(); int result = adder.add(2, 3); System.out.println(result); } }
前回はaddメソッドの実行を見ましたが、今回はmainメソッドです。
Adderクラスをコンパイルオプションの-gを付加してコンパイルし、javap -vでmainメソッドを逆コンパイルしたのが以下です。
public static void main(java.lang.String...); descriptor: ([Ljava/lang/String;)V flags: (0x0089) ACC_PUBLIC, ACC_STATIC, ACC_VARARGS Code: stack=3, locals=3, args_size=1 0: new #7 // class Adder 3: dup 4: invokespecial #9 // Method "<init>":()V 7: astore_1 8: aload_1 9: iconst_2 10: iconst_3 11: invokevirtual #10 // Method add:(II)I 14: istore_2 15: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream; 18: iload_2 19: invokevirtual #20 // Method java/io/PrintStream.println:(I)V 22: return LineNumberTable: line 8: 0 line 9: 8 line 10: 15 line 11: 22 LocalVariableTable: Start Length Slot Name Signature 0 23 0 args [Ljava/lang/String; 8 15 1 adder LAdder; 15 8 2 result I }
mainメソッドがコールされた時のオペランドスタックとローカル変数配列の初期状態は次のようになります。
addメソッドはインスタンスメソッドだったので、ローカル変数配列のインデックス0にはthisが格納されていました。しかし、mainメソッドはクラスメソッド(staticメソッド)なので、thisはありません。そのため、インデックス0にはmainメソッドの引数が格納されます。
argsは文字列の配列ですが、それがそのままローカル変数配列に格納されるわけではありません。文字列配列argsの実態はヒープに作成され、ローカル変数配列にはその参照が格納されます。
コンスタントプール
mainメソッドでは、最初にAdderオブジェクトを生成し、adder変数に代入しています。
まずはAdderオブジェクトの生成です。
0行のnew #7がオブジェクトを生成している部分です。この#7はコンスタントプールへの参照になります。
addメソッドではコンスタントプールを使用することがなかったので、ここで触れておきましょう。
javap -vの出力にはコンスタントプールが含まれています。その一部を以下に示します。
Constant pool: #1 = Methodref #2.#3 // java/lang/Object."<init>":()V #2 = Class #4 // java/lang/Object #3 = NameAndType #5:#6 // "<init>":()V #4 = Utf8 java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Class #8 // Adder #8 = Utf8 Adder #9 = Methodref #7.#3 // Adder."<init>":()V #10 = Methodref #7.#11 // Adder.add:(II)I #11 = NameAndType #12:#13 // add:(II)I #12 = Utf8 add #13 = Utf8 (II)I
コンスタントプールは番号付きでこのように並べられています。
さて、先ほどのnew #7の#7を見てみるとClass #8とあります。Classは文字通りクラスを示しています。そのクラス名が#8になります。
#8は、Utf8 Adderです。Utf8は文字列定数を示しています。実際にクラスファイルでは、文字列をUTF-8で表記しています。そして、その後の"Adder"がその文字列定数の実態です。
つまり、#7でクラスを示し、そのクラス名は#8で"Adder"であるということを示しています。
オブジェクト生成
では、mainメソッドに戻りましょう。
new #7では、Adderクラスのオブジェクト生成を行い、その結果、ヒープに生成したオブジェクトの参照をオペランドスタックに積みます。
ここまではオブジェクトの生成をしただけの状態で、コンストラクターはコールしていません。
mainメソッドの3行、4行がコンストラクターをコールする部分です。
まず、3行でdupを行います。dupはduplicateのコートで、スタックの値を複製します。
ここではAdderオブジェクトへの参照を複製します。
そして、その後のinvokesupecialでコンストラクターをコールします。
invokespecialはコンストラクター、プライベートメソッド、スーパークラスのインスタンスメソッドをコールするためのオペコードです。
コールするメソッドが、コンスタントプールの#9です。#9の部分を見てみると...
#3 = NameAndType #5:#6 // "<init>":()V #4 = Utf8 java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Class #8 // Adder #8 = Utf8 Adder #9 = Methodref #7.#3 // Adder."<init>":()V
#9はMethodrefで、メソッドへの参照を示しています。その値の#7は先ほど見たようにAdderクラスを指しています。
#3を見てみると、NameAndTypeとなっています。これはメソッド名とメソッドのシグネチャー(引数と戻り値の型)を示しています。
NameAndType #5:#6の#5がメソッド名、#6がシグネチャーです。
#5を見てみると、文字列定数で<init>です。
コンストラクター名は、Javaのコードではクラス名と同じですが、バイトコードではどのクラスでも<init>になります。
シグネチャーを表すのが、#6の文字列定数の()Vです。
()の中に引数の型が示されますが、Adderクラスのコンストラクターはデフォルトコンストラクターで引数なしなので、単に()となっています。
()の後に示されるのが、戻り値の型です。Vはvoid、つまり戻り値なしです。
コールするメソッドが分かったので、実際にコールするわけですが、その時にオペランドスタックの値が使われます。
ここでは、Adderオブジェクトへの参照が2つスタックに格納されていますが、1つがコールするメソッドのオブジェクト、そしてもう1つがそのメソッドへの暗黙の引数と考えることができます。
オブジェクト生成はnew, dup, invokespecialが常にセットになっているので、そういうものだと考えていただいて大丈夫です。
invokespecialを実行すると、mainメソッドはそのままにして、Adderクラスのコンストラクターを実行するフレームがJVMスタックに積まれます。
そして、コンストラクターの実行が完了すると、コンストラクターのフレームが取り除かれて、mainメソッドが再開します。
オペランドスタックには、コンストラクターをコールした後のAdderオブジェクトが積まれます。
さて、その後の7行のastore_1で、Adderオブジェクトの参照をローカル変数配列のインデックス1に格納します。
これで、Adder adder = new Adder();の処理が完了しました。
インスタンスメソッドコール
Adderオブジェクトが生成出来たので、次はaddメソッドをコールする部分です。
8: aload_1 9: iconst_2 10: iconst_3 11: invokevirtual #10 // Method add:(II)I 14: istore_2
インスタンスメソッドをコールするには、コールするメソッドのオブジェクトとメソッドの引数をオペランドスタックに積んでおきます。
まず、aload_1でローカル変数のインデックス1にあるAdderオブジェクトを積みます。
9行と10行のiconstは、定数をスタックに積むオペコードです。接尾語の_2と_3がスタックに積む値を示しています。
constの接尾語は-1から5までなので、これを超える数にするとどうなるでしょう。
byte型の範囲であればbipush、short型の範囲であればsipush、それを超える場合はldcが使われます。
たとえば、adder(128, 1_000_000);とすると、バイトコードは次のように変化します。
8: aload_1 9: sipush 128 12: ldc #10 // int 1000000 14: invokevirtual #11 // Method add:(II)I 17: istore_2
128にはsipush、1,000,000にはldcでコンスタントプールの値が使われています。
メソッドのターゲットとなるオブジェクトと引数をオペランドスタックに積んだ後に、インスタンスメソッドをコールするinvokevirtualを実行します。
どのメソッドをコールするのかは、コンスタントプールの#10に記述されています。
Adderクラスのデフォルトコンストラクターと同じようにコンスタントプールをたどっていくと、Adder.add(II)Iになることが分かります。
(II)が引数が2つで両方ともint、最後のIで戻り値の型もintだということを示しています。
invokevirtualを実行すると、mainメソッドのフレームは一時停止し、新たにaddメソッドのフレームがJVMスタックに積まれ、addメソッドの実行が始まります。
addメソッドの実行は前回紹介したので、ここでは省略します。
addメソッドの実行が完了すると、addメソッドのフレームは取り除かれ、addメソッドの戻り値がmainメソッドのフレームのオペランドスタックに積まれます。
後は、その値をistore_2を使用してローカル変数配列のインデックス2に格納します。
ローカル変数配列のインデックス2はresult変数を表しているので、addメソッドをコールしてresultに代入するJavaのコードが完了したことになります。
最後のSystem.out.printlnメソッドもインスタンスメソッドなので、メソッドコールの方法はaddメソッドの場合と同じです。
少しだけ違うのが、printlnメソッドのターゲットとなるオブジェクトのSystem.outです。
System.outはSystemクラスのクラス変数(static変数)であるoutです。
このため、System.outを取得するために、オペコードのgetstaticを使用します。
15行のgetstatic #14がその部分です。
コンスタントプールの#14を見てみると、次のようになっています。
#14 = Fieldref #15.#16 // java/lang/System.out:Ljava/io/PrintStream; #15 = Class #17 // java/lang/System #16 = NameAndType #18:#19 // out:Ljava/io/PrintStream; #17 = Utf8 java/lang/System #18 = Utf8 out #19 = Utf8 Ljava/io/PrintStream;
#14がFieldrefでフィールドの参照を示していることが分かります。その値が#15.#16です。
#15の方がフィールドのクラス、#16がフィールド名と型を表します。
さらにたどっていくと、#15がjava.lang.Systemを指していることが分かります。
#16はNameAndTypeで、#18がフィールド名、#19が型を表しています。#18には文字列定数でout、#19も文字列定数でLjava/io/PrintStreamとなっています。
今まで、Iがint、voidがVだということは出てきました。プリミティブ型ではなく参照型の場合、接頭語としてLが使用され、その後にパッケージを含めたクラス名が続きます。
Aではなくて、Lになることにご注意ください。
これで、getstaticでSystem.outの参照がオペランドスタックに積まれることが分かりました。
後は、addメソッドと同じように引数もオペランドスタックに積んで、invokevirtualを実行します。
mainメソッドにはreturn文の記述はありませんが、実際には省略されているだけでreturn文は必ずあります。
25行のreturnがそれに相当します。mainメソッドに戻り値はないので、接頭辞はないreturnを使用します。
returnを実行すると、mainメソッドのフレームが破棄され、Adderクラスの実行が完了します。
まとめ
Javaだと高々数行のコードですが、バイトコードで見てみるとJavaのコードを細かく分解して実行されます。
ここで示したように、メソッド単位でフレームが作られ、その内部でオペランドスタックとローカル変数配列で処理を進めていくのです。
さて、次回はif文やforループなどの制御構造について紹介する予定です。