6

現在、独自の Java 仮想マシン実装の JIT コンパイル部分に取り組んでいます。私たちのアイデアは、指定された Java バイトコードをオペコードに単純に変換し、それらを実行可能メモリに書き込み、メソッドの先頭に直接呼び出すことでした。

指定された Java コードが次のようになると仮定します。

int a = 13372338;
int b = 32 * a;
return b;

ここで、次のアプローチが作成されました (指定されたメモリが 0x1000 で始まり、戻り値が eax であると想定しています)。

0x1000: first local variable - accessible via [eip - 8]
0x1004: second local variable - accessible via [eip - 4]
0x1008: start of the code - accessible via [eip]

Java bytecode | Assembler code (NASM syntax)
--------------|------------------------------------------------------------------
              | // start
              | mov edx, eip
              | push ebx
              |         
              | // method content
ldc           | mov eax, 13372338
              | push eax
istore_0      | pop eax
              | mov [edx - 8], eax
bipush        | push 32
iload_0       | mov eax, [edx - 8]
              | push eax
imul          | pop ebx
              | pop eax
              | mul ebx
              | push eax
istore_1      | pop eax
              | mov [edx - 4], eax
iload_1       | mov eax, [edx - 4]
              | push eax
ireturn       | pop eax
              |         
              | // end
              | pop ebx
              | ret

これは、仮想マシン自体が行うのと同じようにスタックを使用するだけです。このソリューションに関する質問は次のとおりです。

  • このコンパイル方法は実行可能ですか?
  • この方法ですべての Java 命令を実装することさえ可能ですか? throw/instanceof や同様のコマンドはどのように変換できますか?
4

2 に答える 2

5

このコンパイル方法は機能し、簡単に起動して実行でき、少なくとも解釈のオーバーヘッドが取り除かれます。しかし、それは非常に大量のコードと非常にひどいパフォーマンスをもたらします。大きな問題の 1 つは、ターゲット マシン (x86) がレジスタマシンであるにもかかわらず、スタック操作が 1:1 で音訳されることです。投稿したスニペット (および他のコード) でわかるように、これにより、常に単一の操作ごとに複数のスタック操作オペコードが発生するため、レジスタを使用します。つまり、ISA 全体を可能な限り非効率的に使用します。

例外などの複雑な制御フローにも対応できます。インタープリターで実装するのと大差ありません。try優れたパフォーマンスが必要な場合は、ブロックに出入りするたびに作業を実行したくありません。これを回避するためのスキームがあり、C++ と他の JVM の両方で使用されます (キーワード: ゼロコストまたはテーブル駆動の例外処理)。これらは非常に複雑で、実装、理解、およびデバッグが複雑であるため、最初はより単純な代替手段を使用する必要があります。覚えておいてください。

生成されたコードについて: ほぼ確実に必要になる最初の最適化は、スタック操作を 3 つのアドレス コードまたはレジスタを使用するその他の表現に変換することです。これとこれの実装に関するいくつかの論文があるので、あなたが望まない限り、私は詳しく説明しません。次に、もちろん、これらの仮想レジスタを物理レジスタにマップする必要があります。レジスター割り当ては、コンパイラーの構築において最もよく研​​究されているトピックの 1 つであり、JIT コンパイラーで使用するのに十分効果的で高速なヒューリスティックが少なくとも半ダースあります。私の頭の上にある 1 つの例は、リニア スキャン レジスタの割り当てです (具体的には JIT コンパイル用に作成します)。

さらに、(迅速なコンパイルではなく) 生成されたコードのパフォーマンスに重点を置いたほとんどの JIT コンパイラは、1 つ以上の中間形式を使用し、この形式でプログラムを最適化します。これは基本的に、定数伝播、値の番号付け、再関連付け、ループ不変コード モーションなどのベテランを含む、ミル コンパイラ最適化スイートの実行です。教科書やウィキペディアに至るまでの 30 年間の文学の中で。

上記で得られるコードは、プリミティブ、配列、およびオブジェクト フィールドを使用するストレート ライン コードに適しています。ただし、メソッド呼び出しを最適化することはまったくできません。すべてのメソッドは仮想です。つまり、非常に特殊な場合を除いて、インライン化またはメソッド呼び出しの移動 (たとえばループの外への移動) は基本的に不可能です。これはカーネル用だとおっしゃいました。動的なクラスのロードなしで Java のサブセットを使用することを受け入れることができる場合は、JIT がすべてのクラスを認識していると仮定することで、より適切に実行できます (ただし、非標準になります)。次に、たとえば、リーフ クラス (または、より一般的には決してオーバーライドされないメソッド) を検出し、それらをインライン化できます。

動的なクラスの読み込みが必要であるが、それがまれであると予想される場合は、より多くの作業が必要になりますが、より適切に行うこともできます。このアプローチの利点は、ログ ステートメントを完全に削除するなど、他のことにも一般化できることです。基本的な考え方は、いくつかの仮定 (たとえば、これstaticは変わらない、または新しいクラスが読み込まれないなど) に基づいてコードを特殊化し、それらの仮定に違反した場合は最適化を解除することです。これは、実行中にコードを再コンパイルする必要がある場合があることを意味します (これは困難ですが、不可能ではありません)。

この道をさらに進むと、その論理的な結論はトレースベースの JIT コンパイルであり、これJava に適用されていますが、私の知る限り、メソッドベースの JIT コンパイラよりも優れているとは言えませんでした。非常に動的な言語で発生するように、優れたコードを得るために何十または何百もの仮定を行う必要がある場合は、より効果的です。

于 2013-09-25T20:39:37.813 に答える
2

あなたの JIT コンパイラに関するいくつかのコメント (「delnan」が既に書いたものを書かないことを願っています):

一般的なコメント

「本物の」JITコンパイラはあなたのものと同じように機能すると確信しています。ただし、最適化を行うことはできます (例: "mov eax,nnn" と "push eax" は "push nnn" に置き換えることができます)。

スタックにローカル変数を格納する必要があります。通常、「ebp」はローカル ポインターとして使用されます。

push ebx
push ebp
sub esp, 8 // 2 variables with 4 bytes each
mov ebp, esp
// Now local variables are addressed using [ebp+0] and [ebp+4]
  ...
pop ebp
pop ebx
ret

関数は再帰的である可能性があるため、これが必要です。変数を (EIP に関連する) 固定位置に格納すると、変数は「静的」な場所のように動作します。(再帰関数の場合、関数を複数回コンパイルしないと仮定しています。)

トライ/キャッチ

Try/Catch を実装するには、JIT コンパイラは Java バイトコードだけでなく、Java クラスの別の属性に格納されている Try/Catch 情報も確認する必要があります。Try/catch は、次の方法で実装できます。

  // push all useful registers (= the ones, that must not be destroyed)
 push eax
 push ebp
  ...
  // push the "catch" pointers
 push dword ptr catch_pointer
 push dword ptr catch_stack
  // set the "catch" pointers
 mov catch_stack,esp
 mov dword ptr catch_pointer, my_catch
  ... // some code
  // Here some "throw" instruction...
 push exception
 jmp dword ptr catch_pointer
  ... //some code
  // End of the "try" section: Pop all registers
 pop dword_ptr catch_stack
  ...
 pop eax
  ...
  // The "catch" block
my_catch:
 pop ecx // pop the Exception from the stack
 mov esp, catch_stack // restore the stack
  // Now restore all registers (same as at the end of the "try" section)
 pop dword_ptr catch_stack
  ...
 pop eax
 push ecx // push the Exception to the stack

マルチスレッド環境では、各スレッドに独自の catch_stack および catch_pointer 変数が必要です!

特定の例外タイプは、次の方法で「instanceof」を使用して処理できます。

try {
    // some code
} catch(MyException1 ex) {
    // code 1
} catch(MyException2 ex) {
    // code 2
}

...実際には次のようにコンパイルされます...:

try {
    // some code
} catch(Throwable ex) {
    if(ex instanceof MyException1) {
        // code 1
    }
    else if(ex instanceof MyException2) {
        // code 2
    }
    else throw(ex); // not handled!
}

オブジェクト

オブジェクト (および配列) をサポートしない単純化された Java 仮想マシンの JIT コンパイラは非常に簡単ですが、Java のオブジェクトは仮想マシンを非常に複雑にします。

オブジェクトは、スタック上またはローカル変数内のオブジェクトへのポインターとして単純に格納されます。通常、JIT コンパイラは次のように実装されます。 クラスごとに、クラスに関する情報を含むメモリの一部が存在します (たとえば、どのメソッドが存在し、どのアドレスにメソッドのアセンブラ コードが配置されているかなど)。オブジェクトは、すべてのインスタンス変数と、クラスに関する情報を含むメモリへのポインタを含むメモリの一部です。

「Instanceof」と「checkcast」は、クラスに関する情報を含むメモリへのポインタを調べることで実装できます。この情報には、すべての親クラスと実装されたインターフェイスのリストが含まれる場合があります。

ただし、オブジェクトの主な問題は、Java でのメモリ管理です。C++ とは異なり、「新規」はありますが「削除」はありません。オブジェクトの使用頻度を確認する必要があります。オブジェクトが使用されなくなった場合は、メモリから削除し、デストラクタを呼び出す必要があります。

ここでの問題は、ローカル変数 (同じローカル変数にオブジェクトまたは数値が含まれる場合があります) と try/catch ブロック (「catch」ブロックは、スタック ポインターを復元する前に、オブジェクトを含むローカル変数とスタック (!) を処理する必要があります) です。 )。

于 2013-09-26T06:48:03.433 に答える