Java 24のリリース関連エントリーが続きましたが、再びバイトコード入門に戻ってきました。
今回はJVMスタックに積まれたフレームでどのようにバイトコードを処理するのか紹介していきます。今回は動作を説明するため、個々の命令についての説明は必要最低限とさせていただきます。
バイトコード処理構成のおさらい
まず、バイトコードを処理する構成について簡単におさらいしておきましょう。
バイトコードを実行するために、JVMスタックが使用されるということを前回説明しました。
JVMスタックはスレッドごとに作成されます。ここでのスレッドはOSのスレッドと1対1に対応するPlatform Threadを指しています。Virtual ThreadはPlatform Thread上で動作するので、Virtual Thread用にJVMスタックが作られることはありません。
JVMスタックには、メソッドコールごとにフレームが積まれます。
たとえば、mainメソッドからfooメソッドがコールされ、fooメソッドからbarメソッドがコールされて、barメソッドを処理している場合、JVMスタックにはmainメソッドのフレーム、fooメソッドのフレーム、barメソッドのフレームという3つのフレームが積まれます。
barメソッドの処理が完了し、fooメソッドに戻ってきた時点でbarメソッドのフレームもJVMスタックから取り除かれます。
フレームは主にローカル変数用配列と、バイトコード処理中のデータを保持するオペランドスタックから構成されます。
いずれもそのサイズはjavacコンパイル時に決定します。
バイトコード処理
今回は動作だけを説明するので、とても簡単なアプリケーションを使用しましょう。
Adderクラスは整数の足し算をするメソッドaddメソッドを持つクラスです。
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); } }
このプログラムが実行されると、メインスレッドに対応するJVMスタックが生成されます。そして、mainメソッドがコールされて、JVMスタックにもmainメソッドに対応するフレームが積まれます。
mainメソッドの中でaddメソッドがコールされると、JVMスタックにもaddメソッドに対応するフレームが積まれます。
この状態を表したのが、以下の図です。
では、addメソッド実行の様子を追っていきましょう。
addメソッドのバイトコード
Adderクラスをデバッグオプションの-gを使用してコンパイルし、javapでaddメソッドを調べたのが以下です(-gオプションについては、前回を参照してください)。
private int add(int, int); descriptor: (II)I flags: (0x0002) ACC_PRIVATE Code: stack=2, locals=4, args_size=3 0: iload_1 1: iload_2 2: iadd 3: istore_3 4: iload_3 5: ireturn LineNumberTable: line 3: 0 line 4: 4 LocalVariableTable: Start Length Slot Name Signature 0 6 0 this LAdder; 0 6 1 x I 0 6 2 y I 4 2 3 z I
ここで使用されているオペコード(命令)を簡単に説明しておきましょう。
- iload: int型の整数をローカル変数配列からオペランドスタックに積む。_の後の数字はローカル変数配列のインデックス
- iadd: int型の加算
- istore: オペランドスタックの先頭にあるint型整数を取り出し、ローカル変数配列に格納。_の後の数字はローカル変数配列のインデックス
- ireturn: オペランドスタックの先頭にあるint型整数を戻り値としてメソッドを完了させる
この後に実際の動きを説明するので、ここではそんなものぐらいに思っておいてください。
addメソッドの動作
では、オペランドスタックとローカル変数配列を含めて、addメソッドの動作を追っていきます。
addメソッドがコールされると、引数がローカル変数配列に格納されます。また、インスタンスメソッドの場合、暗黙の引数としてthisが渡され、それも一緒にローカル変数配列に置かれます。
addメソッドを引数としてx=2, y = 3でコールされた直後の様子は次のようになります。
ローカル変数配列のインデックス0にthis、1にxの値である2、2にyの値である3が入ります。
上図では分かりやすさのため、ローカル変数配列にx、y、zを記述してありますが、実際にはローカル変数名はコンパイル時に削除され、インデックスだけで扱われます。
ただし、前回説明したようにコンパイル時に-gオプションをつければ、LocalVariableTableが付随し、インデックスと変数名の対応が付けられるようになります。
はじめのiload_1はローカル変数配列のインデックス1の値をオペランドスタックに積むということです。iloadのiはint型を表しています。i以外にも接頭辞はありますが、それは次回説明することにします。
この結果、オペランドスタックには2が積まれます。
同様に、iload_2でオペランドスタックに3が積まれます。
次のiaddはint型の加算です。
ここでは、「バイトコード入門その2」で示したHPの電卓のように、演算に必要な個数だけ値をスタックから取り出し、結果をスタックに積みます。
加算の場合、演算に必要な値は2つなので、オペランドスタックに積まれていた3と2を取り出し、加算をした結果の5をオペランドスタックに積みます。
加算の結果はオペランドスタックにしかないので、オペランドスタックから値を取り出し、ローカル変数配列に収めるのが、次の行のistore_3です。
istore_3なので、配列のインデックス3の場所に加算結果の5を格納します。
この操作が、ローカル変数zに5を代入していることに相当します。
残りはJavaのコードのreturn z;の部分です。
iload_3で再び5をオペランドスタックに積みます。
そして、オペランドスタックに積んだ値を戻り値として、メソッドを完了させるのがireturnです。
ireturnが完了すると、addメソッドに相当するフレームは削除されます。
これでaddメソッドが完了しました。
このaddメソッドはとても単純なメソッドですが、オペランドスタックとローカル変数配列の使い方は変わらないので、これが理解できれば後はそれほど難しいことはないはずです。
ちなみに、最後のストアとロードが不要のように見えるかもしれませんが、気にしない方がよいです。
もし、このメソッドが何度もコールされるようであればHotSpot VMによって最適化されます。
javacでコンパイルした時点では最適化はされずに、本当に必要であれば実行時に最適化されるのです。
次回はメソッドコールやオブジェクト生成など、もうちょっと複雑なバイトコードを紹介していきます。また、if文やループなども紹介する予定です。