Java言語#
教科書8章 プログラミング言語
序論#
C言語に引き続きJava言語の操作的意味論を考える.Javaはオブジェクト指向言語であり,C言語と比較して数多くの言語機能が搭載されている.当然ながらOO関連の操作ではC言語よりも複雑な挙動を取る.
一方でOO以外の命令型関連の処理はC言語と共通する部分も多い.本ページを学ぶ際は,先のC言語を復習しておくと良い.あるいは,先のC言語で理解できなかった点は本ページを学ぶことで解決する可能性もある.
Javaの実行環境#
Javaの特徴の一つとして,JVM(Java仮想マシン,Java virtual machine)と呼ばれる仮想マシンを採用している点が挙げられる.JVMはソフトウェア上で実装されたスタック型の仮想マシンである.
Javaで記述されたソースコードは,コンパイラによって機械語ではなくJavaバイトコードと呼ばれる中間表現に変換される.以降,Javaバイトコードを単にバイトコードと呼ぶ.バイトコードはJVM上で実行可能な命令形式であり,JVMはこのバイトコードに従って様々な計算を行う.
%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 10, 'rankSpacing': 30, 'padding': 5}}}%%
graph LR
classDef tr fill:#f6f6f6,stroke:#c0c0c0;
classDef tx fill:#ffcfe7,stroke:#f61182;
source[ソースコード<br>.java];
byte[バイトコード<br>.class];
compiler[コンパイラ]:::tr;
source --> compiler;
compiler --> byte;
byte --> jvm1["JVM<br>(win用)"]:::tx;
byte --> jvm2["JVM<br>(mac用)"]:::tx;
byte --> jvm3["JVM<br>(linux用)"]:::tx;
jvm1 --> Windows:::tr;
jvm2 --> macOS:::tr;
jvm3 --> Linux:::tr;
一般的なコンパイル型言語の場合,次のような流れとなる.
%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 10, 'rankSpacing': 30, 'padding': 5}}}%%
graph LR
classDef tr fill:#f6f6f6,stroke:#c0c0c0;
classDef tx fill:#ffcfe7,stroke:#f61182;
source[ソースコード<br>.c];
compiler1["コンパイラ<br>(win用)"]:::tr;
compiler2["コンパイラ<br>(mac用)"]:::tr;
compiler3["コンパイラ<br>(linux用)"]:::tr;
bin1["バイナリ<br>(win用)"];
bin2["バイナリ<br>(mac用)"];
bin3["バイナリ<br>(linux用)"];
source --> compiler1;
source --> compiler2;
source --> compiler3;
compiler1 --> bin1;
compiler2 --> bin2;
compiler3 --> bin3;
bin1 -------> Windows:::tr;
bin2 -------> macOS:::tr;
bin3 -------> Linux:::tr;
具体的なバイトコードは次の通りである.バイトコードは本来バイナリファイルであるが,ここではテキスト形式で示している点に注意せよ.
機械語へ直接翻訳せずバイトコードを介すことで,特定のCPUやOSに依存することなくプログラムを開発し公開することが可能となる.この特徴は "Write once, run anywhere" と呼ばれている.
Note
Java以外にもJVMを利用するプログラミング言語も登場している.KotlinやScala,GroovyはJavaとは別の言語ではあるが,コンパイルの結果はJavaバイトコードである.つまりJVM上で動作する.このような言語をJVM言語と呼ぶ.Javaの作り上げた様々な資産(JVM自体やIDE,ビルドツールなど)を活用しつつ,Javaとは別の言語によってプログラムの開発が可能である.
また,PythonのようなJVM言語ではない言語のソースコードを,Javaバイトコードに変換するというアイデアも登場している.
Quiz
Javaで実装された有名なシステムを答えよ.
Javaの操作的意味論を考える上では,以下の点に着目すれば良い.
- Javaソースコードがどのようなバイトコードに変換されるか
- バイトコードに対してJVMがどのように振る舞うか
本ページではC言語の時と同様,いくつかの単純化・モデル化を適用した上で操作的意味論を考える.
C言語 | Java言語 | |
---|---|---|
ソースコードの言語 | SC言語* |
SJava言語* |
コンパイル結果の命令体系 | SCコード* |
Javaバイトコード |
実行環境 | SCマシン* |
JVM |
*
は本Webサイト固有の単純化されたモデルであり,実際のプログラミング環境で用いられている要素ではない点に注意せよ.Javaは中間表現であるバイトコード,および仮想マシンであるJVMがすでに定義されているため,ここでは単純化せずそのまま説明に用いる.
SJava言語#
Javaのサブセット言語,SJava言語(Small Java言語)を定義する.通常のJavaから以下のような単純化を適用している.
- データ型は
int
とクラス名のみ(int i
やString s
はOK) - メソッドの戻り値は上記データ型に加えて
void
も指定可能(void m() {...}
はOK) - 制御文はif文とwhile文のみ(for文,switch-case文,do-while文などは排除)
- 例外処理は省略(throw文やtry文は排除)
- アクセス修飾子は
public
か省略のみ(private
やprotected
はNG) ++
や--
等の単項演算子と+=
や-=
などの複合演算子は省略- 継承
extends
はあり,インタフェースや抽象クラスは省略
SJava言語を用いたプログラムの一例を示す.
class User {
int age;
String name;
public User(int age, String name) {
this.age = age;
this.name = name;
}
public void print() {
System.out.println("age is: " + age);
System.out.println("name is: " + name);
}
}
class Student extends User {
int grade;
public Student(int age, String name, int grade) {
super(age, name);
this.grade = grade;
}
public void print() {
super.print();
System.out.println("grade is: " + grade);
}
}
class Teacher extends User {
...
SJava言語の構文定義を示す.SC言語では取り入れていた,演算子に対する優先順位や結合性のための構文規則も省略している.全体として構文定義は大幅に単純化されていると認識せよ.
構文図式によるSJava言語の構文定義
JVMとJavaバイトコード#
Javaの操作的意味論を考える前に,JVMの基本的な動作について説明する.JVM自体は数多くの機能を備えており,ここでは一部のみを説明している点に注意せよ.
メモリ領域#
JVMのメモリ領域はスタック領域,ヒープ領域,メソッド領域の3つで構成される.
JVMはスタックマシンの一種であり,SCマシンと同様スタック領域を中心に計算を進める.ヒープ領域には参照型データ(インスタンス)の各種データが保持される.メソッド領域には実行すべきバイトコード列が格納される.メソッド領域はSC言語で述べていたプログラム領域とほぼ等価である.
スタックとヒープの違いを考える.Javaにおける変数はプリミティブ型と参照型の2つがある.プリミティブ型はスタック領域に直接値が格納される.参照型は値自体はヒープ領域に格納され,その参照がスタック領域に積み上げられる.
int p1 = 7; // プリミティブ型
int p2 = 30; // プリミティブ型
Integer r1 = new Integer(50); // 参照型
String r2 = new String("h"); // 参照型
int[] r3 = {10, 11, 12}; // 参照型
上記のソースコードに対しては,以下のようにメモリ上にデータが格納される.青枠の■は変数を格納する単一のメモリセルを表している.
参照型r1
とr2
の実態データ(50と"h")はサイズが小さくスタック内に格納も可能である.しかし参照型はr3
のように大きなメモリサイズを要することが多い.例えば,クラス内に多数のフィールドを持つ場合,全フィールドについてメモリ領域を確保する必要がある.ヒープ領域には参照型の大きなメモリ領域を確保し,スタック領域にその参照を積み上げて計算を行う.
Quiz
以下の2つのプログラムはいずれもメモリ領域不足のエラーが発生する.どちらがスタックの,どちらがヒープの領域不足かを考えよ.
public static void main(String[] args) {
StringBuffer s = new StringBuffer(); // 書き換え可能な文字列
while(true) {
s.append("x");
}
}
Answer
Aがスタックの,Bがヒープの領域不足になる.
Exception in thread "main" java.lang.StackOverflowError
at Main.f(Main.java:6)
at Main.f(Main.java:6)
at Main.f(Main.java:6)
...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3745)
at java.base/java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:172)
at java.base/java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:538)
at java.base/java.lang.StringBuffer.append(StringBuffer.java:317)
at Main.main(Main.java:12)
スタックフレーム#
SC言語でも確認したように,スタックを用いることでメソッド呼び出し時の状況の変化を容易に表現できる.例えば,メソッドmain()
実行中に別メソッドf()
を呼び出した際には,main()
で利用していた各種変数の内容や式処理の途中経過を保持したまま,f()
の計算に必要な新たな変数領域を確保できる.
JVMのスタック領域における各メソッド専用のメモリ領域を,スタックフレームと呼ぶ(単にフレームとも呼ぶ).SC言語で述べていた駆動レコードと同じ概念である.例えば次のソースコードに対しては,
以下のようにスタックフレームが積み上げられる.
メソッドf2()
の内部処理はスタック最上部で行い,f2()
の処理が終了した際には当該スタックフレームを丸ごと削除する.その後f1()
のスタックフレーム上でf1()
の処理を継続する.
スタックフレーム内部は次のようなレイアウトで構成されている.
JVMでは次の4つの特殊なレジスタを持つ.スタックフレームのレイアウトと併せてその役割をよく考えよ.
●pc
:メソッド領域のうち次に実行するバイトコードを指す.SC言語でのpc
●レジスタ(プログラムカウンタ)と同じ役割である.バイトコード命令を1つ実行すると値が1加算される.if文やwhile文などの制御文の際には,このpc
の値が適当なメソッド領域の位置に変更される.
●optop
:現スタックフレームの最上部を指す.SC言語でのsp
●レジスタ(スタックポインタ)と同じ役割である.optop
はスタック最上部のオペランドスタックと呼ばれる領域を指し示している.オペランドスタックはスタック領域における計算作業用の領域であり,push/popの繰り返しによって様々な計算が行われる.例えば4 + 3
という式に対しては,オペランドスタック領域上でオペランド4
と3
を積み上げ,バイトコード命令iadd
によって最終的に7
のみが積み上げられる.
●vars
:現スタックフレームの最下部にある変数領域■の起点を指す.SC言語でのbase
●レジスタと同様の役割を持つ.スタックフレーム内で保持すべき変数とは,メソッド呼び出し時の引数,およびメソッド内部で利用される局所変数の2種類である.メソッドによって引数と変数の数は異なるため,この変数領域のサイズはメソッドごとに決定される.ただしメソッド実行中にこの領域サイズが変更されることはない(可変長のデータはヒープ側に保持される).
呼び出すメソッドが非staticメソッド(インスタンスメソッド)の場合,インスタンス自身の参照であるthis
も積み上げられる.this
は暗黙的な(参照型の)局所変数だといえる.
thisの振る舞い(補足)
this
の振る舞いがわからない場合は以下の例をよく考えること.
●frame
:呼び出し元スタックフレームに関する制御領域■の起点を指す.一例として,メソッドmain()
からf()
を呼び出す瞬間を考える.このとき,新たに積み上げるf()
用の新スタックフレームには,以下のような情報を記憶しておく必要がある.
- 現在の●
pc
レジスタの値(=呼び出し元main()
の次の命令の位置,戻り番地) - 現在の●
vars
レジスタの値(=呼び出し元main()
のスタックフレームの形状) - 現在の●
frame
レジスタの値(=呼び出し元main()
のスタックフレームの形状) - 現在の●
optop
レジスタの値はf()
の●vars
-1なので記憶は不要
f()
の処理終了後には上記の情報に基づいて,どのバイトコードを継続するか,呼び出し元スタックフレームがどのような形状になっているかを復元する.
Note
スタック領域内のフレームの形状は,レジスタの指定によって成り立っている点を認識せよ.スタックに限らずメモリ領域は単なる直列データ構造であり,基本的にはランダムアクセスが可能である.つまり,スタックの特徴である先入れ後出し(LIFO)以外の自由なアクセスができる.
スタック領域をスタックとして扱うには,スタックの形状,つまり最下部●と最上部●の位置を意識しながらLIFO形式でメモリを利用する.
なお,現在の●optop
レジスタはスタックフレーム内で保持する必要はない.呼び出し元スタックフレームの●optop
は呼び出し先スタックフレームの●vars
-1と一致するためである
(vars
はスタックの最下部である).
Javaバイトコード#
JavaバイトコードはJVMが実行する命令群である.バイトコードの命令(オペコード)は1バイトで表される.よって256個が定義可能であるが,現在は約200個の命令が定義されている.
JVMがスタックマシンであることから,バイトコードはSCコードとよく似た挙動を取る.当然ながらx64やx86等の実際のCPU命令セットとも共通する点は多い.一例としてJavaソースコードに対応するバイトコードを示す.スタックをイメージすれば,その挙動を理解できるはずである.
ロードやストア命令に対しては_0
のような接尾辞を取ることがある.例えば,int型変数のロード命令iload
に対してはiload_0
~iload_3
の4つの命令が存在する.iload_0
はiload 0
と機能的に等価である.これらはバイトコードを小さくするために設けられた命令である(1).本ページの本質ではないので注意せよ._
を無視して読み進めれば良い.
- オペコードのみで完結する,つまりオペランドが不要になるのでバイトコードサイズが小さくなる.
iload 0
の16進表現は15 00
であるのに対し,iload_0
の16進表現は1a
のみである.2バイトが1バイトになる.
Note
局所変数を常に4個以内に納めれば,バイトコードは僅かに小さくなるということである.
Note
javap
コマンドを用いれば実際にバイトコードを確認することができる.
$ javac Main.java # コンパイル(*.classの生成)
$ javap -v Main.class # バイトコードの確認
Classfile /D:/tmp/Main.class
Last modified 2024/12/25; size 275 bytes
...
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: iconst_5
1: istore_1
2: bipush 10
4: istore_2
5: bipush 50
7: iload_1
8: iadd
9: istore_2
...
スタックへのプッシュ命令#
スタックへのプッシュ命令を題材として,具体的なバイトコードをいくつか説明する.プッシュ命令はスタック領域への最も基本的な操作である.先述したスタックフレームのレイアウトを参照しながら読み進めること.また各命令のスタック領域への操作内容は,SC言語と同様C言語形式で説明する(optop++;stack[optop]=...;
等).
再掲:スタックフレームのレイアウト
3 + 4
という式があった場合,定数値3
と4
をスタック上に積み上げる必要がある.積み上げる値の大きさに応じて,様々なプッシュ命令が設けられている.これは命令の接尾辞_n
と同じく,バイトコードに対する最適化が目的である(1).
- プッシュ対象の値の型サイズに合わせて,オペランドのサイズを短くしている. 例えば,byte型のプッシュ
bipush
ではオペランドは1バイト, short型のプッシュsipush
ではオペランドは2バイトである.
命令 | スタック領域とレジスタに対する操作 |
---|---|
iconst c |
optop++;stack[optop]=c; |
bipush c |
optop++;stack[optop]=c; |
sipush c |
optop++;stack[optop]=c; |
ldc a |
optop++;stack[optop]=heap[a]; |
●optop
は現スタックフレームの最上部を指している.各プッシュ命令では,まずoptop++
でスタックの最上部を確保し,stack[optop]=...
で値をプッシュする.
プッシュ対象の値cが0~5の場合はiconst
,-127~128(byte型)はbipush
,-32,768~32,767(short型)はsipush
が用いられる.それ以上の値は定数ロード命令ldc
が用いられる.暗記は不要である.値の大きさに応じて様々なプッシュ命令があると認識せよ.
ldc
では,ヒープ領域(1)に保持された定数値をロードしてからスタックに積み上げられる.例えば50000
という値は,ldc 1
のようにコンパイルされる.プッシュ対象の値が大きすぎると,オペランドのサイズに収まらないためである.バイトコードのオペランドは2バイトまでである.
- 厳密にはヒープ内の定数プール(constant pool)と呼ばれる領域に保持される.これは定数のみが列挙された空間である.
スタックへ積み上げる対象は上記の定数だけでなく,変数の場合がある.例えばプリミティブ型変数の加算式,i + j
に対しては変数i
とj
の値を一度ロードしてからスタックへ積み上げる.この場合,iload
命令が用いられる.
命令 | スタック領域とレジスタに対する操作 |
---|---|
iload a |
optop++;stack[optop]=stack[vars+a]; |
aload a |
optop++;stack[optop]=stack[vars+a]; optop++;stack[optop]=heap[stack[vars+a]]; |
読み込む変数がプリミティブ型の場合はiload
命令が用いられる.プリミティブ型の値はスタックフレームの変数領域■内に直接格納されている.具体的な位置はスタック最下部●vars
レジスタからの相対位置(vars+a
)で指定される(stack[vars+a]
).
読み込む変数が参照型の場合はaload
命令が用いられる.参照型の値はスタックではなくヒープ領域にあり,そのヒープ領域への参照がスタックの変数領域■に保持されている(先述のメモリ領域を参照せよ).※2
参照型を読み出す際の振る舞いはiload
の挙動と基本的に同じである.iload
は値そのものが,aload
は値ではなく参照がスタック最上部に積み上げられる.
よって,一度スタック領域を見てヒープ内への参照を取りだしてから(stack[vars+a]
),実際にヒープ内の値をロードし(heap[...]
),スタックへプッシュする(stack[optop]=...
).
操作的意味論#
具体的なSJavaソースコードと,それに対応するバイトコードを例示しながらJava言語の操作的意味論を考えていく.基本的な説明の流れは以下に従うので適宜参照せよ.
- ある操作
X
(代入や分岐など)を用いたソースコード例を確認する - そのソースコードに対応するバイトコード列を確認する
- バイトコード中の命令がレジスタとスタックをどのように操作しているかを確認する
- バイトコード列を一般化し,操作
X
の意味論を考える
局所変数への代入#
プリミティブ型の局所変数への代入は以下のようなバイトコードに変換される.
まず右辺値(オペランド)のサイズに応じて,各種スタックへのプッシュ命令(bipush
,sipush
,ldc
)が使い分けされている.右辺値が変数の場合はiload
が用いられる.右辺値をスタックに積み上げた後は,int型の変数領域にストアするistore
によって代入が実現される.
次に参照型の局所変数への代入文を考える.
定数のロード命令ldc
によって右辺値が積み上げられている.参照型の値は常にヒープ領域に格納されている.変数領域への格納処理は参照のストア命令astore
が用いられる.ストア命令はストア対象が値か参照の違いはあるものの,スタックに対する操作の流れは共通している.
2つのストア命令に対する,スタック領域とレジスタに対する操作の内容を以下の表に示す.
命令 | スタック領域とレジスタに対する操作 |
---|---|
istore a |
stack[vars+a]=stack[optop];optop--; |
astore a |
stack[vars+a]=stack[optop];optop--; heap[stack[vars+a]]=stack[optop];optop--; |
istore
はスタック最上部の値を直接スタック内に格納している.格納先の位置は●vars
レジスタからの相対位置で指定される.
※4
astore
の振る舞いはistore
と同じである.ただしスタックの最上部,および局所領域には常に値自体ではなく,参照が格納されている.この参照はヒープ領域を指し示している.
他方,astore
の格納先はヒープ領域である.格納先の位置は間接アドレスの形式で指定される.まずスタックの●vars
レジスタからの相対位置を確認し,そこに記載された参照を辿ることで格納すべきヒープ上のメモリ領域を特定する.
再掲:スタック領域とヒープ領域の中身
代入文の操作的意味論をまとめる.
代入文の操作的意味
※<i>
は変数i
のvars
からの相対位置であり,非負整数を取る.
四則演算#
続いて四則演算を用いた式を考える.まず四則演算式のバイトコード例を示す.
加算の際は,演算子の二項をスタックに積み上げiadd
命令でスタック最上部の2つの値を足し合わせる.単項マイナス演算子に対しては,対象をスタックに積み上げた後ineg
で全ビットを反転する.
四則演算子や単項マイナス演算子の操作内容は以下の通りである.
命令 | スタック領域とレジスタに対する操作 |
---|---|
iadd |
optop--;stack[optop]=stack[optop]+stack[optop+1]; |
isub |
optop--;stack[optop]=stack[optop]-stack[optop+1]; |
imul |
optop--;stack[optop]=stack[optop]*stack[optop+1]; |
idiv |
optop--;stack[optop]=stack[optop]/stack[optop+1]; |
ineg |
stack[optop]=-stack[optop]; |
《宿題1》#
Homework 約30分
答え
次のSJava言語のプログラムをバイトコード列に変換(コンパイル)せよ.必ず演算子の優先度を考慮すること.
ヒント:演算子の優先度を考慮する際は,逆ポーランド記法をイメージすると良い.詳細はC言語#SCマシンを参照せよ.
以下の書式をコピーして回答せよ.変数x
の相対位置は1とせよ.
比較演算#
続いて比較演算子を考える.2項の比較演算子(==
)に対応するバイトコードは次の通りである.
比較演算子==
は意味的には逆のif_icmpne
(compare not equal)にコンパイルされている点に注意せよ(1).if_icmpne
は条件付きジャンプ命令である.if_icmpne L
はスタック最上部の2つを比較し,異なる場合はプログラムカウンタpc
をL
に指定する(処理をL
にジャンプさせる).
- 比較演算子を逆条件にする理由は,if文において条件成立時の処理と不成立時の処理の順序をバイトコードでも同じにするためだと言われている. 詳細はStackoverflowの記事を参照せよ.
比較演算子==
に対しては,まずif_icmpne
を用いて比較を行い,値が異なるなら0のプッシュ命令にジャンプさせる.値が同じなら1をプッシュし,goto
によって0のプッシュ処理をスキップする.
結果的に2つの値が同じ時に値1がスタックに積み上げられる.比較演算の結果である真偽値(true/false
)は,メモリ上では常に1か0で表されている.1が真,0が偽である.
比較演算子はプログラムカウンタを直接書き換えるgoto
やif_compne
等のジャンプ命令によって実現されている.Java言語自体はgoto
文を利用できないが,Javaバイトコードのレベルではジャンプ命令が多用されている.
命令 | スタック領域とレジスタに対する操作 |
---|---|
if_icmpne L |
if(stack[optop]!=stack[optop-1]){optop-=2;pc=L} |
if_icmpeq L |
if(stack[optop]==stack[optop-1]){optop-=2;pc=L} |
if_icmpge L |
if(stack[optop]>=stack[optop-1]){optop-=2;pc=L} |
if_icmpgt L |
if(stack[optop]> stack[optop-1]){optop-=2;pc=L} |
ifeq L |
if(stack[optop]==0) {optop--;pc=L;} |
比較演算子の操作的意味
※ソースコードとバイトコードで比較演算子が反転する点に注意せよ.
Quiz
次のJavaプログラムの出力を考えよ.if_icmpne
はスタック上の値の比較if(stack[optop]!=stack[optop-1])
である点をよく考えよ.
public static void main(String[] args) {
String s1 = new String("aaa");
String s2 = new String("aaa");
System.out.println(s1 == s2); // true? false?
}
Answer
false
が出力される.
if_icmpne
はスタック内の値を直接比較する.よって比較対象が参照型変数の場合は参照の値の比較となる.s1
とs2
は中身は同じ(ヒープ内の値は同じ)だが全く異なるインスタンスである.つまり参照は別物なため,結果はfalse
となる.
ヒープ内を比較する場合は比較専用メソッドequals()
を使う.
補足:Stringクラスは特殊
Stringクラスは2種類の宣言方法がある.
public static void main(String[] args) {
String s1 = new String("aaa");
String s2 = new String("aaa");
System.out.println(s1 == s2); // false
String s3 = "aaa";
String s4 = "aaa";
System.out.println(s3 == s4); // true
}
s1
とs2
はStringクラスのインスタンスであるのに対し,変数s3
とs4
は文字列リテラルと呼ばれる.StringリテラルはStringプールと呼ばれるヒープ上の特殊な領域に格納される.リテラルはメモリ上の扱いが異なるだけで,Stringインスタンスと同じように扱うことができる(s3.toUpperCases()
はOK).
変数s3
とs4
はどちらもStringプール上の"aaa"
というメモリ領域を指し示す.つまり参照が同じなため,==
演算による比較でtrueが得られる.
文字列を比較する際はequals()
を使うことを強く推奨する.Stringインスタンスでも文字列リテラルでも同じ結果となる.
制御文#
制御文(条件文と繰り返し文)の操作的意味論を考える.まずif文のバイトコードを確認せよ.
10 == x
の部分は先の比較演算子と全く同じである.レジスタ最上部の2つの値が異なる場合に,L1
にプログラムカウンタを設定する.つまりif文の条件式が成り立たない場合に処理をL1
にジャンプさせる.条件式が成り立つときはジャンプせず,そのまま真の場合の処理を行い,最後にgoto L2
で偽の処理をスキップする.
制御文の操作的意味
さらにwhile文の操作的意味論は次の通りである.
if文とwhile文の操作的意味論を比較すると,■■■ → ●●●
の流れが完全に一致している.while文のみgoto
による強制ジャンプによって繰り返しを実現している.
オブジェクトの生成#
オブジェクト指向関連の言語機能を考える.まずオブジェクト生成のバイトコードを以下に示す.
ここで最も重要な命令はnew
である.new X
命令は,オペランドのクラスX
の各種フィールド用領域をヒープ上に確保し,そのアドレスをスタックにプッシュする.この操作はC言語におけるmalloc()
と同じ挙動である(詳細は命令型言語#動的メモリ確保).
dup
はスタック最上部の値をコピーしてさらにスタックに積み上げる(重複,duplicate).これは続くinvokespecial
でスタックポップが行われるためであり,深く考える必要はない.
命令 | スタック領域とレジスタに対する操作 |
---|---|
new X |
optop++;stack[optop]=malloc(X); |
dup |
optop++;stack[optop]=stack[optop-1]; |
new
とdup
直後のスタック領域とヒープ領域の状態を以下に示す.重要な点はヒープ側にフィールド変数の領域が確保され,そのヒープへの参照をスタックに積み上げている点である.
最後にinvokespecial
命令によってクラスX
のコンストラクタを呼び出す.これによってnew
で確保したヒープ上のフィールド変数の初期化が行われる.invokespecial
の操作的意味論はメソッド呼び出しで説明する.
オブジェクト生成の操作的意味
メソッド呼び出し#
メソッド呼び出しの振る舞いを考える.メソッド呼び出しの瞬間には新たなスタックフレームが生成される.当然スタックへの操作は複雑なため,一つずつ丁寧に確認せよ.
ここで,メソッドm()
は2つの引数を持ち,3つの局所変数を利用しているものとする.
まずインスタンスx
の参照をaload
でスタックに積み上げる.次に2つの引数を各種プッシュ命令を用いて積み上げる.最後にメソッド呼び出し専用のバイトコード命令,invokevirtual
を呼び出す.invokevirtual
の振る舞い,および命令実行前後のスタックフレームの変化を次の表と図に示す.
命令 | スタック領域とレジスタに対する操作 |
---|---|
invokevirtual m |
stack[optop+v+1]=pc; // 現フレームの情報を退避 stack[optop+v+2]=vars; // 現フレームの情報を退避 stack[optop+v+3]=frame; // 現フレームの情報を退避 vars =optop-p; // 新フレームの構築(レジスタ値を更新) frame=optop+v+1; // 新フレームの構築(レジスタ値を更新) optop=optop+v+4; // 新フレームの構築(レジスタ値を更新) pc =m; ※ v は局所変数の数,p は引数の数 |
invokevirtual m
が行うべき処理は以下2つである.
- 現フレームの退避(=各種レジスタ値の退避)
- 新フレームの構築(=各種レジスタ値の更新)
まずは,各種レジスタ
●pc
,●vars
,●frame
の現在値を制御領域■に退避させる.この値が呼び出し元関数のスタックフレームの領域となり,return
時の旧フレームの復元に利用される.
続いて各種レジスタ
●pc
,●vars
,●frame
を書き換えて,新たなスタックフレーム領域を作り出す.このとき,メソッド引数として積み上げられた2つの値(50
と10
)が,新たなスタックフレームの引数領域としてそのまま再利用される.また,インスタンスx
の参照はthis
変数として再利用される.すなわち現フレームのオペランドスタックの一部が,新フレームの変数領域■としてオーバーラップすることになる.
最後にプログラムカウンタpc
を書き換えて,メソッドm()
の命令列へと処理をジャンプさせる.
これまでの流れに従い,メソッド呼び出しの操作的意味論を示しておく.意味論の観点ではソースコードとバイトコードとの対応よりも,バイトコード命令invokevirtual
の処理内容が重要である.
メソッド呼び出しの操作的意味
※v
は局所変数の数,p
は引数の数
他にもinvokespecial
などのメソッド呼び出し命令が存在する.
命令 | スタック領域とレジスタに対する操作 |
---|---|
invokespecial m |
コンストラクタ用のメソッド呼び出し命令. 基本は invokevirtual と同じ挙動である.(1) |
invokestatic m |
staticメソッド用のメソッド呼び出し命令. 基本は invokevirtual と同じ挙動である. |
- さらにスタック最上部を消費(ポップ)する.よってオブジェクト生成時は
invokespecial
の前にはdup
が必要である.
メソッドリターン#
最後にメソッドのリターン処理を取り上げる.以下の例は,返り値なしのリターンと返り値ありのリターンである.
返り値がない場合はreturn
命令のみが呼び出される.返り値がある場合は,スタック最上部に返り値を積み上げてからireturn
命令を呼び出す.
return
とireturn
の挙動は次の通りである.リターンで行うべき処理は,呼び出し元関数のスタックフレームの再構築である.これはinvokevirtual
と逆の操作である.具体的には,各種レジスタを書き換えて,現在のスタックフレームの真下のスタックフレームを再指定する.
命令 | スタック領域とレジスタに対する操作 |
---|---|
return |
optop=vars-1; // 旧フレームの構築(レジスタ値を復元) pc =stack[frame]; // 旧フレームの構築(レジスタ値を復元) vars =stack[frame+1]; // 旧フレームの構築(レジスタ値を復元) frame=stack[frame+2]; // 旧フレームの構築(レジスタ値を復元) |
ireturn |
stack[vars]=stack[optop]; // 返り値を旧フレームの最上部にコピー optop=vars; // 旧フレームの構築(レジスタ値を復元) pc =stack[frame]; // 旧フレームの構築(レジスタ値を復元) vars =stack[frame+1]; // 旧フレームの構築(レジスタ値を復元) frame=stack[frame+2]; // 旧フレームの構築(レジスタ値を復元) |
return
では最初に●optop
レジスタを●vars
レジスタの一つ下に設定する.これがリターンで戻るべき呼び出し元フレームの最上部(オペランドスタック)の位置に該当する.続いて●pc
,●vars
,●frame
の3つのレジスタを呼び出し元フレームに指定し直す.これらの値は現スタックフレームの制御領域■に保持されている.
ireturn
も概ねreturn
命令と同様であるが,返り値の処理が必要である.返り値処理のために,まずスタック最上部に積まれた値stack[optop]
をスタック最下部stack[vars]
にコピーする(this
の部分に上書きする).続いて●optop
をスタック最下部に指定し直す.これにより,旧スタックフレームのオペランドスタックに返り値が積まれた状態を作り上げる.後はreturn
と全く同じ処理であり,各種レジスタを再指定して旧スタックフレームの形状を再構築する.
リターン文の操作的意味
GC#
JVMではガベージコレクション(GC)と呼ばれる特殊なメモリ管理の仕組みが取り入れられている.GCはヒープ領域上で不要になったメモリ領域を自動的に削除する.
命令型言語#動的メモリ確保でも説明したように,C言語ではmalloc()
とfree()
を用いて開発者が明示的にメモリ領域の確保と削除を行う.この動的なメモリの領域としてはヒープ領域が用いられる.Javaも同様の振る舞いを採用しており,new
演算子でインスタンスを生成するとヒープ側にメモリ領域が確保される.
以下のようなJavaソースコードに対しては,100個のString
クラスのインスタンスがヒープ領域に設置される.この場合,局所変数s
はnew
の度に右辺値の参照先が書き換わるだけなので,スタック領域のサイズは常に一定である.
複数インスタンスの生成 | |
---|---|
Javaでは,new
演算子によってヒープのメモリ領域を確保できるが,free()
のような明示的なメモリ解放の手段は存在しない.free()
忘れはメモリリークに繋がるかつ顕在化しにくいバグであり,開発者にその責任を負わせるべきではない.malloc()
free()
は低水準な命令であるともいえる.
Javaではfree()
ではなく,GCと呼ばれるヒープ領域の自動的な解放の仕組みを採用している.基本的には到達不可能なメモリ領域を探し出してその領域を解放する.上記のソースコードの6行目終了の時点では,"1"
から"99"
までのStringインスタンスは誰からも参照されておらず,到達不能,つまり不要な領域だといえる.他方,"100"
のインスタンスは変数s
がその参照を保持しており,到達可能,つまり解放の対象とはならない.
GCはJVMだけでなく,PythonやJavaScriptの実行環境でも採用されている.近年のプログラマはメモリ解放の責務から解放されているといえる.
なおGCの実行には多くの計算コストを要することがある.GCが実行されることによって,プログラムが一定時間完全に停止することもある.この現象をStop the worldと呼ぶ.Stop the worldを回避するために,効率的なGCアルゴリズムが多数提案されている.マーク・アンド・スイープや参照カウントなどの手法が有名である.
また,Rustは自動解放GCや明示解放free()
を用いない,全く異なるメモリ管理の方式を採用している.