JVM を読む | JVM の構造その1 - 型について
前回の続きです。前回はこちらから。
このシリーズは,JVM の仕様書を読み解くためのガイドとして構成しています。
JVM の仕様書は非常に長大で難解な内容が多いため,各セクションごとに要点をまとめていきます。
また,JVM の内部構造や動作原理を知ることで,Java のパフォーマンスやセキュリティ,メモリ管理の仕組みを深く理解する試みです。
シリーズはこちらから。
第二章 The Structure of the Java Virtual Machine
JVM の仕様書の第2章は「Java Virtual Machine の構造」です。
といいましてもこの章は全7章ある JVM の仕様書の中でも特に長く,また特に複雑な内容ですので,全8回に分けて解説していきます。
ここでは Chapter 2.1,2.2,2.3 の内容(主にクラスファイルと型システム)を扱います。
.class
ファイル形式(› 2.1 The class
File Format)
2.1 JVM によって実行されるコードは,特定のハードウェアやオペレーティング・システムに依存しない形式で表現されます。
みんな大好き クラス・ファイル・フォーマット(class
file format)と呼ばれる形式のことです。
この形式は JVM 系言語で書かれたプログラムをコンパイルした結果生成される,バイト・コード(Byte code)を格納するための標準的な形式です。
バイト・コードにはクラスやインタフェースの定義,メソッドやフィールドの情報,さらには定数プールなどが含まれます(詳しくは第4章で解説します)。
バイト・コードについて
Java 言語で記述されたコードは,JVM が受け付ける唯一の機械語である バイト・コードにコンパイルされます。
これはアセンブラとよく似ており,JVM という仮想的なスタック・マシン(スタックを操作して計算を行う仮想マシン)に対して命令を発行します。
(このすばらしいアプリケーションは,ImHex(GitHub)というものです。Web 版もありますので,ぜひに!)
これは javap -v Main
コマンドを実行した結果(抜粋)です:
› 2.2 Data Types)
2.2 データ型(さて,ここからは本格的に JVM の構造について説明していきます。
このセクションでは,まず JVM の型システムについて説明します。
JVM はプリミティブ型と参照型の2つのデータ型をサポートします。
- プリミティブ型:数値や真偽値などの基本的なデータ型
- 参照型:オブジェクトや配列への参照を表す型
これらは変数に格納したり,メソッドの引数として渡したり,戻り値として返したりできます。
型のチェック
例えば,Java 言語でプログラムを書いているときに誤った型の値を変数に代入しようとすると,コンパイル時にエラーが発生します。
このように,細かな型のチェックはコンパイル時に(バイト・コードの生成時に)一括して行われます。
ですから,JVM は型の整合性を実行時に保証しません。
その代わりに,JVM は特定の型を操作するための命令を使用して,オペランドの型を明示的に区別します。
例えば iadd
や ladd
のような命令は,それぞれ2つの数値を足して返すものですが。前者は int
型の値を,後者は long
型の値を扱います。
このようにして JVM は扱う型を命令ごとに明示的に指定して,その型に応じた操作を行います。
ぺやんぐ注
以下のような JVM 命令について考えます。
なお,JVM はスタック・マシンですから各命令はスタックのトップにある値を操作します。
iconst_1 // int 型の 1 をスタックにプッシュ
iconst_5 // int 型の 5 をスタックにプッシュ
// --- スタックの内容; [1, 5] ---
iadd // スタックから2つの int 型の値をポップして足し算し,結果をスタックにプッシュ
// --- スタックの内容; [6] ---
この例では,iadd
命令はスタックから2つの int
型の値をポップして足し算を行い,その結果をスタックにプッシュします。
このため,JVM は型の整合性を保証しないものの,命令ごとに適切な型を使用することが求められます。
さらに long
を扱う場合は以下のようになります。
lconst_0 // long 型の 0 をスタックにプッシュ
lconst_1 // long 型の 1 をスタックにプッシュ
// --- スタックの内容; [0L, 1L] ---
ladd // スタックから2つの long 型の値をポップして足し算し,結果をスタックにプッシュ
// スタックの内容; [1L]
なお,スタック・トップにある値とその命令が扱う方が異なる場合は VarificationError
がスローされます。
さらに詳しい解説は,JVM を読む | JVM の構造その6 - 命令セット概論と型の関係について をご参照ください!
(脚注おわり。)
オブジェクト型
JVM はオブジェクト型を仕様として標準的にサポートしています。
オブジェクト型とは,動的に作成されるクラスのインスタンスや(プリミティブ型を含む)配列です。
(例えば Object obj = new Object();
のようなコードで作成されるインスタンスや,int[] arr = new int[10];
のような配列のことです。)
オブジェクト型は常にそれを参照する型である参照型として扱われ,これは JVM において特別な意味を持ちます。
参照型の値はオブジェクトのメモリ上の位置を指すアドレスです。C の言葉で言うところの「ポインタ」のようなものです。
なお, オブジェクトの直接的な操作はできませんが,一方で参照を通じてオブジェクトのフィールドやメソッドにアクセスできます(=参照渡し)。
ぺやんぐ注
例えば以下のコードについて考えます:
public static void doStuff(int[] array) {
array[0] = 2; // 配列の最初の要素を変更
}
int[] arr = new int[10]; // int 型の配列を作成
arr[0] = 1; // 配列の最初の要素に値を代入
doStuff(arr); // 配列をメソッドに渡す
System.out.println(arr[0]); // 2 と出力される
このとき doStuff(int[])
メソッドに渡される array
引数は arr
の参照を受け取ります。
そのため,同じメソッド内で array[0] = 2;
とすると,arr
の最初の要素が変更されます。
このようにして, JVM はオブジェクト型を参照型として扱い,オブジェクトのメモリ上の位置を指すアドレスを操作します。
(脚注おわり。)
› 2.3 Primitive Types and Values)
2.3. プリミティブ型とその値(JVM でサポートされるプリミティブ型は数値型と真偽値型,および returnAddress
型の3つです。
数値型
数値型は整数型と浮動小数点型の2つに分けられます。
整数型は以下の5つがあります:
-
byte
- 8 ビットの符号付き整数型(-128
から127
までの値を表現) -
short
- 16 ビットの符号付き整数型(-32,768
から32,767
までの値を表現) -
char
- 16 ビットの符号なし整数型(0
から65,535
までの値を Unicode 文字として表現) -
int
- 32 ビットの符号付き整数型(-2,147,483,648
から2,147,483,647
までの値を表現) -
long
- 64 ビットの符号付き整数型(-9,223,372,036,854,775,808
から9,223,372,036,854,775,807
までの値を表現)
浮動小数点型は以下の2つがあります:
-
float
- 32 ビットの単精度浮動小数点型 -
double
- 64 ビットの倍精度浮動小数点型
浮動小数点型の詳細
浮動小数点型は IEEE 754 標準に概ね基づいています。
すなわち float
型は 32 ビットの単精度浮動小数点数を表し,double
型は 64 ビットの倍精度浮動小数点数を表します。
浮動小数点型は数値の範囲と精度を提供しますが,整数型と比べて精度が低くなることがあります。
特に float
型は 7 桁程度の精度を持ち,double
型は 15 桁程度の精度を持ちます。
特に NaN
(Not a Number)や無限大(Infinity
)などの特殊な値もサポートしており,
これらは浮動小数点演算において特別な意味を持ちます。
浮動小数点型の零以外の有限値 F
(NaN
や無限大を除く)は,次のように表現されます:
ここで:
-
は符号(-1 または 1)S -
はM の範囲にある仮数(N は仮数のビット数。2^{N} float
型は23
,double
型は52
。) -
は指数で,E ~E_{min} = -(2^{K-1}-2) の範囲の値(K は指数のビット数。E_{max} = 2^{K-1}-1 float
型は8
,double
型は11
。)
加えて NaN
は順序を持ちません。ですから数値比較や等価性のチェックでは,どちらかが NaN
の場合は常に false
を返します。
特にある値がそれ自身と等しいかどうかのチェックでは,NaN
は常に false
を返します。
さらに不等性のチェックでは,NaN
は常に true
を返します。
(注: NaN は Not a Number の略であり,数値としての意味を持たないためです。)
例:
float a = /* NaN */;
System.out.println(a == a); // false
System.out.println(a != a); // true
System.out.println(a < 0); // false
System.out.println(a > 0); // false
boolean
型)
真偽値型(真偽値型は boolean
型と呼ばれ,true
または false
の2つの値を持ちます。
実は, JVM の内部では boolean
型というのは(明示的には)存在しません。
代わりにそれぞれ整数型の 1
と 0
として表現されます。
さらに JVM の命令セットでは boolean
型に特化した命令は存在しません。
そのために,boolean
型の値は int
型の値として扱って操作します。
なお,配列の作成だけは newarray
命令を使用して特別にサポートされています。
一方で,配列への値の代入や取得には bastore
(-127
から 127
までの値を保存)や baload
(-127
から 127
までの値を取得)などの命令が使用されます。
returnAddress
型
これは jsr
(Jump to Subroutine) 命令でサブルーチンにジャンプしたとき,ret
命令でサブルーチンから戻るためのジャンプ元アドレスを表す特別な型です。
この型は,JVM の命令セットにおいて特別な意味を持つ型ですから,jsr
,jsr_w
,ret
命令のみで使用されます。
そのため,通常のプログラムでは直接使用されることはありません。
returnAddress
型の値は,JVM 命令のオペコードへのポインタを表します。数値のプリミティブ型とは異なり,
実行時に何らかの操作によってその値が変化することはありません。
main:
// `jsr` 命令はサブルーチンにジャンプし,スタックに `returnAddress` を保存します。
jsr label
label:
astore_0 // 戻る先のアドレスをスタックから取り出し,ローカル変数 0 番地に保存する
// サブルーチンの処理
// 何らかの(例えば計算などの)処理を行う
// `ret` 命令はスタックから `returnAddress` を取り出
ret 0 // ローカル変数 0 番地に保存された `returnAddress` を使ってジャンプ元に戻る
ぺやんぐ注
jsr
と ret
命令は,サブルーチンの呼び出しと戻りを行うための便利な命令です。
しかし,その理解のしにくさと StackMapFrame の計算の複雑さから,Java SE 1.6 以降では使われません。
StackMapFrame について
StackMapFrame とは,コードのセキュリティをより向上させるためにJava SE 1.6 で導入された機能です。
具体的には,コードをコンパイルする際に,コンパイラが「或る命令に到達するときに,スタックやローカル変数の中にはどのような型の値があるのか」を記録します。
この情報は StackMapTable としてクラス・ファイル内に格納され,JVM がコードを実行する際に,Verification(検証)を行うために使用されます。
(これが付加されていないバイト・コードを読み込もうとすると, VerifyError
がスローされて読み込めません。)
或る命令に対して複数の経路で到達できる場合に(if
系命令によるジャンプなど),JVM は全ての経路においてスタックとローカル変数の型が同じであることを要求します。
そのためには,JVM はコードのフロー解析を行い,スタックとローカル変数の型を型推論によって計算する必要があります。
計算した型情報は StackMapFrame として,複数の経路で到達できる命令に対して記録されます。
例えば, A -> B -> C -> B -> D
のように遷移するコードについて考えます。
B
に着目すると,これには2通りの遷移があることが分かります。1つは A
から来る場合,もう1つは C
から来る場合です。
このとき B
に到達したときにスタックやローカル変数にどのような型の値があるかは,A
から来た場合と C
から来た場合で異なる可能性があります。
JVM はこれを許さず,コードが整合性破綻の無くコンパイルされていることを保証する必要があります。
この計算には数回のループと複雑な条件分岐が必要であり,特に大規模なコードでは非常に時間がかかることがあります。
そのために, Java SE 1.6 以降ではコンパイル時にあらかじめ StackMapFrame を計算し,実行時の検証を最低限に抑えるようにしました。
少し脱線してしまいましたが,ここで jsr
と ret
命令の話に戻ります。
おさらいですが,jsr
でサブルーチンにジャンプすると,スタックに returnAddress
が保存されます。
(これをローカル変数に保存することもできます。)
その後,サブルーチンの処理が終わると ret
命令でジャンプ元に戻ります。
この returnAddress
は,JVM の命令のアドレスを表し,スタックに格納されたり,ローカル変数に格納されたりします。
このような特性は,静的解析による型推論を非常に困難にし,実質的に StackMapFrame の計算を不可能にします(不可能ではありませんが,かなり難しいです)。
そのために,Java SE 1.6 以降では jsr
と ret
命令は使用されなくなり,代わりに通常のメソッド呼び出しと戻りを使用することが推奨されています。
(脚注おわり。)
› 2.4 Reference Types and Values)
2.4. 参照型とその値(参照型には,クラス型,配列型,およびインタフェース型の3つがあります。
これらはそれぞれ,動的に作成されるクラス・インスタンスや配列,或いはインタフェースを実装したクラス・インタンスへや配列への参照を表します。
配列型は,プリミティブ型の配列(例えば int[]
や boolean[]
)や参照型の配列(例えば String[]
や Object[]
)を含みます。
なお,配列型はそれ自体に数値での長さを持ちません。
その代わりに,これは1次元の構成要素型と呼ばれる概念で構成されます。例えば int[]
は int
型の配列であり,String[][]
は String[]
型の配列です。このように,配列の基となる型を構成要素型と呼びます。
もちろん,配列の構成要素型はプリミティブ型や参照型(構成要素型を含む)のいずれかです。
これによって,長さに制限の無い多次元配列を表現しています。
なお,「配列の構成要素型の構成要素型の…」と続けた先の末端は配列でない型(プリミティブ型か参照型)になります。
参照型は null
値である場合もあります。
null
値は,参照型の値が何も指していないことを示し,参照型のデフォルトの値となっています。なお, null
値はどの参照型にも代入でき,キャストも可能です。
まとめ
いかがでしたか?
この章では JVM の構造とデータ型について解説しました。
JVM はプリミティブ型と参照型の2つのデータ型をサポートし,クラス・ファイル・フォーマットを使用してコードを表現します。
また,JVM は型の整合性を保証しないものの,命令ごとに適切な型を使用することが求められます。
次回は Chapter 2.5 の内容(実行時のデータ領域)を扱います。
では,よいバイト・コードライフを!
次回リンク
参考文献&リンク集
- 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
Discussion