🖥

JVM を読む | JVM の構造その6 - 命令セット概論と型の関係について

に公開

前回の続きです。前回はこちらから。

https://zenn.dev/peyang/articles/reading-jvm-chapter-02-9-10

このシリーズは,JVM の仕様書を読み解くためのガイドとして構成しています。
JVM の仕様書は非常に長大で難解な内容が多いため,各セクションごとに要点をまとめていきます。
また,JVM の内部構造や動作原理を知ることで,Java のパフォーマンスやセキュリティ,メモリ管理の仕組みを深く理解する試みです。

シリーズはこちらから。

https://zenn.dev/peyang/articles/reading-jvm-chapter-00

第二章 The Structure of the Java Virtual Machine

JVM の仕様書の第2章は「Java Virtual Machine の構造」です。
といいましてもこの章は全7章ある JVM の仕様書の中でも特に長く,また特に複雑な内容ですので,全8回に分けて解説していきます。

ここでは Chapter 2.11.1 ~ Chapter 2.11.4 の内容を扱います。

2.11 命令セット概論(› 2.11 Instruction Set Summary

JVM の命令は,実行する操作を指定する1バイトのオペコード(opcode)と,必要に応じて追加のオペランド(operand)を持ちます。

instruction ::= opcode [operand]*

ここで,JVM のインタプリタの内部構造は次のように簡単に表現できます:

do {
  // 次の命令の位置を取得する
  currentPc = fetchNextPC();
  if (!currentPc) {
    break;  // 次の命令がない場合はループを抜ける
  }
  opcode = readNextOpcode(currentPc);
  switch (opcode) {
    // 命令を処理するケース
  }
} while (true);  // 無限ループ

オペランドとバイトコードの格納

オペランドの数と大きさは,命令ごとに異なります。
オペランドのサイズが1バイトを超える場合は,ビッグエンディアン(上位バイトが先頭)で格納されます。
例えば,16ビットのオペランドは,2バイト(byte1,byte2)として格納され,その値は byte1 << 8 | byte2 として計算されます。

例えば,ローカル変数への符号なし 16 ビット整数の値を格納する命令は,2つの符号なしバイト(byte1,byte2) として格納され,
その値は byte1 << 8 | byte2 として計算されます。

バイト・コードの命令列は,基本的に1バイトで詰め込まれています。
さらに,各命令(オペコードとそのオペランド)は,連続して配置されます。
その際には,何らかのパディングを用いて次の命令をアライメントする必要はありません。

すなわち,以下のような命令列も有効なバイト・コードです:

0x10 0x05 0x3c 0x00
↑ bipush
     ↑ ローカル変数 5
          ↑ istore_1
               ↑ nop

2.11.1 型と JVM の関係 (› 2.11.1 Types and the Java Virtual Machine)

命令と型の深い深い関係

JVM が対応するほとんどの命令は,(内部的には無いように見えても)その命令が操作するデータの型に特化しています。
例えば iload 命令と fload 命令は,どちらもローカル変数から値をロードしますが,前者は int 型の値を,後者は float 型の値をロードします。
もしこの2つの内部的な実装が同一であっても,オペコードは異なるのです。

このように型に特化している命令は,以下の表のようにその名前に型情報が含まれている事が多いです。

接頭辞 説明
b 8ビット整数(byte)型を表す。
c 16ビット文字(char)型を表す。
s 16ビット整数(short)型を表す。
i 32ビット整数(int)型を表す。
l 64ビット整数(long)型を表す。
f 32ビット浮動小数点数(float)型を表す。
d 64ビット浮動小数点数(double)型を表す。
a オブジェクト参照(Object)型を表す。

詳しくは第6章で解説します(暫しお待ち下さい)。

オペコードの限界とパフォーマンスのジレンマ

先ほど述べた通り,JVM のオペコードは 1 バイトで表現されます。
1バイトということは,オペコードの種類が計 2^8 = 256 種類を超えることはできません。

さらに前述の通り,例えば加算命令を1つとっても add(iaddladdfadddadd) といったふうに, 型ごとに異なるオペコードが必要です。

そこで JVM は,一部に直交的な命令を用意して,非直交的な命令を補完する戦略を取りました。

直交的な命令というのは,dupswap のように,どの型に対しても同じ動作をする命令です。どの型に対しても同じ動作をするので,あらゆる型に対して同じオペコードを使用でき,オペコードの数を抑えられます。

一方で非直交的な命令は,型ごとに異なる動作をする命令です。
例えば, iaddint 型の値を加算しますが,laddlong 型の値を加算します。
このような非直交的な命令を使用することで,VM の動作を効率化したのです。

このような非直交的な命令と直交的な命令を組み合わせることで,オペコードの数を抑えつつ,効率的な実行を実現しているのです。
さらに必要に応じて,その操作がある型と無い型とを変換するための命令(d2ii2d など)を用意して,命令を網羅的にカバーしています。

例:

iload_0  ; int 型のローカル変数 0 をスタックにプッシュ
i2d      ; スタックの int 型の値を double 型に変換
dstore_1 ; スタックの double 型の値をローカル変数 1 に格納

dload_1 ; double 型のローカル変数 1 をスタックにプッシュ
dup     ; スタックの値を複製
dadd    ; スタックの2つの double 型の値を加算
...

このように,dup 命令はどの型に対しても同じ動作をする直交的な命令であり,
i2d 命令は int 型の値を double 型に変換する非直交的な命令です。
これらを組み合わせることで,異なる型の値を効率的に操作できます。

型と命令の使い回し

ここで述べて於かなければならない事実があります。
それは,ほとんどの命令には整数型 bytechar, short 用の命令がないことです。
特に boolean 型用の命令は全く持って存在しません。

事実,int 型をローカル変数からスタックにプッシュする命令(iload)は有るのに対して,byte 型をローカル変数からスタックにプッシュする命令(bloadのような)は存在しません。

これには少々深い理由がございます。
例えば byte 型や short 値がオペランド・スタックにプッシュされるたびに,内部では符号拡張によって暗黙的に int 型に変換されます。
同様に boolean 型と char 型の値がプッシュされるたびに,零拡張によって暗黙的に int 型に変換されます。

このように,JVM は byte, char, short, boolean 型の値を int 型に変換して処理するため,これらの型専用の命令は存在しなくてよいのです。
そのために,これらの型の値は int 型の操作をする命令によって正しく操作できるのです。

型とカテゴリ

型にはカテゴリという概念があります。
これは型のサイズやメモリ上の配置に関する情報を示すものです。
JVM では型は次の2つのカテゴリに分類されます:

  1. カテゴリ 1int, float, byte, char, short, boolean, reference, returnAddress
    • これらの型は 32 ビット(4 バイト)で表現されます。
    • 例えば int 型の値は 4 バイトのメモリ領域を占有します。
  2. カテゴリ 2long, double
    • これらの型は 64 ビット(8 バイト)で表現されます。
    • 例えば long 型の値は 8 バイトのメモリ領域を占有します。

2.11.2 読み込みと保存に関連する命令(› 2.11.2 Load and Store Instructions

これらの命令は,ローカル変数とオペランド・スタック間のデータの読み込みと保存を行います。

ローカル変数からの読み込み

ローカル変数から値を読み込む命令は,例えば次のようなものがあります:

命令 説明
iload int 型のローカル変数から値を読み込み,オペランド・スタックにプッシュします。
fload float 型で同様の操作をします。
dload double 型で同様の操作をします。
lload long 型で同様の操作をします。
aload オブジェクト参照型で同様の操作をします。

さらに,それぞれについて _0, _1, _2, _3 の接尾辞を付けることで,ローカル変数のインデックスを直接指定できます。
例えば iload_0 はローカル変数 0 から int 型の値を読み込みます。
これにより,ローカル変数のインデックスを指定するためのオペランドを省略でき,命令のサイズを小さくできます。

オペランド・スタックへの保存

ローカル変数に値を保存する命令は,次のようなものがあります:

命令 説明
istore オペランド・スタックから int 型の値を読み込み,ローカル変数に保存します。
fstore float 型で同様の操作をします。
dstore double 型で同様の操作をします。
lstore long 型で同様の操作をします。
astore オブジェクト参照型で同様の操作をしす。

これも同様に,それぞれについて _0, _1, _2, _3 の接尾辞を付けて直接ローカル変数のインデックスを指定できます。

ローカル変数番号の拡張

ローカル変数を扱う命令(上記のものと iinc, ret)は,ローカル変数のインデックスを 0 ~ 255 の範囲で指定します。
これはオペランドを 1 バイトで表現するためです。

しかし,ローカル変数のインデックスが 255 を超える場合があるかも分かりません。
そのような場合には wide 命令を使用することで,オペランドを 2 バイトで表現します。
そうすることによってローカル変数のインデックスを 0 ~ 65535 の範囲で指定できます。

例:

iload 9999  // これは無効な命令です。
wide iload 9999  // そのため, wide 命令を使って 2 バイトでインデックスを指定します。

スタックへの定数のプッシュ

定数を無からオペランド・スタックにプッシュする命令は,次のようなものがあります:

命令 説明
bipush 8 ビットの符号付き整数をオペランド・スタックにプッシュします。
sipush 16 ビットの符号付き整数を扱います。
ldc 定数プールから値を読み込みます。
ldc_w 16 ビットのインデックスを使用して定数プールから値を読み込みます。
ldc2_w 64 ビットのインデックスを使用して定数プールから値を読み込みます。
iconst_<i> int 型の定数をオペランド・スタックにプッシュします。
fconst_<f> float 型の定数をオペランド・スタックにプッシュします。
dconst_<d> double 型の定数をオペランド・スタックにプッシュします。
lconst_<l> long 型の定数をオペランド・スタックにプッシュします。

このとき…

  • <i>m1, 0, 1, 2, 3 のいずれかで,iconst_m1, iconst_0 …のように記述します。
    なお, iconst_m1-1 を表します。
  • <f>0, 1, 2 のいずれかです。
  • <d>0, 1 のいずれかです。
  • <l>0, 1 のいずれかです。

2.11.3 算術演算に関する命令(› 2.11.3 Arithmetic Instructions

算術命令は,(通常は)オペランド・スタックのトップにある2つの値を操作し,結果をオペランド・スタックにプッシュします。

これらは整数値を扱う命令と浮動小数点値を扱う命令に分かれます。
特に,浮動小数点値を使う命令は, IEEE 754 規格には厳格には準拠していません(参考:JVM を読む | JVM の構造その4 - オブジェクトの表現と浮動小数点数の計算について)。

命令は次のように分類されます:

命令 int 型 long 型 float 型 double 型
加算 iadd ladd fadd dadd
減算 isub lsub fsub dsub
乗算 imul lmul fmul dmul
除算 idiv ldiv fdiv ddiv
剰余 irem lrem frem drem
否定 ineg lneg fneg dneg
ビットシフト ishl, ishr, iushr lshl, lshr, lushr
論理積 iand land
論理和 ior lor
排他的論理和 ixor lxor
インクリメント iinc
比較 fcmpl, fcmpg dcmpl, dcmpg

2.11.4. 型変換に関する命令(› 2.11.4 Type Conversion Instructions

JVM は,異なる型間の変換を行うための命令を提供しています。
これは,プログラマがコード内で明示的に型変換をしたり,JVM 命令セットの非直交的な部分を補ったりするために使えます。

拡張変換

JVM は,次のような値を拡張するための命令を提供しています:

命令 説明
i2l int 型の値を long 型に変換します。
i2f int 型の値を float 型に変換します。
i2d int 型の 値を double 型に変換します。
l2i long 型の値を int 型に変換します。
l2f long 型の値を float 型に変換します。
l2d long 型の値を double 型に変換します。
f2d float 型の値を double 型に変換します。

この x2y の形式は,x 型の値を y 型に変換することを意味します(ちなみに 2 は「to」の意味です)。

ほとんどの拡大変換では,絶対値が変わらないように変換されます。
事実,intlong の変換や, intdouble の変換では,数値の値はそのままコピーされます。
さらに floatdouble の変換では,浮動小数点数の値もそのままコピーされます。

浮動小数点数値の変換について

整数値の変換について

intlong の変換は, int 型の値の2の歩数表現をそのまま long 型にコピーしたうえで,0 を左側に埋めます。
さらに char を変換する場合には,同じく char 型の値の2の歩数表現をそのまま int 型にコピーしたうえで,0 を左側に埋めます。

縮小変換

JVM は,次のような値を縮小するための命令を提供しています:

命令 説明
i2b int 型の値を byte 型に変換します。
i2c int 型の値を char 型に変換します。
i2s int 型の値を short 型に変換します。
l2i long 型の値を int 型に変換します。
f2i float 型の値を int 型に変換します。
d2i double 型の値を int 型に変換します。
d2l double 型の値を long 型に変換します。
d2f double 型の値を float 型に変換します。

浮動小数点数値の変換について

浮動小数点値を整数型(int, long)に変換する場合は,値は次のように丸められます:

  1. 浮動小数点が NaN(非数)である場合は,0 に変換されます。
  2. 浮動小数点が ±Infinityではない場合に,かつ変換後の型で表現できる場合は,(丸め規則に従って)その値に丸められます。
  3. 浮動小数点が ±Infinity の場合は,符号に応じてそれぞれの型の最大値または最小値に変換されます。

まとめ

JVM の命令セットは,オペコードとオペランドを組み合わせて構成されます。
命令は型に特化しており,直交的な命令と非直交的な命令を組み合わせることで,効率的な実行を実現しています。

型はカテゴリに分類され,命令はそれぞれの型に対して異なる動作をします。
また,ローカル変数とオペランド・スタック間のデータの読み込みと保存を行う命令があり,定数をオペランド・スタックにプッシュする命令も存在します。
算術演算に関する命令は,整数値と浮動小数点値に分かれ,型変換に関する命令も提供されています。

次回は,JVM の命令セットの解説の続きとして,オブジェクトの生成やフロー制御に関する命令について解説します。

では,よいバイト・コードライフを!

次回リンク

https://zenn.dev/peyang/articles/reading-jvm-chapter-02-11-5-10

参考文献&リンク集

  • 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
GitHubで編集を提案

Discussion