はじめに
- 韓国人として、日本語とコンピュータの勉強を同時に行うために、ここに文章を書いています
- 翻訳ツールの助けを借りて書いた文章なので、誤りがあるかもしれません
私は最近、Javaの勉強会を開催することになり、それを準備しながら学んだJavaのコンパイルとJVMについて説明する文章を書いてみました。
量が多い関係で、2回に分けてコンパイル過程をまとめようと思います。
順序
- JVM
- JVMの特徴
- JDK? JRE?
- フロントエンドコンパイル過程
- バイトコードとは
JVMとは
-
JVM(Java Virtual Machine)は
Javaプログラムを実行するための仮想マシンです。Javaはプラットフォームの独立性を目指して設計されたため、一度作成されたコードは、JVMがインストールされている様々なオペレーティングシステムで実行可能です。JVMは、Javaソースコードをバイトコードに変換し、各オペレーティングシステム上で実行できるようにする役割を担っています。
Java Virtual MachineはJavaプラットフォームの基盤です。ハードウェアおよびオペレーティングシステムからの独立性、コンパイルされたコードの小さなサイズ、ユーザーを悪意あるプログラムから保護する機能を担う技術の構成要素です。Java Virtual MachineはJavaプログラミング言語を何も知らず、特定のバイナリ形式であるクラスファイル形式のみを理解します。クラスファイルにはJava Virtual Machine命令(またはバイトコード)、シンボルテーブル、およびその他の補助情報が含まれています。 - Java Oracleドキュメント
VM(Virtual Machine)とは?(仮想化とは?)
VM(Virtual Machine)は、物理的なハードウェア上でソフトウェア的に独立したコンピュータ環境を提供する仮想化技術です。これにより、1つの物理的システム上で複数のオペレーティングシステムやアプリケーションを実行することが可能になります。
VMは大きく分けて2種類があります。
-
システム仮想マシン(System VM):物理的なハードウェアから独立して複数のオペレーティングシステムを実行することができます。各仮想マシンは独自のオペレーティングシステムを持ち、物理的なリソースを共有しつつも、お互いに独立した環境を維持します。代表的なプラットフォームにはVMwareやVirtualBoxがあります
-
プロセス仮想マシン(Process VM):特定のアプリケーションやプロセスを実行するための別の環境を提供します。例えば、JVM(Java Virtual Machine) は、Javaプログラムを実行するための仮想マシンです。この方法により、特定のプロセスが様々なオペレーティングシステム上で一貫して実行されることを可能にします
JVMの特徴
- Write once, run anywhere (OS独立性の提供)
Javaは、セットトップボックスや携帯機器など、さまざまなコンシューマーデバイスで同じコードを実行し、配布できるように作られた言語です。
かつてはソフトウェアが特定のオペレーティングシステムやハードウェアに依存していましたが、これを克服するためにJavaは、一度作成すればどこでも実行できる方法を追求しました。
CやC++のようなOSに依存する言語は、OSごとに異なるコンパイラやライブラリを使用します。異なる環境(例: mac、Windowsなど)でコードを実行するには、OSに合わせて再コンパイルする必要があります。
スレッド処理やGUI(グラフィカルユーザーインターフェース)などは、それぞれのオペレーティングシステムの内部動作によって異なる方式で実装されているため、同じコードでも異なるオペレーティングシステム上では正しく動作しない可能性があります。
一方、JavaはJVMを通じてOSと独立した動作を提供します。Javaプログラムはコンパイル後にバイトコードとなり、JVMをサポートするすべてのOSで同じように実行されます。開発者は一度コードを書けば、再コンパイルや修正を行うことなく、どのOSでも同じように実行できます。これがJavaが掲げるWORA(Write Once, Run Anywhere)の理念です。
JavaコードはOSに依存しませんが、JVMはOSに依存します。
JVMはOSごとにバイトコードをOSが理解できる機械語に変換します。つまり、Javaコード自体はOSに依存せずどこでも実行できますが、JVM自体はOSに依存しているため、OSごとに異なるJVMが必要です。
-
ガベージコレクション
CやC++のような言語では、開発者がオブジェクトの生成から消滅までの全ての過程を管理しなければなりません。つまり、メモリを割り当てた後、使わなくなったメモリを開発者が手動で解放する必要がありました。
一方、Javaではガベージコレクションという自動メモリ管理機能が提供されています。Javaのガベージコレクターは、もはや参照されなくなったオブジェクトを自動的に検出し、メモリから解放します。 -
スタックベースの仮想マシン
レジスタベースのVMは、スタックではなく仮想レジスタを使って演算を行います。この方式は、CPUがレジスタを使って処理する方法に似ており、命令を処理する際に少ないメモリアクセスと演算で済むため、命令数が減り、実行速度が向上します。
一方、スタックベースのVMは、オペランドをスタックに保存し、スタック上で命令を処理します。この方式はハードウェアレジスタに依存しないため、様々なプラットフォームで実行できるプラットフォーム独立性と移植性を提供します。JavaのJVMがこのスタックベースの構造を選んだのも、まさにこの移植性とプラットフォーム独立性を考慮したためです。スタックベースの構造は、どのハードウェア上でも一貫した実行環境を保証し、様々なオペレーティングシステムやアーキテクチャで同じJavaバイトコードを解釈して実行できるようにします。
したがって、JVMは性能よりもコードの移植性やプラットフォームの独立性を優先し、スタックベースの構造を採用しているのです。
JDK? JRE?
- では、JDKとJREとは何でしょうか?
-
JDK (Java Development Kit)
JDKは、開発と実行の両方を含むJava開発ツールです。
JDKに含まれる主なコンポーネント
RE (Java Runtime Environment):Javaアプリケーションを実行するために必要なランタイム環境。
- javac (Java Compiler):Javaソースコードをバイトコードにコンパイルするコンパイラ
- Java Debugger (jdb):Javaプログラムをデバッグできるツール
- JavaDoc:コメントを基にJava APIドキュメントを生成するツール
- jar (Java ARchive):複数のJavaクラスを1つのファイルにまとめるユーティリティ。(ZIP形式に基づく圧縮ファイル)
- java (Java Application Launcher):コンパイルされたバイトコードを実行するランチャー
-
JRE (Java Runtime Environment)
JREは実行環境だけを提供し、Javaプログラムを実行できるようにします。
JREに含まれる主なコンポーネント
Class Libraries:標準のJavaクラスライブラリ(Java API)。
Runtime Libraries:Javaアプリケーションの実行中に必要なライブラリ。
そして、JVMは当然Javaの実行環境ですので、JDKにもJREにも内包されています。
JVMの種類
JVMは「Java Virtual Machine Specification」という公式仕様に基づいて動作します。この仕様では、JVMの基本機能、メモリ管理、命令セットなどが定義されており、これを基にさまざまなJVMの実装が作られています。すべてのJVMはこの仕様を遵守しているため、どの環境でも一貫した実行が保証されます。
- JDKの一部が商用化され有料となったため、各ベンダーは目的や使用方法に応じて、さまざまなJVMディストリビューションを市場に提供しています
HotSpot VM
- 標準JVMとして最も広く使用され、歴史が長い
- 複数のガベージコレクターを含む
- Javaクラスローダーを提供
- クライアント(C1)とサーバー(C2)のJITコンパイラを内蔵
- 多くの利用者がいるため、非常に安定しており、コミュニティやエコシステムも充実している
GraalVM
-
Javaで作られたJITコンパイラを内蔵
C++で書かれた従来のJITコンパイラ(HotSpotのC1/C2)を補完または置き換える方向で、Javaで記述されたため、機能拡張や保守が容易です。 -
AOT(Ahead Of Time)コンパイルのJavaネイティブイメージコンパイラを内蔵
Javaのコンパイル過程
Javaのコンパイル過程は大きく2つに分かれます。
- フロントエンド:javacを使ってJavaソースコードをコンパイルし、バイトコードを生成する部分
- バックエンド:JVMで動作するJIT(Just-In-Time)コンパイラやAOT(Ahead-Of-Time)コンパイラによってバイトコードが実行される部分
- AOTコンパイルとインタプリタは、便宜上省略しています。
Javaはどこでも実行されるべきです
javac -source 1.8 MyClass.java
-sourceオプションは、ソースコードの文法バージョンを指定します
javac -target 1.8 MyClass.java
-targetオプションは、コンパイルされたクラスファイルが互換性を持つJVMバージョンを指定します。
これにより、特定のJavaバージョンの文法を使用しながらも、そのバージョンのJVMで実行可能なバイトコードとしてコンパイルすることができます。
コンパイル方法
javacコンパイラは次のようなコマンドで使用できます。
javac [オプション] <ソースファイル>
例えば、以下のコマンドを実行すると、
javac Main.java
このように、.class(バイトコードファイル)が正しく生成されていることが確認できます。
javac
javacコマンドを入力すると、詳しいヘルプを確認することができます。
さまざまなオプションや説明については、こちらを参照してください
https://docs.oracle.com/en/java/javase/17/docs/specs/man/javac.html
フロントエンドコンパイル(JAVACコンパイル過程)
- javacコンパイラのコードは.javaファイルとして記述されています
- これはJavaの「Write Once, Run Anywhere」の哲学を反映しています
コンパイル前の過程
コンパイル前のロジックは次の通りです。
- コマンドラインに javac {オプション, ファイルパス} を入力すると、javac 実行ファイルが呼び出されます
このファイルはJVMを実行し、内部的に Main.compile() メソッドが呼び出されます。
- コンテキストを生成します。
# 2. コンテキストを生成します。
このメソッドが呼び出されると、コンパイルのための本格的な設定が開始されると考えてよいでしょう。
Mainクラスの compile() メソッドの説明
- このMainクラスのcompile()メソッドは、全体のコンパイルプロセスを管理するロジックです
- コマンドラインから取得した命令に基づいて、どのようにコンパイルを行うかを決定し、実際にコンパイル作業を行う部分を呼び出します
- また、コンパイルがどのように行われたか、最終的な結果を返す役割も果たします
この段階は、コマンドライン引数を処理する過程です。
javac {argv}
ここで、もし argv.length() が0であれば、-help オプションを選択したのと同じ動作をします。
try {
Option.HELP.process(h, "-help");
}
Option クラスを詳しく見てみると、各オプションが定義されている enum であることがわかります。
下の方にスクロールして、Option.Help の定義を確認すると,プロセスがこのように構成されており
次のような出力が表示されるようになっていることが確認できます。
javac
したがって、次のように何も入力せずに実行しても javac --help と同じように動作する理由がここにあります。
さて、本題に戻って、compile()メソッドについて再度見てみましょう。
// prefix argv with contents of environment variable and expand @-files
Iterable<String> allArgs;
try {
allArgs = CommandLine.parse(ENV_OPT_NAME, List.from(argv));
} catch (UnmatchedQuote ex) {
reportDiag(Errors.UnmatchedQuote(ex.variableName));
return Result.CMDERR;
} catch (FileNotFoundException | NoSuchFileException e) {
reportHelper(Errors.FileNotFound(e.getMessage()));
return Result.SYSERR;
} catch (IOException ex) {
log.printLines(PrefixKind.JAVAC, "msg.io");
ex.printStackTrace(log.getWriter(WriterKind.NOTICE));
return Result.SYSERR;
}
そのすぐ下に、次のようなコードがあります。
Iterable<String> allArgs;
try {
allArgs = CommandLine.parse(ENV_OPT_NAME, List.from(argv));
}
コマンドライン引数をパースして、必要なオプションやファイル名を抽出し、環境変数や @-file のような特殊な引数も処理します。
結果的に、コンパイルに使用するすべての引数が Iterable 形式で整理されます。
Arguments args = Arguments.instance(context);
args.init(ownName, allArgs);
コマンドライン引数は、コンパイラ(context)の Arguments オブジェクトに保存され、その後のコンパイル過程で参照および処理されます。
Options options = Options.instance(context);
先に述べたオブジェクトは、context に保存されて管理されます。
javac -classpath /libs -source 1.8 MyProgram.java
次のようなコマンドがある場合
{
"classpath" -> "/libs",
"source" -> "1.8"
}
これが context の HashMap にキー/バリューの形式で保存されます。
// init CacheFSInfo
// allow System property in following line as a Mustang legacy
boolean batchMode = (options.isUnset("nonBatchMode")
&& System.getProperty("nonBatchMode") == null);
if (batchMode)
CacheFSInfo.preRegister(context);
基本的に、該当コンパイラはバッチモードで動作します。
CacheFSInfo.preRegister(context):バッチモードが有効であれば、ファイルシステム情報が キャッシュ(CacheFSInfo)に登録されます。これにより、同じファイルを繰り返し参照する際に、パフォーマンスの最適化が可能になります。
boolean ok = true;
ok という変数でコンパイルの状態を表します。
その後、コンパイルのロジックでエラーが発生すると、この変数が false に変更されます。
ok == true が続いている場合は、コンパイルが正常に進行していることを意味します。
ファイルマネージャを初期化する段階です。ファイルマネージャは、コンパイラがソースファイルを読み込み、クラスファイルを書き込む役割を果たします。この過程では、ファイルマネージャが正しく設定されているかを確認し、必要なオプションを処理してファイルシステムとの接続を準備します。
fileManager = context.get(JavaFileManager.class);
fileManager を context から取得します。
fileManager は、コンパイラがソースファイルやクラスファイルなどを読み書きする際に使用されるオブジェクトです。
JavaFileManager undel = fileManager instanceof DelegatingJavaFileManager delegatingJavaFileManager ?
delegatingJavaFileManager.getBaseFileManager() : fileManager;
どの JavaFileManager を使用するかを定義します。
DelegatingJavaFileManager は、ファイルの入出力を他のマネージャーに委譲するオブジェクトです。
もし fileManager が DelegatingJavaFileManager であれば、実際の作業を処理する基本ファイルマネージャー(BaseFileManager) を取得します。
if (undel instanceof BaseFileManager baseFileManager) {
baseFileManager.setContext(context);
ok &= baseFileManager.handleOptions(args.getDeferredFileManagerOptions());
}
これは、ファイルシステムがどのように動作するべきかを設定する過程です。
baseFileManager であれば、それを再度 context に設定します。
その他のコマンドラインで指定されたオプションの設定も行います。
その中の一つを見てみると、
// init multi-release jar handling
if (fileManager.isSupportedOption(Option.MULTIRELEASE.primaryName) == 1) {
Target target = Target.instance(context);
List<String> list = List.of(target.multiReleaseValue());
fileManager.handleOption(Option.MULTIRELEASE.primaryName, list.iterator());
}
マルチリリースJAR(Multi-Release JAR):マルチリリースJARファイルは、JARファイル内に複数のJavaバージョンに対応したクラスファイルを含むファイルです。つまり、Java 8用のクラスとJava 9以降で使用するクラスを同時に含むことができ、JARファイルが複数のバージョンに対応して動作するようにすることができます。
このようにコマンドの設定が完了したら、本格的にjavacのコンパイルを開始します。
本格的なJavaCompilerのcompile()
実際のコンパイラの核心ロジックです。
そして、実際にコンパイル処理を実行します。
一言で言えば、JAVACのコンパイルプロセスを担当している部分です。
コンパイル概要
ソースファイルをクラスファイルにコンパイルするプロセスは簡単ではなく、一般的に3つの段階に分けることができます。ソースファイルの各部分は、必要に応じて異なる速度でこのプロセスを進行することがあります。
javacの流れ
このプロセスはJavaCompilerクラスによって処理されます。
- コマンドラインで指定されたすべてのソースファイルが読み込まれ、構文ツリーにパースされ、すべての外部から見える定義がコンパイラーのシンボルテーブルに入力されます
- 適切なアノテーションプロセッサがすべて呼び出されます。もしアノテーションプロセッサが新しいソースファイルやクラスファイルを生成する場合、新しいファイルが作成されなくなるまでコンパイルが再起動されます
- 最後に、パーサーによって生成された構文ツリーが分析され、クラスファイルに変換されます。分析の過程で、追加のクラスへの参照が見つかることがあります。コンパイラーはソースパスやクラスパスでこれらのクラスを確認し、ソースパスで見つかる場合、それらのファイルもコンパイルされますが、これらはアノテーション処理の対象にはなりません
もう少し簡単で詳しくまとめると、
-
準備: プラグインアノテーションの初期化
-
構文解析とシンボルテーブルの生成
- 字句および構文解析: ソースコードをトークン化して抽象構文木を作成
- シンボルテーブルの生成: シンボルのアドレスと情報を生成
-
プラグインアノテーション処理器によるアノテーション処理
-
意味解析とバイトコード生成
- 特定のチェック: 文法の静的情報を確認
- データフローおよび制御フロー解析: プログラムの動的実行を確認
- シンタックスシュガーの除去: 簡略化された文法を元の形式に戻す
- バイトコード生成: バイトコードに変換(バックエンド)
という流れになります。
コードを確認しながら進めます。
ここで重要な部分は
1. (準備)プラグインアノテーションの初期化
if (processors != null && processors.iterator().hasNext())
explicitAnnotationProcessingRequested = true;
フラグをtrueに設定して、アノテーション処理をリクエストしたことを示します。
2. 構文解析とシンボルテーブルの生成
字句解析および構文木の生成
- parseFiles()
parsefiles()メソッドは、コンパイル段階のうち字句解析と構文解析を処理します。
字句解析とは?
- ソースコードの文字ストリームをトークンの集合に変換するプロセスを指します。
プログラムを記述する際、最小の単位は文字ですが、コンパイル時にはキーワード、変数、リテラル、演算子などが最小の単位であるトークンとなります。
例えば
int a = 0;
が宣言されている場合、int、a、=、0、;がそれぞれ1つのトークンとなり、これ以上分割されることはありません。
Parserの実装クラスはJavacParserであり、内部的にLexerを使用します。
Lexerの実装クラスであるScannerを確認すると、
字句解析器は、ASCII文字とUnicodeエスケープからなる入力ストリームをトークンの連続にマッピングする。
コメントには以下のように記載されています。
Scannerは、Javaソースファイルを字句解析し、構文解析が可能なようにトークンを生成・管理する役割を果たしていると要約できそうです。
これで字句解析のプロセスが完了しました。
構文解析とは?
構文解析は、字句解析で生成されたトークンを使用してソースコードの構文構造を把握し、それをツリー形式で表現する段階です。各ノードはプログラムコードの構文構造を示します。
-
構文ルールの確認:字句解析で得られたトークンがJava言語の**構文ルール(syntax rules)**に従って正しく配置されているか確認します。例えば、Javaの条件文(if-else)、ループ文(for, while)、クラス宣言などが正しく使用されているかを検査します
-
抽象構文木(AST)の生成:構文ルールに従ったトークンを**抽象構文木(Abstract Syntax Tree, AST)**に変換します。このツリーはソースコードの構造的な表現であり、プログラムの各要素(変数、クラス、メソッドなど)がツリーのノードとして表現されます
-
最上位のツリーは、ソースファイル全体を表すJCCompilationUnitであり、その下にクラス宣言、メソッド宣言、式、ブロックなどのツリーが構成されます
-
構文ツリーの構築:生成された構文ツリーはツリー構造として組織されます。各構文要素(クラス、メソッド、変数など)はツリーの**ノード(node)**となり、親子関係を持ちながらソースコードの構造を表します
ここで
次のように処理されますが、あまりにも複雑で分析を諦めました。
JCCompilationUnitにJCTreeという表現形式で保存されます。
この過程を経て、開発者が作成したJavaソースコードはこれ以上使用されません。
今後は、この構文解析の過程で生成されたツリーを使用することになります。
シンボルテーブルの生成
- enterTree()メソッドがシンボルテーブルの生成を担当します。
**シンボルテーブル(Symbol Table)**とは、コンパイラがプログラムを解析および翻訳する際に、変数、関数、クラス、メソッドなどの識別子(identifier)**に関する情報を保存・管理するデータ構造です。これはコンパイル過程で非常に重要な役割を果たし、プログラム内の識別子に関するメタデータ(型、スコープ、宣言位置など)を保存する、いわば辞書のような役割を担っています。
- シンボルアドレスとシンボル情報の集合 = シンボルテーブル(キーとバリューのペアであるハッシュテーブル)
- 実際のシンボルテーブルは、必ずしもハッシュテーブルとは限らず、スタックやツリー形式など、さまざまな実装方式があります
- シンボルテーブルに登録された情報は、コンパイルの各プロセスで使用されます(例:意味解析の過程で意味を確認し、名前が宣言と一致しているかを確認するなど)
- 最終的な目的コードを生成する段階でも、シンボルテーブルはアドレスの割り当てに利用されます
3. アノテーション処理
- 特定のアノテーションは、コンパイル時に事前に処理されることがあり、そのためフロントエンドコンパイラの動作に影響を与えます
- 上記の図のように、アノテーション処理中に構文ツリーが修正される場合、コンパイラは再び構文解析の段階に戻り、それが完了するまでこのプロセスを繰り返します
- 1回の繰り返しを「ラウンド(round)」と呼びます
- コンパイラのアノテーション処理APIを使用すると、開発者のコードがコンパイラの動作に影響を与えることがあります
- 構文ツリーのすべての要素や、コードのコメントなどにもアクセスできます
次のように繰り返し処理が行われます。
アノテーション処理に関する部分は、次回の新しい投稿でさらに詳しく書く予定です。
4. 意味解析
意味解析の段階は、大きく特性検査とデータ・制御フローに分けられます。
- 論理的なエラーがないか分析します(型検査、変数の有効性確認、シンボルテーブルの生成など)
- 意味解析の主な目的は、構造的に正しいコードが文脈的にも正しいかを確認することです
- 抽象構文木(AST)をコンパイラが取得しても、それが文脈的に正しいことは保証されません
特性検査
int a = 2;
boolean b = true;
char c = 1;
次のようにコードが記述されている場合、以下のように代入コードが発生します。
int d = a + c ;
int d = b + c ;
char d = a + c ;
3つの代入コードのうち、抽象構文木はすべて正しく生成されますが、Javaで許可される正しい代入は最初のint d = a + c;だけです。残りの2つはJavaで許可されないため、コンパイルできません。
IDEで次の写真のように赤い下線で警告される場合は、意味解析の段階で発生したエラーを警告しているのです。
また、特性検査の段階では**定数折りたたみ(constant folding)**というステップが含まれます。
int a = 1 + 2;
このようなコードがある場合、抽象構文木では1、2、+が存在します。
しかし、定数折りたたみの段階を経るとリテラル3だけが残ります。
int a = 1 + 2;
int b = 3;
この2つの宣言を処理する速度は同じです。
次のクラスでは、コメントで型チェックと定数折りたたみについて明示しており、該当クラスのメソッド内で定数折りたたみの方法を確認できます。
このcheckクラスでは、型検査やアクセス修飾子が適切に適用されているかどうかが検査されます。
データフローと制御フロー解析
flow()メソッドでは、データフローと制御フローの解析を行います。このメソッドのコメントには次のように記載されています。
データフローのチェックを属性付き構文木に対して行います。これには、確定代入のチェックや到達不可能な文のチェックが含まれます。エラーが発生した場合は、空のリストが返されます。
返却値:属性付き構文木のリスト
このようにデータフローに関するチェックを行うと明示しています。
その後、詳細なコードを確認すると
属性付き構文木に対してデータフローのチェックを行います。
とコメントで再度明示しています。
env.toplevel.sourcefile);
try {
make.at(Position.FIRSTPOS);
TreeMaker localMake = make.forToplevel(env.toplevel);
flow.analyzeTree(env, localMake);
compileStates.put(env, CompileState.FLOW);
...
このようにflow.analyzeTree()メソッドで詳細な実装が行われていますが、そのページが非常に大きいため、IDEで読み込むことができませんでした。
もし該当のFlowクラスにアクセスできない場合は、
GitHubで直接開いて確認することをお勧めします。
コメントが非常に長いのですが、一度読んでみると
/** This pass implements dataflow analysis for Java programs though
* different AST visitor steps. Liveness analysis (see AliveAnalyzer) checks that
* every statement is reachable. Exception analysis (see FlowAnalyzer) ensures that
* every checked exception that is thrown is declared or caught. Definite assignment analysis
* (see AssignAnalyzer) ensures that each variable is assigned when used. Definite
* unassignment analysis (see AssignAnalyzer) in ensures that no final variable
* is assigned more than once. Finally, local variable capture analysis (see CaptureAnalyzer)
* determines that local variables accessed within the scope of an inner class/lambda
* are either final or effectively-final.
*
* <p>The JLS has a number of problems in the
* specification of these flow analysis problems. This implementation
* attempts to address those issues.
*
* <p>First, there is no accommodation for a finally clause that cannot
* complete normally. For liveness analysis, an intervening finally
* clause can cause a break, continue, or return not to reach its
* target. For exception analysis, an intervening finally clause can
* cause any exception to be "caught". For DA/DU analysis, the finally
* clause can prevent a transfer of control from propagating DA/DU
* state to the target. In addition, code in the finally clause can
* affect the DA/DU status of variables.
*
* <p>For try statements, we introduce the idea of a variable being
* definitely unassigned "everywhere" in a block. A variable V is
* "unassigned everywhere" in a block iff it is unassigned at the
* beginning of the block and there is no reachable assignment to V
* in the block. An assignment V=e is reachable iff V is not DA
* after e. Then we can say that V is DU at the beginning of the
* catch block iff V is DU everywhere in the try block. Similarly, V
* is DU at the beginning of the finally block iff V is DU everywhere
* in the try block and in every catch block. Specifically, the
* following bullet is added to 16.2.2
* <pre>
* V is <em>unassigned everywhere</em> in a block if it is
* unassigned before the block and there is no reachable
* assignment to V within the block.
* </pre>
* <p>In 16.2.15, the third bullet (and all of its sub-bullets) for all
* try blocks is changed to
* <pre>
* V is definitely unassigned before a catch block iff V is
* definitely unassigned everywhere in the try block.
* </pre>
* <p>The last bullet (and all of its sub-bullets) for try blocks that
* have a finally block is changed to
* <pre>
* V is definitely unassigned before the finally block iff
* V is definitely unassigned everywhere in the try block
* and everywhere in each catch block of the try statement.
* </pre>
* <p>In addition,
* <pre>
* V is definitely assigned at the end of a constructor iff
* V is definitely assigned after the block that is the body
* of the constructor and V is definitely assigned at every
* return that can return from the constructor.
* </pre>
* <p>In addition, each continue statement with the loop as its target
* is treated as a jump to the end of the loop body, and "intervening"
* finally clauses are treated as follows: V is DA "due to the
* continue" iff V is DA before the continue statement or V is DA at
* the end of any intervening finally block. V is DU "due to the
* continue" iff any intervening finally cannot complete normally or V
* is DU at the end of every intervening finally block. This "due to
* the continue" concept is then used in the spec for the loops.
*
* <p>Similarly, break statements must consider intervening finally
* blocks. For liveness analysis, a break statement for which any
* intervening finally cannot complete normally is not considered to
* cause the target statement to be able to complete normally. Then
* we say V is DA "due to the break" iff V is DA before the break or
* V is DA at the end of any intervening finally block. V is DU "due
* to the break" iff any intervening finally cannot complete normally
* or V is DU at the break and at the end of every intervening
* finally block. (I suspect this latter condition can be
* simplified.) This "due to the break" is then used in the spec for
* all statements that can be "broken".
*
* <p>The return statement is treated similarly. V is DA "due to a
* return statement" iff V is DA before the return statement or V is
* DA at the end of any intervening finally block. Note that we
* don't have to worry about the return expression because this
* concept is only used for construcrors.
*
* <p>There is no spec in the JLS for when a variable is definitely
* assigned at the end of a constructor, which is needed for final
* fields (8.3.1.2). We implement the rule that V is DA at the end
* of the constructor iff it is DA and the end of the body of the
* constructor and V is DA "due to" every return of the constructor.
*
* <p>Intervening finally blocks similarly affect exception analysis. An
* intervening finally that cannot complete normally allows us to ignore
* an otherwise uncaught exception.
*
* <p>To implement the semantics of intervening finally clauses, all
* nonlocal transfers (break, continue, return, throw, method call that
* can throw a checked exception, and a constructor invocation that can
* thrown a checked exception) are recorded in a queue, and removed
* from the queue when we complete processing the target of the
* nonlocal transfer. This allows us to modify the queue in accordance
* with the above rules when we encounter a finally clause. The only
* exception to this [no pun intended] is that checked exceptions that
* are known to be caught or declared to be caught in the enclosing
* method are not recorded in the queue, but instead are recorded in a
* global variable "{@code Set<Type> thrown}" that records the type of all
* exceptions that can be thrown.
*
* <p>Other minor issues the treatment of members of other classes
* (always considered DA except that within an anonymous class
* constructor, where DA status from the enclosing scope is
* preserved), treatment of the case expression (V is DA before the
* case expression iff V is DA after the switch expression),
* treatment of variables declared in a switch block (the implied
* DA/DU status after the switch expression is DU and not DA for
* variables defined in a switch block), the treatment of boolean ?:
* expressions (The JLS rules only handle b and c non-boolean; the
* new rule is that if b and c are boolean valued, then V is
* (un)assigned after a?b:c when true/false iff V is (un)assigned
* after b when true/false and V is (un)assigned after c when
* true/false).
*
* <p>There is the remaining question of what syntactic forms constitute a
* reference to a variable. It is conventional to allow this.x on the
* left-hand-side to initialize a final instance field named x, yet
* this.x isn't considered a "use" when appearing on a right-hand-side
* in most implementations. Should parentheses affect what is
* considered a variable reference? The simplest rule would be to
* allow unqualified forms only, parentheses optional, and phase out
* support for assigning to a final field via this.x.
*
* <p><b>This is NOT part of any supported API.
* If you write code that depends on this, you do so at your own risk.
* This code and its internal interfaces are subject to change or
* deletion without notice.</b>
*/
次のように非常に長く書かれています。
ここで、コメントの核心部分について説明すると、一番重要な内容は最初の段落に記載されています。
この段階では、Javaプログラムに対するデータフロー解析を、さまざまなAST(抽象構文木)の訪問段階を通じて実装します。到達可能性解析(AliveAnalyzer参照)は、すべての文が到達可能かどうかを確認します。例外解析(FlowAnalyzer参照)は、発生したすべてのチェック例外が宣言されているか、または捕捉されているかを確認します。確定代入解析(AssignAnalyzer参照)は、各変数が使用される前に代入されているかを確認します。確定未代入解析(AssignAnalyzer参照)は、final変数が一度以上代入されないことを保証します。最後に、ローカル変数キャプチャ解析(CaptureAnalyzer参照)は、内部クラスまたはラムダ内でアクセスされるローカル変数がfinalまたは実質的にfinalであることを確認します。
簡単にまとめると、
#1. プログラムの各文が実行可能か(つまり、そのコードに実際に到達できるか)を確認します
public int calculate(int num) {
if (num > 0) {
return num; // ここでは値が返されます。
}
// しかし、ここでは値が返されずに終了します。
}
このような文では、returnが必要ですが、それが記述されていないためエラーが発生します。
#2. 例外が正しく処理されているかを確認します
public void readFile() throws IOException {
// 例外を処理しないとコンパイルエラーが発生します。
BufferedReader br = new BufferedReader(new FileReader("file.txt"));
}
#3.変数が使用される前に正しく初期化されているかを確認します
public static void main(String[] args) {
int x;
// x = 10; // コメントを外すと正しく初期化されます。
System.out.println(x); // エラー発生: xが初期化されていません。
}
- finalで宣言された変数に対して、一度だけ値が割り当てられているかを確認します
public static void main(String[] args) {
final int x = 10;
x = 20; // エラー発生: final変数には再度割り当てできません
System.out.println(x);
}
#5. 内部クラスやラムダ式で使用されるローカル変数がfinalであるかを確認します
public static void main(String[] args) {
int x = 10;
x = 20; // ラムダ式でエラー発生
Runnable r = () -> {
System.out.println(x); // ラムダ式で使用される変数xは実質的にfinalでなければなりません。
};
r.run();
}
このような内容が、
次のように親切にコメントで説明されていますので、さらに詳しく知りたい方はぜひ読んでみてください。(私は途中で読むのを諦めました。)
ここまでの内容をまとめると、
データおよび制御フロー解析とは、正しいデータが使用され、それが正しく制御されているかを検査する段階と考えれば良いでしょう。
簡略化された文法の除去
Javaでは、開発者の利便性のために、final、forEach、try-with-resources、var、lambdaなどの簡略化された文法が提供されています。
これらは、コンパイル時に元の形に変換され、ランタイムで簡略化された文法の基本形として使用されます。
それでは、一度詳しく見ていきましょう。
desugar()メソッドがこの機能を担当します。
私は上記のようにJavaコードを記述し、コンパイル後に生成されたバイトコードを分析してみました。
invokedynamic #17, 0 // InvokeDynamic
InvokeDynamicで定義されている部分がありますが、
Java 1.8以前では異なるコンパイルが行われます。Javaコンパイラのターゲットを1.8に設定してコンパイルを進めると、
StringBuilderがnew StringBuilder()で生成され、.append()メソッドを通じて文字列が連結される方式を確認できます。
これは、Java 9から導入されたinvokedynamicを使用することで、より最適化された方法で処理されるためです。ランタイムでは、JVMが内部的に最も効率的な方法を動的に処理するためです。
詳細な動作方式と説明については、次のように提供します。
invokedynamicの柔軟性:
invokedynamic命令は、動的にメソッドを呼び出す機能を提供します。つまり、コードを記述する時点ではどのメソッドが呼び出されるか決定されず、実行時にどのメソッドを呼び出すかが決定されます。
これを実現するために、invokedynamicはクラスの定数プール(constant pool)にある特別な属性を参照します。この属性には、**ブートストラップメソッド(Bootstrap Methods, BSMs)**と呼ばれる追加情報が含まれています。
ブートストラップメソッド(BSMs):
BSMsはinvokedynamicの核心的な役割を果たします。すべてのinvokedynamic呼び出しには、対応するBSMが定数プールに登録されています。このメソッドは、invokedynamic命令が実際にどのメソッドを呼び出すかを決定するために使用されます。
Java 7以降、InvokeDynamicという新しい項目がクラスファイル形式に追加されました。この項目は、特定のinvokedynamic呼び出しにBSMを関連付ける役割を担っています。
ランタイムの動作方式:
invokedynamic命令が呼び出される時点で、クラスがロードされる際にBSMが呼び出され、実際にどのメソッドを呼び出すかが決定されます。その結果はCallSiteオブジェクトに保存されます。
例えば、ConstantCallSiteは、一度メソッドが決定されると、それ以降はそのメソッドを直接呼び出せるように保存されます。つまり、一度決定されると、その後は追加の作業なしで直接呼び出すことができるため、効率的です。この方式は、JITコンパイラなど、Java仮想マシン(JVM)の他の下位システムにも適しています。
お時間があるときに、一度読んでみると良い記事かと思います。
バイトコード生成
いよいよ最後の段階です。
これまでの内容を基に、バイトコードを生成します。
コメントでも次のように述べられています。
指定されたクラスのリストに対してソースファイルまたはクラスファイルを生成します。ソースファイルまたはクラスファイルを生成するかどうかは、コンパイラのオプションに基づいて決定されます。ファイルの書き込み中にエラーが発生すると、生成は停止します。
構文ツリーを巡回しながらバイトコードを生成すると説明されています。
バイトコードとは
- javacでコンパイルが完了した後に生成される中間コードです
- .class形式でバイトコードが保存され、JVMがこれを読み取り実行します
- バイトコードはOSに依存しません。しかし、これを実行するJVMはOSに依存します
バイトコードは機械語ではありません
バイトコードは機械語ではなく、JVM(Java仮想マシン)が理解できる中間コードです。このバイトコードを通じて、JavaはOSに依存しない特性を持っています。
バイトコードは、javap -c <ファイルパス>コマンドを使用して確認できます。
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
クラスファイルは次のような構成で成り立っています。
u4 magic;
この部分は、クラスファイルが.classファイルであることを示す識別子です。常に0xCAFEBABEという固定された値を持ちます。
u2 minor_version;
u2 major_version;
Javaのメジャーバージョンとマイナーバージョンを示します。
u2 constant_pool_count:
これは、定数プール内の項目数を表します。
他の部分は名前の通り、クラス、フィールド、インターフェースの数やフィールド情報を含んでいます。
JVMがこれをコンパイルして実行する際に必要な基本情報が含まれていると考えれば良いでしょう。
私はこれをすべて理解していないので、理解できる方は以下の資料を参考にしてください。
javap -v Main.class
次のように詳細モードで出力すると、
このように詳細な情報が表示されます。
終わりに
このように、Javaのjavacコンパイルプロセスを見てきました。
Javaの勉強会のために、一生懸命勉強した内容をまとめて投稿しました。
間違いや不足している点があるかもしれませんので、ご指摘いただければ幸いです。
次回は、JVMと バイトコードをJVMが実行するまでのバックエンドのプロセスについて取り上げる予定です。
また、アノテーションやJavaの特殊なジェネリクス、ランタイムに関連する記事も合わせて公開する予定です。
ありがとうございました。
参考文献
"深入理解Java虚拟机"(JVM 밑바닥까지 파헤치기)本