JVMの発展とスタックベースの命令セットアーキテクチャ

  • 2011年、JDK7がリリースされ、1.7u4から新しいガベージコレクタG1が導入されました(ただしデフォルトではありません)。
  • 2017年、JDK9がリリースされ、G1がデフォルトのGCとなり、CMSに取って代わりました。(多くの企業ではjdk8を使用時にパラメータでG1を指定します)
  • 2018年、JDK11がリリースされ、革新的なZGCが導入され、高性能を実現しています。

仮想マシンの概要

仮想マシンとは、仮想的なコンピュータを指し、一連の仮想コンピュータ命令を実行できます。主にシステム仮想マシンとプログラム仮想マシンの2種類に分類されます。どちらも実行時に仮想マシンが提供するリソースによって制限されます。

  • システム仮想マシン:システム全体をシミュレーションするもの、例えばVirtual BoxVMware
  • プログラム仮想マシン:単一のコンピュータプログラムを実行するために設計されたもの、例えばJava仮想マシン。

Java仮想マシン

Java仮想マシンはバイトコードを実行する仮想コンピュータですが、バイトコードは必ずしもJava言語からコンパイルされたものではありません。しかし、この仮想マシンのルールに準拠する言語を使用すれば、プラットフォーム非依存性、ガベージコレクション、そして信頼性の高いJITコンパイラを享受できます。JVMとハードウェアの間には直接のやり取りはありません。

  • 1回コンパイルして、どこでも実行
  • 自動メモリ管理
  • 自動ガベージコレクション

以下はJavaプラットフォームのドキュメントにあるJavaの概念図の説明です。javacコマンドがJDK内にあることがわかります。つまり、.javaファイルを.classファイルにコンパイルするフロントエンドコンパイラです。このコンパイラはJREには含まれておらず、JREがコンパイル環境を含まないことを示しています。

JREとJDKはどちらもJVM仮想マシンを含んでいます。JREは実行時環境であり、JDKは開発環境を含んでいます。

JDK7におけるJavaファミリーの構成:https://docs.oracle.com/javase/7/docs/ JDK7におけるJavaファミリーの構成:https://docs.oracle.com/javase/8/docs/

JVMの構造

上記の図は主に3つの部分で構成されています:クラスローダー、実行時データ領域、実行エンジン。

クラスローダーは、フロントエンドコンパイラによってコンパイルされたクラスファイル(バイトコードファイル)を、実行時データ領域にロードし、Classオブジェクトを生成します。このプロセスにはロード、リンク、初期化などのステップが含まれます。

実行時領域は主に以下のように分類されます:

  • スレッド固有(各スレッドに1つ):
    • プログラムカウンタ:Program Count Register、スレッド固有、ガベージコレクションなし
    • 仮想マシンスタック:VM Stack、スレッド固有、ガベージコレクションなし
    • ネイティブメソッドスタック:Native Method Stack、スレッド固有、ガベージコレクションなし
  • スレッド共有:
    • メソッド領域:Method AreaHotSpotを例にすると、JDK1.8以降、メタスペースがメソッド領域に取って代わりました。ガベージコレクションがあります。
    • ヒープ:Heap、ガベージコレクションの最も重要な場所。

実行エンジンは主にインタープリタとJITコンパイラ(ホットスポットコードを事前にコンパイルする後端コンパイラ)、ガベージコレクタで構成されます。バイトコードファイルは直接マシンで認識できないため、実行エンジンが必要です。

Javaコードの実行フロー

Javaコードがバイトコードファイルになる過程では、実際に词法解析、構文解析、構文木、意味解析など一連の操作が含まれています。

実行エンジン内にはJITコンパイラがあり、ここで2回目のコンパイルが発生します。ホットスポットコードをマシン命令にコンパイルし、メソッドの次元に基づいてキャッシュします(メソッド領域に配置)。これをCodeCacheと呼びます。

JVMのアーキテクチャモデル

Javaコンパイラは主にスタックベースの命令セットアーキテクチャに基づいています。主な理由は移植性にあると考えられます。JVMはクロスプラットフォームである必要があります。命令セットアーキテクチャには主に2種類があります:

  • スタックベースの命令セットアーキテクチャ:1つのメソッドは1つのスタックへのプッシュ操作に相当し、実行完了はポップ操作に相当します。
  • レジスタベースの命令セットアーキテクチャ

スタックベースの命令セットアーキテクチャの特徴

主な特徴:

  • 設計と実装が簡単で、リソース制約のあるシステム(例:セットトップボックス、小さな玩具)に適しています。
  • レジスタ割り当ての難しさを回避:ゼロアドレス命令方式を使用します。
  • 命令ストリームの大部分はゼロアドレス命令で、実行プロセスは操作スタックに依存し、命令セットがより小さい(ゼロアドレス)、コンパイラの実装が容易です。
  • ハードウェアサポートが不要で、移植性が高く、クロスプラットフォームを実現しやすい。

レジスタベースのアーキテクチャの特徴

  • 典型的な応用はx86のバイナリ命令セット
  • ハードウェアに依存し、移植性が低い
  • パフォーマンスが高く、実行効率が良い
  • 1つの操作を完了するために少ない命令
  • 大部分の場合、レジスタベースのアーキテクチャは一、二、三アドレス命令が主ですが、スタックベースの命令セットはゼロアドレス命令が主です。

説明:ゼロアドレス命令、一アドレス命令、二アドレス命令とは何か?
ゼロアドレス命令は操作コードのみを持ち、操作数を持ちません。このような命令には2つのケースがあります:1つは操作数が不要な場合、もう1つは操作数がデフォルト(暗黙的)である場合で、デフォルトでは操作数がレジスタにあり、命令は直接レジスタにアクセスできます。

  • 三アドレス命令:アドレスフィールドのA1、A2がそれぞれ第一、第二操作数のアドレスを決定し、A3が結果アドレスを決定します。次の命令のアドレスは通常、プログラムカウンタによって順番に与えられます。
  • 二アドレス命令:アドレスフィールドのA1が第一操作数のアドレスを決定し、A2が第二操作数のアドレスと結果アドレスの両方を決定します。
  • 単一アドレス命令:アドレスフィールドのAが第一操作数のアドレスを決定します。第二操作数と操作結果を格納するために特定のレジスタを固定使用します。したがって、それらのアドレスは命令内に暗黙されています。
  • ゼロアドレス命令:スタック型コンピュータでは、操作数は通常下位スタックトップの2つのユニットに格納され、結果はスタックトップに戻されます。アドレスはすべて暗黙的であるため、ほとんどの命令は操作コードのみを持ち、アドレスフィールドはありません。

スタックデータ構造は通常、プッシュとポップしかないため、操作される場所はスタックトップの要素のみで、位置は確定しており、アドレスは不要です。


2+3の操作を実行する場合、スタックベースの計算フロー:

iconst_2 // 定数2をスタックにプッシュ
istore_1 
iconst_3 // 定数3をスタックにプッシュ
istore_2
iload_1
iload_2
iadd  // 定数2,3をスタックからポップし、加算を実行
istore_0  // 結果5をスタックにプッシュ

レジスタベースの計算フロー:

mov eax,2   // eaxレジスタの値を2に設定
add eax,3   // eaxレジスタの値に3を加算

上記の例からわかるように、スタックベースの命令はより小さいですが、レジスタベースの命令はより少ないです。

簡単なプログラムで確認してみましょう:

public class StackCalculationDemo {
    public static void main(String[] args) {
        int result = 2 + 3;
    }
}

コンパイル後、classディレクトリに移動し、コマンドで逆コンパイル:

javap -v StackCalculationDemo.class

バイトコードのモジュールを見ると、先頭にiconst_5があることがわかります。52+3の結果で、つまりコンパイル時に2+3が直接5に変換され、実行時に計算されないことがわかります。これは23が両方とも定数であるためです。

この現象をコンパイル時の定数畳み込みと呼びます。

しかし、次のようなコードの場合はどうなるでしょうか?

        int a = 2;
        int b = 3;
        int c = a + b;

逆コンパイルされた命令:

constconstant(定数)、storestorage(ストレージ)を意味します。

 stack=2, locals=4, args_size=1
         0: iconst_2  // 2は定数
         1: istore_1  // 2を1番の操作数スタックにロード
         2: iconst_3  // 3は定数
         3: istore_2  // 3を2番の操作数スタックにロード
         4: iload_1   // 1番の操作数スタックから取り出し、ロード
         5: iload_2   // 2番の操作数スタックから取り出し、ロード
         6: iadd      // 両者を加算
         7: istore_3  // 結果をインデックス3番の操作数スタックに格納
         8: return

つまり、スタックアーキテクチャのJVMでは、上記の変数加算計算を完了するために8つの命令が必要です。

スタックアーキテクチャのまとめ

クロスプラットフォームの特性により、Java命令はスタックに基づいて設計されています。異なるCPUアーキテクチャがあるため、利点はクロスプラットフォーム、命令セットが小さく、コンパイラの実装が容易です。欠点はパフォーマンスの低下で、同じ機能を実現するためにより多くの命令が必要になります。

タグ: JVM Java ガベージコレクション スタックベース命令セット クロスプラットフォーム

7月26日 21:24 投稿