アーキテクチャごとに異なるJVMが必要な場合、この概念の導入の背後にあるロジックはわかりません。他の言語ではマシンごとに異なるコンパイラが必要ですが、Javaでは異なるJVMが必要なため、JVMの概念やこの追加ステップの導入の背後にあるロジックは何ですか?
アーキテクチャごとに異なるJVMが必要な場合、この概念の導入の背後にあるロジックはわかりません。他の言語ではマシンごとに異なるコンパイラが必要ですが、Javaでは異なるJVMが必要なため、JVMの概念やこの追加ステップの導入の背後にあるロジックは何ですか?
回答:
ロジックは、JVMバイトコードはJavaソースコードよりもはるかに単純だということです。
コンパイラは、非常に抽象的なレベルでは、解析、セマンティック分析、コード生成の3つの基本的な部分を持っていると考えることができます。
解析は、コードを読み取り、それをコンパイラのメモリ内でツリー表現に変換することで構成されます。セマンティック分析は、このツリーを分析し、その意味を理解し、すべての高レベルの構造を低レベルの構造まで簡素化する部分です。また、コード生成では、単純化されたツリーを取得して、フラット出力に書き込みます。
バイトコードファイルを使用すると、再帰(ツリー構造)ソース言語ではなく、JITが使用するのと同じフラットバイトストリーム形式で記述されるため、解析フェーズが大幅に簡素化されます。また、セマンティック分析の多くの手間のかかる作業は、Java(または他の言語)コンパイラーによってすでに実行されています。したがって、コードをストリーム読み取りし、最小限の解析と最小限のセマンティック分析を行ってから、コード生成を実行するだけです。
これにより、理論的には単一ソースのクロスプラットフォームコードを記述することを可能にする高レベルのメタデータとセマンティック情報を保持しながら、JITが実行する必要のあるタスクが非常に単純になり、したがって実行がはるかに高速になります。
さまざまな種類の中間表現は、いくつかの理由により、コンパイラ/ランタイム設計でますます一般的になっています。
Javaの場合、最初の最大の理由はおそらくポータビリティでした。Javaは当初「Write Once、Run Anywhere」として大々的に販売されていました。これを実現するには、ソースコードを配布し、さまざまなコンパイラを使用してさまざまなプラットフォームをターゲットにしますが、これにはいくつかの欠点があります。
中間表現の他の利点は次のとおりです。
なぜソースコードを配布しないのかと疑問に思っているようですね。その質問を振り返ってみましょう:なぜマシンコードを配布しないのですか?
明らかに、ここでの答えは、Javaは設計上、コードが実行されるマシンが何であるかを知っているとは想定していないということです。デスクトップ、スーパーコンピューター、電話、またはその中間とそれ以上のあらゆるものです。Javaには、ローカルJVMコンパイラーが処理を行う余地があります。コードの移植性を高めることに加えて、これは、コンパイラーがマシン固有の最適化が存在する場合はそれを利用し、存在しない場合は少なくとも機能するコードを生成するなどのことをコンパイラーに許可するという素晴らしい利点があります。以下のようなものSSEの命令またはハードウェアアクセラレーションは、それらをサポートする唯一のマシンで使用することができます。
この観点から見ると、生のソースコードではなくバイトコードを使用する理由はより明確です。生の機械語に可能な限り近づけることで、次のような機械コードの利点の一部を実現または部分的に実現できます。
高速実行については言及していません。ソースコードとバイトコードの両方は、実際の実行のために同じマシンコードに完全にコンパイルされるか、理論的にはコンパイルされます。
さらに、バイトコードにより、マシンコードよりもいくつかの改善が可能になります。もちろん、前述したプラットフォーム非依存性とハードウェア固有の最適化がありますが、古いコードから新しい実行パスを生成するためにJVMコンパイラーにサービスを提供するようなものもあります。これは、セキュリティの問題にパッチを当てたり、新しい最適化が発見されたり、新しいハードウェアの指示を利用したりするためのものです。実際には、バグを公開する可能性があるため、このように大きな変更を確認することはめったにありませんが、それは可能であり、常に小さな方法で発生するものです。
ここには少なくとも2つの異なる質問が考えられます。1つは実際にはコンパイラ全般に関するもので、Javaは基本的にジャンルの単なる例です。もう1つは、使用する特定のバイトコードがJavaに固有です。
最初に一般的な質問を考えてみましょう:コンパイラは、特定のプロセッサで実行するためにソースコードをコンパイルするプロセスで中間表現を使用するのはなぜですか?
その答えの1つは非常に簡単です。O(N * M)問題をO(N + M)問題に変換します。
N個のソース言語とM個のターゲットが与えられ、各コンパイラが完全に独立している場合、それらすべてのソース言語をそれらすべてのターゲットに翻訳するN * Mコンパイラが必要です(「ターゲット」はプロセッサとOS)。
ただし、これらすべてのコンパイラーが共通の中間表現に同意する場合、ソース言語を中間表現に変換するNコンパイラーのフロントエンドと、中間表現を特定のターゲットに適したものに変換するMコンパイラーのバックエンドを持つことができます。
さらに良いことには、問題を2つの多かれ少なかれ排他的なドメインに分離します。言語設計、構文解析などを知っている/気にする人はコンパイラのフロントエンドに集中でき、命令セット、プロセッサ設計などを知っている人はバックエンドに集中できます。
したがって、たとえば、LLVMのようなものが与えられた場合、さまざまな言語のフロントエンドがたくさんあります。また、多くの異なるプロセッサ用のバックエンドもあります。言語担当者は、自分の言語の新しいフロントエンドを作成し、多くのターゲットをすばやくサポートできます。プロセッサの開発者は、言語設計や解析などを扱うことなく、ターゲットの新しいバックエンドを作成できます。
コンパイラをフロントエンドとバックエンドに分離し、2つの間で通信するための中間表現を使用することは、Javaのオリジナルではありません。それは長い間かなり一般的な習慣でした(とにかくJavaが登場するかなり前から)。
この点でJavaが新しいものを追加した範囲では、Javaは分散モデルに含まれていました。特に、コンパイラは長い間内部的にフロントエンドとバックエンドに分かれていましたが、通常は単一の製品として配布されていました。たとえば、Microsoft Cコンパイラを購入した場合、内部には「C1」と「C2」があり、それぞれフロントエンドとバックエンドでしたが、購入したのは両方を含む「Microsoft C」だけでしたピース(2つの間の操作を調整する「コンパイラドライバー」を使用)。コンパイラーは2つの部分で構築されていますが、コンパイラーを使用する通常の開発者にとっては、ソースコードからオブジェクトコードに変換されるものは1つであり、間には何も表示されません。
代わりに、JavaはJava Development KitのフロントエンドとJava Virtual Machineのバックエンドを配布しました。すべてのJavaユーザーには、使用しているシステムを対象とするコンパイラバックエンドがありました。Java開発者はコードを中間形式で配布したため、ユーザーがそれをロードすると、JVMは特定のマシンで実行するために必要なことをすべて行いました。
この分布モデルもまったく新しいものではないことに注意してください。たとえば、UCSD Pシステムも同様に機能しました。コンパイラフロントエンドはPコードを生成し、Pシステムの各コピーには、その特定のターゲットでPコードを実行するために必要なことを行う仮想マシンが含まれていました1。
JavaバイトコードはPコードに非常に似ています。基本的には、かなり単純なマシンの手順です。そのマシンは、既存のマシンを抽象化することを目的としているため、ほとんどすべての特定のターゲットにすばやく簡単に変換できます。P-Systemが行ったように、元の目的はバイトコードを解釈することだったので、翻訳の容易さは早い段階で重要でした(そして、そう、それはまさに初期の実装が機能した方法です)。
コンパイラのフロントエンドは、Javaバイトコードを簡単に生成できます。(たとえば)式を表すかなり典型的なツリーがある場合、通常はツリーをたどることが非常に簡単で、各ノードで見つけたものからかなり直接コードを生成します。
Javaバイトコードは非常にコンパクトです。ほとんどの場合、ほとんどの典型的なプロセッサ(特に、SunがJavaの設計時に販売したSPARCなどのほとんどのRISCプロセッサ)のソースコードまたはマシンコードよりもはるかにコンパクトです。これは当時特に重要でした。Javaの主な目的の1つは、アプレット(実行前にダウンロードされるWebページに埋め込まれたコード)をサポートすることでした。 1キロビット/秒(もちろん、古い低速のモデムを使用している人はまだかなりいました)。
Javaバイトコードの主な弱点は、特に表現力がないことです。Javaに存在する概念をかなりうまく表現することはできますが、Javaの一部ではない概念を表現するためにはほとんどうまくいきません。同様に、ほとんどのマシンでバイトコードを実行するのは簡単ですが、特定のマシンを最大限に活用する方法でそれを行うのははるかに困難です。
たとえば、Javaバイトコードを本当に最適化したい場合、基本的にリバースエンジニアリングを行って、マシンコードのような表現から逆方向に変換し、SSA命令(または同様のもの)に戻します2。次に、SSA命令を操作して最適化を行い、そこから本当に気になるアーキテクチャをターゲットとするものに変換します。ただし、このやや複雑なプロセスであっても、Javaに馴染みのない一部の概念は表現が非常に難しく、一部のソース言語からほとんどの一般的なマシンで最適に実行される(ほぼ同じ)マシンコードに翻訳することは困難です。
一般的に中間表現を使用する理由について質問している場合、2つの主要な要因があります。
Javaバイトコードの詳細と、なぜ彼らが他のコードではなくこの特定の表現を選んだのかを尋ねているなら、答えはその当時のウェブの元の意図と制限に大きく戻っていると思います、次の優先順位につながります:
多くの言語を表現したり、さまざまなターゲットで最適に実行したりできることは、はるかに低い優先度でした(それらが優先度であると見なされた場合)。
他の人が指摘した利点に加えて、バイトコードははるかに小さいため、配布および更新が容易であり、ターゲット環境で占めるスペースが少なくなります。これは、スペースに大きな制約がある環境では特に重要です。
また、著作権で保護されたソースコードを簡単に保護できます。
当初、JVMは純粋なインタープリターでした。そして、あなたが通訳している言語が可能な限りシンプルであれば、あなたは最高のパフォーマンスの通訳を手に入れます。これがバイトコードの目標でした。ランタイム環境に効率的に解釈可能な入力を提供することです。この単一の決定により、Javaはパフォーマンスによって判断されるように、インタプリタ言語よりもコンパイル言語に近づきました。
後になって、通訳JVMのパフォーマンスが依然として劣悪であることが明らかになったとき、人々はパフォーマンスの良いジャストインタイムコンパイラを作成するための努力を投資しました。これにより、CやC ++などの高速言語とのギャップがいくぶん解消されました。(ただし、Java固有の速度の問題がいくつか残っているため、適切に作成されたCコードと同等のパフォーマンスを発揮するJava環境を取得することはおそらくないでしょう。)
もちろん、ジャストインタイムのコンパイル技術を手に入れれば、実際にソースコードを配布し、マシンコードにジャストインタイムでコンパイルすることに戻ることができます。ただし、これにより、コードのすべての関連部分がコンパイルされるまで、起動パフォーマンスが大幅に低下します。バイトコードは、同等のJavaコードよりも解析がはるかに簡単であるため、ここでも大きな助けになります。
テキストソースコードは、人間が読みやすく、変更しやすい構造です。
バイトコードは、マシンで簡単に読み取って実行できるようにすることを目的とした構造です。
JVMがコードで行うことはすべて読み取られて実行されるため、バイトコードはJVMによる消費に適しています。
まだ例がなかったことに気づきました。愚かな擬似の例:
//Source code
i += 1 + 5 * 2 + x;
// Byte code
i += 11, i += x
____
//Source code
i = sin(1);
// Byte code
i = 0.8414709848
_____
//Source code
i = sin(x)^2+cos(x)^2;
// Byte code (actually that one isn't true)
i = 1
もちろん、バイトコードは最適化だけではありません。メソッドの大部分は、メソッドが「foo」を参照するときにファイル内のどこかに「foo」というメンバーが含まれているかどうかをチェックするなど、複雑なルールを気にせずにコードを実行できることです。