ちょっと間があいてしまいましたが、バイトコード入門の3回目です。今回はバイトコードをどのように処理してJVMの構成について紹介します。前回のスタックマシンがここで活かされてきます。
- 準備編
- スタックマシン
- バイトコード処理の構成 (今回)
JVMのメモリ構成
JVMで管理しているメモリ領域というと、多くの方がヒープと考えるのではないでしょうか。たしかに、ヒープは重要なメモリ領域であることには違いはありませんが、ヒープ以外にもJVMが管理しているメモリ領域があるのです。
HotSpot VMの場合、JVMが管理しているメモリ領域は大別して4種類あります。
- ヒープ: オブジェクトの配置用の領域
- メタスペース: クラス定義、メソッド定義、コンスタントプールなど
- ネイティブメソッドスタック: ネイティブメソッド用領域
- JVMスタック: コールスタック用領域
JVMSはJVMの仕様については記述されていますが、実装については記述されません。メモリ構成も実装に近いため大まかにしか説明されていませんが、JVMS 2.5 Run-Time Data Areasに記述があります。
なお、下図はJVMスタックを省略しています。
1つ目のヒープは、一番なじみがあるメモリ領域だと思います。
Javaのオブジェクトは必ずこのヒープに配置されます。GCの種類によってヒープはさらに細分化されるのですが、それはGCによるものでJVMSでは定義されていません。
たとえば、世代別GCを使用している場合、Young領域とOld領域に分けられるなどがこれに相当します。
2番目のメタスペースは静的なデータを保持させる領域で、クラスローダーごとに管理されています。
メタスペースというのはHotSpot VMの実装に基づいた領域名で、メタスペースに保存するデータとしてはクラス定義、メソッド定義などがあります。また、コンスタントプールもメタスペースに保持されます。
JVMSでは2.5.4 Method Areaと2.5.5 Run-Time Constant Poolがメタスペースの一部になっています。
ネイティブメソッドスタックは、JNIやFFMでネイティブメソッドを使用する際に使われる領域です。
最後のJVMスタック領域が今回メインで取り上げるメモリ領域です。JVMSでは2.5.2 Java Virtual Machine Stacksで定義されています。
これ以外にスレッドごとにどこを実行しているかを保持しておくPCレジスタ(Program Counter)もあります(JVMS 2.5.1)。
JVMスタック領域
例外がスローされた時に出力されるスタックトレースはJavaの開発者であれば誰もが見たことがあるはずですが、その意味を考えたことがありますか?
何かのスタックをトレースしたものということは分かると思います。そのスタックというのは、メソッドがコールされた順序を保持しておくスタックを指しています。一般的にはコールスタックと呼ばれるスタックです。
Javaの場合、このコールスタックはJVMスタックと呼ばれます。そして、そのJVMスタックが配置される領域がJVMスタック領域です。
なお、コールスタックが使用されていても、前回紹介したスタックマシンとは呼ばれないことに注意が必要です。
さて、JavaのJVMスタックは、並列処理が可能なようにスレッドごとに作られます。
そして、JVMスタックに積まれるのがフレームです(まちがえないとは思いますが、AWTのjava.awt.Frameクラスではないです)。
フレームはメソッドコールごとにJVMスタックに積まれます。たとえば、以下のようにmainからfoo、barとコールされる場合を考えてみましょう。
public class Main { static void bar() {} static void foo() { bar(); } public static void main(String... args) { foo(); } }
Mainクラスを実行すると、まずmainに対応するフレームが積まれます。fooメソッドがコールされるとそれに対応するフレームが積まれます。
そして、fooメソッドからbarメソッドがコールされると、barメソッドに対応するフレームが積まれます。
barメソッドが完了して、fooメソッドに戻る時に、barメソッドに対応するフレームは削除されます。
同様にfooメソッドの完了時にfooメソッドに対応するフレームが削除され、mainメソッドが完了する時にmainメソッドに対応するフレームが削除され、JVMスタックは空になります。
ところで、StackOverflowErrorという例外に遭遇したことがあるでしょうか。再帰などでコードにバグがある時に遭遇することが多い例外です。
再帰では自分自身を延々とメソッドコールするわけですが、メソッドコールの連なりが限度を超えた場合にStackOverflowError例外がスローされます。
もうお分かりだとは思いますが、StackOverflowError例外のスタックとはJVMスタックのことです。
JVMスタックにフレームを積み過ぎてあふれてしまうと、StackOverflowError例外がスローされるのです。
同様に例外発生時に提示されるスタックトレースのスタックもJVMスタックです。
例外発生時に、どのメソッドのコールされていたかは、JVMスタックをたどれば分かります。これがスタックトレースです。
実際には、次節で紹介するJVMスタックに積まれるフレームの情報を含めてスタックトレースが作られます。
フレーム
JVMスタックに積まれるフレームはJVMS 2.6 Framesで定義されています。
フレームの主要な構成要素は以下の2つです。
- ローカル変数用配列
- オペランドスタック
ローカル変数用配列
1つ目のローカル変数用配列は、ローカル変数とメソッドの引数を保持させる配列です。
ローカル変数およびメソッド引数がいくつ使用するのかは、ソースコードをコンパイルする時に調べることができます。このため、配列の要素数はその個数分になります。
ローカル変数もしくは引数がプリミティブ型の場合、その値が直接配列に保持されます。参照型の場合はその参照が保持されます。
また、インスタンスメソッドの場合、インデックス0には必ずthisが入ります。
とこで、ローカル変数/メソッド引数の名前はコンパイルすると情報として残りません。メソッド内では、ローカル変数用配列のインデックスで指定されます。
しかし、これだとクラスファイルを読んだだけだと何が配列に入っているのかが分かりにくいんですよね。
こんな理解度の低い人間のためのことを、javacはちゃんと用意してくれてあります。それがコンパイルオプションの-gです。
-gはデバッグ情報をクラスファイルに埋め込むためのオプションです。
たとえば、次のメソッドで試してみましょう。
void sayHello(String name) { var text = "Hello, " + name + "!"; System.out.println(text); }
これを-gを使用せずにコンパイルし、javap -vで表示させると次のようになります。
void sayHello(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: (0x0000)
Code:
stack=2, locals=3, args_size=2
0: aload_1
1: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
6: astore_2
7: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream;
10: aload_2
11: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
14: return
LineNumberTable:
line 3: 0
line 4: 7
line 5: 14
赤字で示したlocal=3がローカル変数用配列のサイズになります。
そして、次回、詳しく説明しますが、aload_1やastore_2の1や2が配列のインデックスになります。
しかし、そのインデックス1や2に何が保持されているかは、バイトコードから推測するしかありません。
そこで-gを使用してコンパイルしてみます。コンパイル結果を次に示します。
void sayHello(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: (0x0000)
Code:
stack=2, locals=3, args_size=2
0: aload_1
1: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
6: astore_2
7: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream;
10: aload_2
11: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
14: return
LineNumberTable:
line 3: 0
line 4: 7
line 5: 14
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 this LHello;
0 15 1 name Ljava/lang/String;
7 8 2 text Ljava/lang/String;
-gオプションを使用することで、最後にLocalVariableTableという表が追加されました。
この表のSlotが配列のインデックスになります。
sayHelloメソッドはインスタンスメソッドなので、前述したようにインデックス0にはthisが入ります。
インデックス1には引数のname、インデックス2にはローカル変数のtextです。
クラスファイルを読み慣れてくれば、Local Variable Tableがなくても、配列のどこに何が保持されているかは分かってきます。しかし、慣れないうちは、コンパイルオプションの-gをつけてコンパイルすることをお勧めします。
オペランドスタック
フレームのもう一方の構成要素がオペランドスタックです。名前の通り、オペランドを保持させるスタックです。
もちろん、このスタックがJVMをスタックマシンたらしめているスタックです。
オペランドというのは、処理命令であるオペコードの処理対象のデータのことになります。
バイトコードにはloadやstoreといったオペコードがあります。これらはローカル変数用配列からスタックにデータを積む、スタックの先頭データを取り出してローカル変数用配列に保持させるというようにスタックに対する処理になります。
他のバイトコードもほとんどがスタックやスタックのデータに対する命令となります。
このスタックのサイズも、コンパイル時に決まります。
先ほどのsayHelloメソッドの場合を見てみましょう。sayHelloメソッドの先頭部分を再掲します。
void sayHello(java.lang.String); descriptor: (Ljava/lang/String;)V flags: (0x0000) Code: stack=2, locals=3, args_size=2
このstack=2がオペランドスタックのサイズとなります。
ちなみに、args_size、つまり引数の個数が2になっているのは、インスタンスメソッドの暗黙の引数としてthisが渡されるからです。
バイトコード処理構成のまとめ
長くなってきたので、今回はここまでとして、まとめてみましょう。
- JVMのメモリ領域のうち、バイトコード処理に使われるのはJVMスタック領域
- JVMスタックはスレッドごとに作成され、メソッドコールごとにフレームが積まれる
- フレームはローカル変数用配列とオペランドスタックなどから構成される
- オペランドスタックを使用してバイトコードの処理を行う
たぶん、次のエントリーはJEPで語れないシリーズのJava 24になると思うので、その後のエントリーでやっとバイトコード処理について解説できるはずです。