JVM を読む | JVM をハックする その3 - 定数と変数で制御する編
前回の続きです。前回はこちらから。
このシリーズは,JVM の仕様書を読み解くためのガイドとして構成しています。
JVM の仕様書は非常に長大で難解な内容が多いため,各セクションごとに要点をまとめていきます。
また,JVM の内部構造や動作原理を知ることで,Java のパフォーマンスやセキュリティ,メモリ管理の仕組みを深く理解する試みです。
シリーズはこちらから。
第三章 Compiling for the Java Virtual Machine
JVM の仕様書の第3章は「Java Virtual Machine のためのコンパイル」です。
この章では,Java ソースコードを JVM が実行可能なバイトコードに変換するためのコンパイル方法について説明しています。
今回からは,実際に JVM の命令と,その動作を理解するための具体的な例を見ていきましょう。
この記事では,定数やローカル変数の扱い,さらには制御変数としての使い方について学びます。
ハンド・ブックはこちらから。
› 3.2 Use of Constants, Local Variables, and Control Structures)
3.2 定数や,ローカル変数,および制御構造を使う(JVM のバイト・コードは JVM の設計と型の制約によって課せられた特性があります。
この例では,この特性を多くの命令を通して示し,詳細に学びます。
JVM のスタック・マシンとしての側面を見る
お待たせしてしまい申し訳ありませんが,命令列について解説する前に,まずは JVM のスタック・マシンとしての側面を理解する必要があります。
(この内容は JVM を読む | JVM の構造その3 - フレームについてで述べた内容と重複しますから,もう既にカンペキに理解している方はこの節を飛ばしてもらっても構いません。)
皆々様ご存知の通り,JVM はスタック・マシンです。
ですから,ほとんどの命令は JVM の現在のフレームのオペランド・スタック(operand stack)に対して何らかの操作を行います。
操作というのはオペランド・スタックから(複数の)値を取り出したり(pop),値をスタックに積んだりする(push)ことです。
フレームとオペランド・スタック,ローカル変数
JVM を読む | JVM の構造その3 - フレームについてで私が述べたように,メソッドが呼び出されるときには,JVM は新しいフレームを作成します。
このフレームにはオペランド・スタックとローカル変数の配列が含まれています。
ですから,メソッドの開始地点ではオペランド・スタックとローカル変数配列の中には(引数以外)何も値が格納されていないことが保証されます。
プログラムの実行は,制御スレッドごとに多数のフレームを積み重ねていくことで行われます。
これらはメソッド呼び出しのチェーンに対応しており,チェーンの先端(スタックで言うところの一番上)のみが現在実行中のメソッドに対応しています。
まとめると,JVM はメソッド呼び出しのごとに,新しいフレームを作成します。
この中にはオペランド・スタックとローカル変数の配列が含まれており,各命令はこのフレームのオペランド・スタックに対して操作を行います。
フレームの初期状態は空ですから,例えば他の実行の副作用で生まれた値が,スタックに積まれていたりローカル変数に格納されていることはありません。
(なお,同記事で述べた通り,メソッドの引数はローカル変数の配列にあらかじめ格納されていることに注意してください。)
おまたせ致しました!
さて,JVM の基礎のキを理解したところで,ここからは実際の命令を見ていきましょう。
まずは制御変数としての定数やローカル変数の使い方を見ていきます。
以下は,for ループを使って 0 から 99 までの整数をカウントする Java のコードです。
(ループの中身は空ですので,ループの構造を理解するための例として使います。)
void spin() {
int i;
for (i = 0; i < 100; i++) {
// ループの中身は空です。
}
}
このコードをコンパイルすると,次のようになります(コンパイラによっては異なる場合がありますが,大体は同じような形になります)。
void spin() {
iconst_0 // int 型の定数 0 をプッシュ
istore_1 // ローカル変数 1 に定数 0 を格納
goto LoopBody // ループの本体へジャンプ(初回はインクリメントを飛ばします。
NextLoop:
iinc 1 1 // ローカル変数 1 の値をインクリメント(i = i + 1)
LoopBody:
iload_1 // ローカル変数 1 の値をスタックにプッシュ
bipush 100 // int 型の定数 100 をプッシュ
if_icmplt NextLoop // スタックの値を比較して i < 100 の場合は NextLoop へジャンプ
return // メソッドを終了
}
javap スタイルの命令例
Method void ispin()
0 iconst_0 // int 型の定数 0 をスタックにプッシュ
1 istore_1 // ローカル変数 1 に定数 0 を格納
2 goto 5 // ループの本体へジャンプ(初回はインクリメントを飛ばします)
5 iinc 1 1 // ローカル変数 1 の値をインクリメント(i = i + 1)
8 iload_1 // ローカル変数 1 の値をスタックにプッシュ
9 bipush 100 // int 型の定数 100 をスタックにプッシュ
11 if_icmplt 5 1/ スタックの値を比較して i < 100 の場合は 5 へジャンプ
14 return // メソッドを終了
以下の図は,この一連の命令の流れを示しています。
このコードは実際に JAL コードとして動作します。
試しに IntelliJ IDEA のエディタ画面に入力し,動かしてみましょう(何も表示されませんが…)。
for 文の表現
Java の for 文は,JVM の命令に変換されると,ジャンプ命令と比較命令,およびインクリメント命令に変換されます。
(for を1つの命令で表現することはできないのです。)
以下にループの本体部分を抜粋します。
NextLoop:
iinc 1 1 // ローカル変数 1 の値をインクリメント(i = i + 1)
iload_1 // ローカル変数 1 の値をスタックにプッシュ
bipush 100 // int型の 定数 100 をプッシュ
if_icmplt NextLoop // スタックの値を比較して i < 100 の場合は NextLoop へジャンプ
ここで bipush
命令は int
型の定数 100
をスタックにプッシュする命令です。
その後 if_icmplt
命令がスタックの値(i
と 100
)を比較します。
比較に成功した場合(i < 100
の場合)は,制御は NextLoop
ラベルの位置(iinc 1 1
命令の位置)に移されて,次のループが行われます。
それ以外の場合は,制御は ifcmplt
命令の次の命令(return
命令)に移され,メソッドは終了します。
ループの条件チェックを本体の後に配置している
この例では,ループの条件チェックを,ループの本体の後に配置しています。
最初に置いても良さそうなのに,なぜわざわざ後に配置しているのでしょうか。
実は驚くべきことに,このように条件チェクを後置することで,前置したときと比べてジャンプ命令が約半分の数で済むのです。
(例えば 100,000 回ループする場合,前置では 100,000 回のジャンプが必要ですが,後置では 50,000 回のジャンプで抑えられます。)
このことについてはJVM を読む | JVM をハックする その5 - 様々なフロー制御 編で詳しく説明しています。
ご興味のある方はそちらもご覧ください。
命令と型の関係
以前私が JVM を読む | JVM の構造その6 - 命令セット概論と型の関係についてで述べたように,JVM の命令はあらゆるデータ型に対する操作にそれぞれ個別の命令を持ち,それによってオペランド(命令の引数)の型を区別しています。
同じ加算をする命令でも,int
型の値を加算する場合は iadd
命令を使い,double
型の値を加算する場合は dadd
命令を使うということです。
この例のように,整数型の値を扱う命令は i
で始まるニーモニック(人が読みやすいように表現された命令の名前)を持ちます。
例えば上記の例で言うところの iconst_0
,istore_1
,iload_1
,iinc
,if_icmplt
などは,すべて int
型に特化した命令だとすぐに分かります。
オペランドの短縮に命を懸ける
ところで,このバイト・コードには「int
型の値を生成してスタックに積む命令」が2種類あることにお気づきでしょうか。
それは iconst_0
と bipush 100
命令の2つです。
前者は int
型の定数 0
を,後者は同じく 100
をスタックに積む命令です。
このことから,JVM では整数型の定数を生成するための命令がいくつも用意されていることが分かります。
特に iconst_<n>
命令は,iconst_m1
や iconst_0
… のようにすることで -1 から 5 までの整数を直接スタックに積む命令です。
一方で bipush
命令は,-128 から 127 までの整数をスタックに積むための命令です。
ではなぜこれらの命令が複数用意されているのでしょう。
別に bipush 0
とか sipush 0
と記述しても良いように思えます。
事実,この代替案は実行できますし,しかも一見同じ動きをするように見えます。
ではなぜわざわざ iconst_0
のような命令が用意されているのでしょうか。
これは生成されるバイト・コード・サイズを小さくすることと,ひいては実行パフォーマンスの向上を目的としています。
というのも,このような命令にはオペランドが必要なく,他の命令と違ってオペランドをフェッチする必要や,デコードする必要がなくなるということです。
さらにオペランドの分だけバイト・コードのサイズも小さくなります。
1つのオペランドをフェッチするのに 1 サイクルかかるとすると,例えば iconst_0
命令は追加のサイクルが不要になります。
これは非常に大きなパフォーマンスの向上をもたらします。
現代の CPU では命令のフェッチは非常に高速です。
しかしながら例えば組み込み系などの環境では,命令のフェッチは CPU のクロックサイクルに大きな影響を与えるかもしれません。
そのため JVM はこのような命令を用意して,パフォーマンスの向上を図っています。
ローカル変数でもオペランドを短縮できる
メソッド spin()
の int i
は,JVM のローカル変数スロット 1
に格納されます。
これは istore_1
命令によって行われます。
ここでもオペランドを短縮する技が使われています。
ほとんどの JVM の命令は,ローカル変数を直接操作するのではなく,オペランド・スタックから値を取り出して操作します。
そのためコンパイルされたコードでは,ローカル変数とオペランド・スタック間の値の転送が非常に多く見られます。
ここで通常使用する命令は,ローカル変数に値を格納する istore
命令と,ローカル変数から値を取り出す iload
命令です。
これらは istore 0
や iload 3
のように,ローカル変数のスロット番号をオペランドとして指定します。
しかし前述の通り,ローカル変数とオペランド・スタック間の値の転送が多いと,コードが冗長になり,パフォーマンスに影響を与える可能性があります。
そこで JVM は,ローカル変数のスロット番号を省略できる短縮形の命令を用意しています。
ここで言う istore_1
や iload_1
のような命令です。
さらに短縮の技法が現れている命令はほかにもあります。
iinc
命令は,ローカル変数の値をインクリメントする命令で,iinc <ローカル変数のスロット番号> <インクリメント値>
(e.g. iinc 1 1
) のように書きます。
この命令は(スタックを介さずに)ローカル変数の値を直接インクリメントするため,実行が非常に高速な上にパフォーマンスの向上にも寄与します。
このように,JVM はローカル変数のスロット番号を省略できる短縮形の命令を用意することで,コードの冗長性を減らし,パフォーマンスを向上させているのです。
double
型の例
違う型の制御変数 - さて,ここまでで int
型の制御変数を使ったループの例を見てきました。
次は double
型の制御変数を使ったループの例を見ていきましょう。
以下にその例を示します。
void dspin() {
double d;
for (d = 0.0; d < 100.0; d += 1.0) {
// ループの中身は空です。
}
}
このコードをコンパイルすると,次のような命令列になります。
void dspin() {
dconst_0 // double 型の定数 0.0 をスタックにプッシュ
dstore_1 // ローカル変数 1 に double型の定数 0.0 を格納
goto LoopBody // ループの本体へジャンプ(初回はインクリメントを飛ばします)
NextLoop:
dload_1 // ローカル変数 1 の値をスタック
dconst_1 // double 型の定数 1.0 をスタックにプッシュ
dadd // スタックの値を加算して結果をスタックにプッシュ
dstore_1 // 結果をローカル変数 1 に格納
LoopBody:
dload_1 // ローカル変数 1 の値をスタック
ldc_w 100 // double 型の定数 100.0 をスタックにプッシュ
dcmpg // スタックの値を比較(d < 100.0 の場合は 1, d == 100.0 の場合は 0, d > 100.0 の場合は -1 をスタックにプッシュ)
ifgt NextLoop // スタックの値が 1 の場合は Next
return // メソッドを終了
}
javap スタイルの命令例
Method void dspin()
0 dconst_0 // double 型の定数 0.0 をスタックにプッシュ
1 dstore_1 // ローカル変数 1 に double 型の定数 0.0 を格納
2 goto 9 // ループの本体へジャンプ(初回はインクリメントを飛ばします)
5 dload_1 // ローカル変数 1 の値をスタックにプッシュ
6 dconst_1 // double 型の定数 1.0 をスタックにプッシュ
7 dadd // スタックの値を加算して結果をスタックにプッシュ
8 dstore_1 // 結果をローカル変数 1 に格納
9 dload_1 // ローカル変数 1 の値をスタックにプッシュ
10 ldc2_w #4 // double 型の定数 100.0 をスタックにプッシュ
13 dcmpg // スタックの値を比較(d < 100.0 の場合は 1, d == 100.0 の場合は 0, d > 100.0 の場合は -1 をスタックにプッシュ)
14 ifgt 5 // スタックの値が 1 の場合は 5 へジャンプ
17 return // メソッドを終了
なお,このときの実行時定数プールを抜粋します:
#4: double 100.0
さて,double
型の制御変数を使ったループでは int
型に特化した iconst_0
などの替わりに, dconst_0
や dstore_1
のように double
型に特化した命令が使用されるようになったことが分かります。
(ldc2_w
命令に関しては,少しトピックから外れてしまうのでこの章の最後に説明します。)
以下の図は,この一連の命令の流れを示しています。
命令は完全には直交していない
ここで,或る読者は次のように考えるかもしれません:
「int
型の制御変数を使ったループでは if_icmplt
命令が使われていたのに,これは dcmpg
命令のあとに ifgt
命令が使われている。なぜだろう?」と。
これについては(この記事がかなり長くなってしまったため)JVM を読む | JVM をハックする その5 - 様々なフロー制御 編で詳しく説明しています。
ご興味のある方はそちらもご覧ください。
広い型たちと扱い方
double
や long
型は,ローカル変数スロットを2つ占有します(JVM を読む | JVM の構造その3 - フレームについてで解説しましたね)。
これはつまり,例えばローカル変数スロット0番地に long
型の値を格納しようとすると,スロット1番地も同時にその値を格納するために使用されることを意味します。
以下に2つの double
型の値を加算して返すという,小さなメソッドの例を示します:
static double sumDoubles(double d1, double d2) {
return d1 + d2;
}
このメソッドをコンパイルすると,次のような命令列になります。
double sumDoubles(double d1, double d2) {
dload_1 // 引数 d1 の値を,ローカル変数1番地と2番地からスタックにプッシュ
dload_3 // 引数 d2 の値を,ローカル変数3番地と4番地からスタックにプッシュ
dadd // スタックの値を加算して結果をスタックにプッシュ
dreturn // スタックの値を返す
}
javap スタイルの命令例
Method double sumDoubles(double d1, double d2)
0 dload_1 // 引数 d1 の値を,ローカル変数1番地と2番地からスタックにプッシュ
1 dload_3 // 引数 d2 の値を,ローカル変数3番地と4番地からスタックにプッシュ
2 dadd // スタックの値を加算して結果をスタックにプッシュ
3 dreturn // スタックの値を返す
以下の図は,この一連の命令の流れを示しています。
short
型の例
違う型の制御変数その2 - 先ほどは double
型の制御変数を使ったループの例を見ました。
そこでは,比較とジャンプが別々の命令で行われており,その理由についても説明しました。
JVM は,int
型の値を最も手厚くサポートしています。
これは JVM のオペランド・スタックとローカル変数配列の効率的な実装を見越した設計です。
或いは,プログラムでは int
型の値を最も多く使用することが多いことも,この理由の一つです。
しかし,同じ int
系の型であるはずの short
型や char
型,byte
型,さらには boolean
型は,int
型のように特別扱いされていません。
事実,store
命令や load
命令,或いは add
命令の byte
型や short
型,char
型バージョンは存在しません。
本当にそうなのか,以下の例を通して確認していきましょう。
void sspin() {
short i;
for (i = 0; i < 100; i++) {
// ループの中身は空です。
}
}
これは以下のような命令列にコンパイルされます。
void sspin() {
iconst_0
istore_1
goto LoopBody
NextLoop:
iload_1 // この short 型の値は,実際は int 型であるかのように扱われる
iconst_1
iadd
i2s // short 型に変換する
istore_1
LoopBody:
iload_1
bipush 100
if_icmplt NextLoop
return
}
javap スタイルの命令例
Method void sspin()
0 iconst_0 // int 型の定数 0 をスタックに
1 istore_1 // ローカル変数 1 に int 型の定数 0 を格納
2 goto 10 // ループの本体へジャンプ(初回はインクリメントを飛ばします)
5 iload_1 // ローカル変数 1 の値をスタックにプッシュ
6 iconst_1 // int 型の定数 1 をスタックにプッシュ
7 iadd // スタックの値を加算して結果をスタック
8 i2s // int 型の値を short 型に変換
9 istore_1 // 結果をローカル変数 1
10 iload_1 // ローカル変数 1 の値をスタックにプッシュ
11 bipush 100 // int 型の定数 100 をスタックに
13 if_icmplt 5 // スタックの値を比較して i < 100 の場合は 5 へジャンプ
16 return // メソッドを終了
このコードは,short
型の制御変数を使ったループですが,実際には int
型の命令(iconst_0
, istore_1
, iload_1
, iadd
, if_icmplt
など)を使用しています。
以下の図は,この一連の命令の流れを示しています。
int
型に拡張して扱うということ
JVM は必要に応じて short
型の値を int
型に拡張して扱います。
しかしこのままでは,short
であると期待した値が int
型のまま扱われることで,本来の範囲を超えてしまう可能性があります。
そのため,i2s
命令を使って int
型の値を short
型に変換して,値の操作結果が適切な範囲内に収まるようにしなければなりません。
整数型を直接サポートしなくても良い理由
JVM 仮想マシンが byte
型や,char
型,或いは short
型を直接サポートしていないことは,特段大きな問題ではありません。
なぜなら,これらの型の値は内部的に int
型に変換されて扱われるからです。
したがって,これらの型の値は大きな差異のなしに int
型の命令で扱えます。
唯一の違いは,int
型の演算の結果を有効な範囲に切り捨てるために,i2s
や i2b
のような変換命令を使用する必要があることだけです。
なお, long
型と浮動小数点型(float
や double
)は,JVM では中程度のサポートしかされていません。
唯一の違いは,条件付きジャンプ命令が,一部対応していないということだけです。
まとめ
いかがでしたか?
今回の記事では,JVM の命令における制御変数の使い方や,定数の扱いについて学びました。
特に,int
型の制御変数を使ったループの例や double
型の制御変数を使ったループの例を通して,JVM の命令セットの特性や制約について理解を深められました。
次回は,算術演算と実行時定数プールにアクセスする方法について学びます。
では,よいバイト・コードライフを!
次回リンク
参考文献&リンク集
- Lindholm, T., Yellin, F., Bracha, G., & Smith, W. M. D. (2025). The Java® Virtual Machine Specification: Java SE 24 Edition.
- Lindholm, T., & Yellin, F. (1999). The Java™ Virtual Machine Specification (2nd ed.). Addison-Wesley. ISBN 978-0-201-43294-7
- Otavio, S. (2024). Mastering the Java Virtual Machine. Packet Publishing. ISBN 978-1-835-46796-1
- Godfrey, N., & Koichi , M. (2010). デコンパイリング Java ― 逆解析技術とコードの難読化 ISBN 978-4-87311-449-1
Discussion