コンテンツにスキップ

Java言語#

教科書8章 プログラミング言語

2025/01/05 追記

参照型の値のロードaloadとストアastoreの説明に誤りがあったので修正しました.修正箇所はこちら:※1 ※2 ※3 ※4

序論#

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;

具体的なバイトコードは次の通りである.バイトコードは本来バイナリファイルであるが,ここではテキスト形式で示している点に注意せよ.

ソースコード (.java)
int i = 10;
i = 50 + x;
バイトコード (.class)
bipush 10
istore_1
bipush 50
iload_2
iadd
istore_1

機械語へ直接翻訳せずバイトコードを介すことで,特定の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 iString sはOK)
  • メソッドの戻り値は上記データ型に加えてvoidも指定可能(void m() {...}はOK)
  • 制御文はif文とwhile文のみ(for文,switch-case文,do-while文などは排除)
  • 例外処理は省略(throw文やtry文は排除)
  • アクセス修飾子はpublicか省略のみ(privateprotectedは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};       // 参照型

上記のソースコードに対しては,以下のようにメモリ上にデータが格納される.青枠のは変数を格納する単一のメモリセルを表している.

参照型r1r2の実態データ(50と"h")はサイズが小さくスタック内に格納も可能である.しかし参照型はr3のように大きなメモリサイズを要することが多い.例えば,クラス内に多数のフィールドを持つ場合,全フィールドについてメモリ領域を確保する必要がある.ヒープ領域には参照型の大きなメモリ領域を確保し,スタック領域にその参照を積み上げて計算を行う.

Quiz

以下の2つのプログラムはいずれもメモリ領域不足のエラーが発生する.どちらがスタックの,どちらがヒープの領域不足かを考えよ.

A
public static void main(String[] args) {
    f();
}
public static void f() {
    f();
}
B
public static void main(String[] args) {
    StringBuffer s = new StringBuffer();  // 書き換え可能な文字列
    while(true) {
        s.append("x");
    }
}

Answer

Aがスタックの,Bがヒープの領域不足になる.

A
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)
    ...

B
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言語で述べていた駆動レコードと同じ概念である.例えば次のソースコードに対しては,

メソッド呼び出し関係
void main() {  f1();  }
void f1()   {  f2();  }
void f2()   {         }

以下のようにスタックフレームが積み上げられる.

メソッドf2()の内部処理はスタック最上部で行い,f2()の処理が終了した際には当該スタックフレームを丸ごと削除する.その後f1()のスタックフレーム上でf1()の処理を継続する.

スタックフレーム内部は次のようなレイアウトで構成されている.

JVMでは次の4つの特殊なレジスタを持つ.スタックフレームのレイアウトと併せてその役割をよく考えよ.

pc:メソッド領域のうち次に実行するバイトコードを指す.SC言語でのpcレジスタ(プログラムカウンタ)と同じ役割である.バイトコード命令を1つ実行すると値が1加算される.if文やwhile文などの制御文の際には,このpcの値が適当なメソッド領域の位置に変更される.

optop:現スタックフレームの最上部を指す.SC言語でのspレジスタ(スタックポインタ)と同じ役割である.optopはスタック最上部のオペランドスタックと呼ばれる領域を指し示している.オペランドスタックはスタック領域における計算作業用の領域であり,push/popの繰り返しによって様々な計算が行われる.例えば4 + 3という式に対しては,オペランドスタック領域上でオペランド43を積み上げ,バイトコード命令iaddによって最終的に7のみが積み上げられる.

vars:現スタックフレームの最下部にある変数領域の起点を指す.SC言語でのbaseレジスタと同様の役割を持つ.スタックフレーム内で保持すべき変数とは,メソッド呼び出し時の引数,およびメソッド内部で利用される局所変数の2種類である.メソッドによって引数と変数の数は異なるため,この変数領域のサイズはメソッドごとに決定される.ただしメソッド実行中にこの領域サイズが変更されることはない(可変長のデータはヒープ側に保持される).

呼び出すメソッドが非staticメソッド(インスタンスメソッド)の場合,インスタンス自身の参照であるthisも積み上げられる.thisは暗黙的な(参照型の)局所変数だといえる.

thisの振る舞い(補足)

thisの振る舞いがわからない場合は以下の例をよく考えること.

thisは暗黙的な変数
class User {
    int age;
    String name;
    public User(int age, String name) {
        this.age = age;    // いつでもthisが利用可能
        this.name = name;  // いつでもthisが利用可能
インスタンス変数とthisは同じ
class Main {
    public static void main(String[] args) {
        X x = new X();
        System.out.println(x);     // Main@279f2327
        x.print();                 //   ↑mainから見たxの参照と,
    }
}
class X {
    public void print() {
        System.out.println(this);  // Main@279f2327
    }                              //   ↑Xから見たthisの参照は同じ
}

frame:呼び出し元スタックフレームに関する制御領域の起点を指す.一例として,メソッドmain()からf()を呼び出す瞬間を考える.このとき,新たに積み上げるf()用の新スタックフレームには,以下のような情報を記憶しておく必要がある.

  • 現在のpcレジスタの値(=呼び出し元main()の次の命令の位置,戻り番地)
  • 現在のvarsレジスタの値(=呼び出し元main()のスタックフレームの形状)
  • 現在のframeレジスタの値(=呼び出し元main()のスタックフレームの形状)
  • 現在のoptopレジスタの値はf()vars-1なので記憶は不要

f()の処理終了後には上記の情報に基づいて,どのバイトコードを継続するか,呼び出し元スタックフレームがどのような形状になっているかを復元する.

Note

スタック領域内のフレームの形状は,レジスタの指定によって成り立っている点を認識せよ.スタックに限らずメモリ領域は単なる直列データ構造であり,基本的にはランダムアクセスが可能である.つまり,スタックの特徴である先入れ後出し(LIFO)以外の自由なアクセスができる.

スタック領域をスタックとして扱うには,スタックの形状,つまり最下部と最上部の位置を意識しながらLIFO形式でメモリを利用する.

再掲:Javaによるスタック実装
public class ArrayStack {
    private int[] elements;
    private int sp;

    public void push(int element) {
        elements[sp++] = element;
    }
    public int pop() {
        return elements[--sp];
    }

なお,現在のoptopレジスタはスタックフレーム内で保持する必要はない.呼び出し元スタックフレームのoptopは呼び出し先スタックフレームのvars-1と一致するためである (varsはスタックの最下部である).

Javaバイトコード#

JavaバイトコードはJVMが実行する命令群である.バイトコードの命令(オペコード)は1バイトで表される.よって256個が定義可能であるが,現在は約200個の命令が定義されている.

JVMがスタックマシンであることから,バイトコードはSCコードとよく似た挙動を取る.当然ながらx64やx86等の実際のCPU命令セットとも共通する点は多い.一例としてJavaソースコードに対応するバイトコードを示す.スタックをイメージすれば,その挙動を理解できるはずである.

ソースコード (.java)
int i = 10;


i = 50 + x;
バイトコード (.class)
bipush 10 // 定数10をプッシュ
istore_1  // 1番(i)へストア

bipush 50 // 定数50をプッシュ
iload_2   // 2番(x)のロード
iadd      // ↑2つを加算
istore_1  // 1番(i)へストア

ロードやストア命令に対しては_0のような接尾辞を取ることがある.例えば,int型変数のロード命令iloadに対してはiload_0iload_3の4つの命令が存在する.iload_0iload 0と機能的に等価である.これらはバイトコードを小さくするために設けられた命令である(1).本ページの本質ではないので注意せよ._を無視して読み進めれば良い.

  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という式があった場合,定数値34をスタック上に積み上げる必要がある.積み上げる値の大きさに応じて,様々なプッシュ命令が設けられている.これは命令の接尾辞_nと同じく,バイトコードに対する最適化が目的である(1).

  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バイトまでである.

  1. 厳密にはヒープ内の定数プール(constant pool)と呼ばれる領域に保持される.これは定数のみが列挙された空間である.

スタックへ積み上げる対象は上記の定数だけでなく,変数の場合がある.例えばプリミティブ型変数の加算式,i + jに対しては変数ijの値を一度ロードしてからスタックへ積み上げる.この場合,iload命令が用いられる.

命令 スタック領域とレジスタに対する操作
iload a optop++;stack[optop]=stack[vars+a];
aload a optop++;stack[optop]=stack[vars+a];
optop++;stack[optop]=heap[stack[vars+a]]; ※1

読み込む変数がプリミティブ型の場合はiload命令が用いられる.プリミティブ型の値はスタックフレームの変数領域内に直接格納されている.具体的な位置はスタック最下部varsレジスタからの相対位置(vars+a)で指定される(stack[vars+a]).

読み込む変数が参照型の場合はaload命令が用いられる.参照型の値はスタックではなくヒープ領域にあり,そのヒープ領域への参照がスタックの変数領域に保持されている(先述のメモリ領域を参照せよ).※2 参照型を読み出す際の振る舞いはiloadの挙動と基本的に同じである.iloadは値そのものが,aloadは値ではなく参照がスタック最上部に積み上げられる. よって,一度スタック領域を見てヒープ内への参照を取りだしてから(stack[vars+a]),実際にヒープ内の値をロードし(heap[...]),スタックへプッシュする(stack[optop]=...).

操作的意味論#

具体的なSJavaソースコードと,それに対応するバイトコードを例示しながらJava言語の操作的意味論を考えていく.基本的な説明の流れは以下に従うので適宜参照せよ.

  1. ある操作X(代入や分岐など)を用いたソースコード例を確認する
  2. そのソースコードに対応するバイトコード列を確認する
  3. バイトコード中の命令がレジスタとスタックをどのように操作しているかを確認する
  4. バイトコード列を一般化し,操作Xの意味論を考える

局所変数への代入#

プリミティブ型の局所変数への代入は以下のようなバイトコードに変換される.

ソースコード
int i;
i = 10;    // byteの範囲内
i = 130;   // shortの範囲内
i = 50000; // それ以上
i = j;     // 変数
バイトコード
bipush 10
istore_1   // 1番地(i)に格納
sipush 130
istore_1   // 1番地(i)に格納
ldc    7
istore_1   // 1番地(i)に格納
iload_1
istore_1   // 1番地(i)に格納

まず右辺値(オペランド)のサイズに応じて,各種スタックへのプッシュ命令(bipushsipushldc)が使い分けされている.右辺値が変数の場合はiloadが用いられる.右辺値をスタックに積み上げた後は,int型の変数領域にストアするistoreによって代入が実現される.

次に参照型の局所変数への代入文を考える.

ソースコード
String s;
s = "a";
s = "hello";
s = s2;
バイトコード
ldc    8   // "a"
astore_1   // 1番地(s)に格納
ldc    10  // "hello"
astore_1   // 1番地(s)に格納
aload_2    // s2
astore_1   // 1番地(s)に格納

定数のロード命令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--; ※3

istoreはスタック最上部の値を直接スタック内に格納している.格納先の位置はvarsレジスタからの相対位置で指定される.

※4 astoreの振る舞いはistoreと同じである.ただしスタックの最上部,および局所領域には常に値自体ではなく,参照が格納されている.この参照はヒープ領域を指し示している. 他方,astoreの格納先はヒープ領域である.格納先の位置は間接アドレスの形式で指定される.まずスタックのvarsレジスタからの相対位置を確認し,そこに記載された参照を辿ることで格納すべきヒープ上のメモリ領域を特定する.

再掲:スタック領域とヒープ領域の中身

代入文の操作的意味論をまとめる.

代入文の操作的意味

ソースコード
i = ■■■;  // プリミティブ型
s = ▲▲▲;  // 参照型
操作的意味
■■■
istore <i>
▲▲▲
astore <s>

<i>は変数ivarsからの相対位置であり,非負整数を取る.

四則演算#

続いて四則演算を用いた式を考える.まず四則演算式のバイトコード例を示す.

ソースコード
i = 50 + x;

i = -x;
バイトコード
bipush 50 // 50をスタックに積む
iload_2   // xを〃
iadd      // スタック最上部2つを加算
istore_1

iload_2   // xをスタックに積む
ineg      // スタック最上部の値を反転
istore_1

加算の際は,演算子の二項をスタックに積み上げ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];

四則演算子の操作的意味

ソースコード
■■■ + ▲▲▲;
操作的意味
■■■
▲▲▲
iadd
ソースコード
- ■■■;
操作的意味
■■■
ineg

《宿題1》#

Homework 約30分 答え

次のSJava言語のプログラムをバイトコード列に変換(コンパイル)せよ.必ず演算子の優先度を考慮すること.

i = 50 + -x * 10;

ヒント:演算子の優先度を考慮する際は,逆ポーランド記法をイメージすると良い.詳細はC言語#SCマシンを参照せよ.

以下の書式をコピーして回答せよ.変数xの相対位置は1とせよ.

bipush 50
???

回答フォーム

比較演算#

続いて比較演算子を考える.2項の比較演算子(==)に対応するバイトコードは次の通りである.

ソースコード
boolean f;
f = (10 == x);
バイトコード
    bipush    10
    iload_1
    if_icmpne L1  // スタック最上部2つが異なるか?
    iconst_1      // no(同じ)なら1をプッシュ
    goto      L2
L1: iconst_0      // yes(異なる)なら0をプッシュ
L2: istore_2

比較演算子==は意味的には逆のif_icmpne(compare not equal)にコンパイルされている点に注意せよ(1).if_icmpneは条件付きジャンプ命令である.if_icmpne Lはスタック最上部の2つを比較し,異なる場合はプログラムカウンタpcLに指定する(処理をLにジャンプさせる).

  1. 比較演算子を逆条件にする理由は,if文において条件成立時の処理と不成立時の処理の順序をバイトコードでも同じにするためだと言われている. 詳細はStackoverflowの記事を参照せよ.

比較演算子==に対しては,まずif_icmpneを用いて比較を行い,値が異なるなら0のプッシュ命令にジャンプさせる.値が同じなら1をプッシュし,gotoによって0のプッシュ処理をスキップする.

結果的に2つの値が同じ時に値1がスタックに積み上げられる.比較演算の結果である真偽値(true/false)は,メモリ上では常に1か0で表されている.1が真,0が偽である.

比較演算子はプログラムカウンタを直接書き換えるgotoif_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;}

比較演算子の操作的意味

ソースコード
■■■ == ▲▲▲;
操作的意味
    ■■■
    ▲▲▲
    if_icmpne L1 // 逆の条件
    iconst_1
    goto      L2
L1: iconst_0
L2:

※ソースコードとバイトコードで比較演算子が反転する点に注意せよ.

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はスタック内の値を直接比較する.よって比較対象が参照型変数の場合は参照の値の比較となる.s1s2は中身は同じ(ヒープ内の値は同じ)だが全く異なるインスタンスである.つまり参照は別物なため,結果はfalseとなる.

ヒープ内を比較する場合は比較専用メソッドequals()を使う.

System.out.println(s1.equals(s2));   // true
補足: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
}
変数s1s2はStringクラスのインスタンスであるのに対し,変数s3s4は文字列リテラルと呼ばれる.StringリテラルはStringプールと呼ばれるヒープ上の特殊な領域に格納される.リテラルはメモリ上の扱いが異なるだけで,Stringインスタンスと同じように扱うことができる(s3.toUpperCases()はOK).

変数s3s4はどちらもStringプール上の"aaa"というメモリ領域を指し示す.つまり参照が同じなため,==演算による比較でtrueが得られる.

文字列を比較する際はequals()を使うことを強く推奨する.Stringインスタンスでも文字列リテラルでも同じ結果となる.

System.out.println(s1.equals(s2));  // true
System.out.println(s3.equals(s4));  // true

制御文#

制御文(条件文と繰り返し文)の操作的意味論を考える.まずif文のバイトコードを確認せよ.

ソースコード
if (10 == x) { ... }
else         { ... }
バイトコード
    bipush 10
    iload_1
    if_icmpne L1 // 2つの値が異なるならL1
    ...          // 真の時の処理
    goto L2
L1: ...          // 偽の時の処理
L2:

10 == xの部分は先の比較演算子と全く同じである.レジスタ最上部の2つの値が異なる場合に,L1にプログラムカウンタを設定する.つまりif文の条件式が成り立たない場合に処理をL1にジャンプさせる.条件式が成り立つときはジャンプせず,そのまま真の場合の処理を行い,最後にgoto L2で偽の処理をスキップする.

制御文の操作的意味

ソースコード
if(■■■) { ●●● }
操作的意味
    ■■■ L1 // if系命令&条件は逆
    ●●●
L1:
ソースコード
if(■■■) { ●●● }
else    { ▲▲▲ }
操作的意味
    ■■■ L1 // if系命令&条件は逆
    ●●●
    goto L2
L1: ▲▲▲
L2:

さらにwhile文の操作的意味論は次の通りである.

制御文の操作的意味

ソースコード
while(■■■) { ●●● }
操作的意味
L2: ■■■ L1 // if系命令&条件は逆
    ●●●
    goto L2
L1:

if文とwhile文の操作的意味論を比較すると,■■■ ●●●の流れが完全に一致している.while文のみgotoによる強制ジャンプによって繰り返しを実現している.

オブジェクトの生成#

オブジェクト指向関連の言語機能を考える.まずオブジェクト生成のバイトコードを以下に示す.

ソースコード
new X();
バイトコード
new X
dup
invokespecial X

ここで最も重要な命令はnewである.new X命令は,オペランドのクラスXの各種フィールド用領域をヒープ上に確保し,そのアドレスをスタックにプッシュする.この操作はC言語におけるmalloc()と同じ挙動である(詳細は命令型言語#動的メモリ確保).

dupはスタック最上部の値をコピーしてさらにスタックに積み上げる(重複,duplicate).これは続くinvokespecialでスタックポップが行われるためであり,深く考える必要はない.

命令 スタック領域とレジスタに対する操作
new X optop++;stack[optop]=malloc(X);
dup optop++;stack[optop]=stack[optop-1];

newdup直後のスタック領域とヒープ領域の状態を以下に示す.重要な点はヒープ側にフィールド変数の領域が確保され,そのヒープへの参照をスタックに積み上げている点である.

最後にinvokespecial命令によってクラスXのコンストラクタを呼び出す.これによってnewで確保したヒープ上のフィールド変数の初期化が行われる.invokespecialの操作的意味論はメソッド呼び出しで説明する.

オブジェクト生成の操作的意味

ソースコード
new X();
操作的意味
new X
dup
invokespecial X
ソースコード
X x = new X();
操作的意味
new X
dup
invokespecial X
astore 5  // 変数x(相対5)へ生成した参照を格納

メソッド呼び出し#

メソッド呼び出しの振る舞いを考える.メソッド呼び出しの瞬間には新たなスタックフレームが生成される.当然スタックへの操作は複雑なため,一つずつ丁寧に確認せよ.

ソースコード
X x = ...;
x.m(10, 50);
バイトコード
...
aload_3          // インスタンスxの参照をプッシュ
bipush 10        // 引数のプッシュ
bipush 50        // 引数のプッシュ
invokevirtual m  // メソッド呼び出し

ここで,メソッドm()は2つの引数を持ち,3つの局所変数を利用しているものとする.

class X {
    void m(int a, int b) { // 2つの引数
        int i, j, k;       // 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つである.

  • 現フレームの退避(=各種レジスタ値の退避)
  • 新フレームの構築(=各種レジスタ値の更新)

まずは,各種レジスタ pcvarsframe の現在値を制御領域に退避させる.この値が呼び出し元関数のスタックフレームの領域となり,return時の旧フレームの復元に利用される.

続いて各種レジスタ pcvarsframe を書き換えて,新たなスタックフレーム領域を作り出す.このとき,メソッド引数として積み上げられた2つの値(5010)が,新たなスタックフレームの引数領域としてそのまま再利用される.また,インスタンスxの参照はthis変数として再利用される.すなわち現フレームのオペランドスタックの一部が,新フレームの変数領域としてオーバーラップすることになる.

最後にプログラムカウンタpcを書き換えて,メソッドm()の命令列へと処理をジャンプさせる.

これまでの流れに従い,メソッド呼び出しの操作的意味論を示しておく.意味論の観点ではソースコードとバイトコードとの対応よりも,バイトコード命令invokevirtualの処理内容が重要である.

メソッド呼び出しの操作的意味

ソースコード
x.m(■■■,●●●);
操作的意味
aload <x>
■■■
●●●
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は引数の数

他にもinvokespecialなどのメソッド呼び出し命令が存在する.

命令 スタック領域とレジスタに対する操作
invokespecial m コンストラクタ用のメソッド呼び出し命令.
基本はinvokevirtualと同じ挙動である.(1)
invokestatic m staticメソッド用のメソッド呼び出し命令.
基本はinvokevirtualと同じ挙動である.
  1. さらにスタック最上部を消費(ポップ)する.よってオブジェクト生成時はinvokespecialの前にはdupが必要である.

メソッドリターン#

最後にメソッドのリターン処理を取り上げる.以下の例は,返り値なしのリターンと返り値ありのリターンである.

ソースコード
return;
バイトコード
return
ソースコード
return 10;
バイトコード
bipush 10
ireturn

返り値がない場合はreturn命令のみが呼び出される.返り値がある場合は,スタック最上部に返り値を積み上げてからireturn命令を呼び出す.

returnireturnの挙動は次の通りである.リターンで行うべき処理は,呼び出し元関数のスタックフレームの再構築である.これは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レジスタの一つ下に設定する.これがリターンで戻るべき呼び出し元フレームの最上部(オペランドスタック)の位置に該当する.続いてpcvarsframeの3つのレジスタを呼び出し元フレームに指定し直す.これらの値は現スタックフレームの制御領域に保持されている.

ireturnも概ねreturn命令と同様であるが,返り値の処理が必要である.返り値処理のために,まずスタック最上部に積まれた値stack[optop]をスタック最下部stack[vars]にコピーする(thisの部分に上書きする).続いてoptopをスタック最下部に指定し直す.これにより,旧スタックフレームのオペランドスタックに返り値が積まれた状態を作り上げる.後はreturnと全く同じ処理であり,各種レジスタを再指定して旧スタックフレームの形状を再構築する.

リターン文の操作的意味

ソースコード
return;
操作的意味
return
  // optop=vars-1;
  // pc   =stack[frame];
  // vars =stack[frame+1];
  // frame=stack[frame+2];
ソースコード
return ■■■;
操作的意味
■■■
ireturn
  // stack[vars]=stack[optop];
  // optop=vars;
  // pc   =stack[frame];
  // vars =stack[frame+1];
  // frame=stack[frame+2];

GC#

JVMではガベージコレクション(GC)と呼ばれる特殊なメモリ管理の仕組みが取り入れられている.GCはヒープ領域上で不要になったメモリ領域を自動的に削除する.

命令型言語#動的メモリ確保でも説明したように,C言語ではmalloc()free()を用いて開発者が明示的にメモリ領域の確保と削除を行う.この動的なメモリの領域としてはヒープ領域が用いられる.Javaも同様の振る舞いを採用しており,new演算子でインスタンスを生成するとヒープ側にメモリ領域が確保される.

再掲:オブジェクト生成時はヒープに領域が確保される

以下のようなJavaソースコードに対しては,100個のStringクラスのインスタンスがヒープ領域に設置される.この場合,局所変数snewの度に右辺値の参照先が書き換わるだけなので,スタック領域のサイズは常に一定である.

複数インスタンスの生成
1
2
3
4
5
6
String s;              // 局所変数は1個だけ(参照を持つ変数が1個だけ)
s = new String("1");   // 新たなインスタンスの生成
s = new String("2");   // 新たなインスタンスの生成
s = new String("3");   // 新たなインスタンスの生成
...
s = new String("100"); // 新たなインスタンスの生成

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()を用いない,全く異なるメモリ管理の方式を採用している.

《宿題2》#

Homework 約30分 答え

次のSJava言語のプログラムをバイトコード列に変換(コンパイル)せよ.

X x = new X();
x.m(i + 10);

以下の書式をコピーして回答せよ.変数xの相対位置は1,iの相対位置は2とせよ.

new X
???

回答フォーム