この記事は、JVM パフォーマンスの最適化シリーズ (最初の記事: ポータル) の 2 番目の記事となり、Java コンパイラーがこの記事で説明する中心的な内容になります。
この記事では、著者 (Eva Andreasson) がまずさまざまな種類のコンパイラを紹介し、クライアント側コンパイラ、サーバー側コンパイラ、およびマルチレイヤ コンパイルの実行パフォーマンスを比較します。次に、記事の最後では、デッド コードの削除、コードの埋め込み、ループ本体の最適化など、いくつかの一般的な JVM 最適化方法が紹介されています。
Java の最も誇る機能である「プラットフォーム独立性」は、Java コンパイラに由来します。ソフトウェア開発者は、可能な限り最高の Java アプリケーションを作成するために最善を尽くし、コンパイラが舞台裏で実行されて、ターゲット プラットフォームに基づいて効率的な実行可能コードを生成します。異なるアプリケーション要件には異なるコンパイラが適しているため、異なる最適化結果が生成されます。したがって、コンパイラがどのように動作するかをより深く理解し、より多くの種類のコンパイラを知ることができれば、Java プログラムをより適切に最適化することができます。
この記事では、さまざまな Java 仮想マシン コンパイラの違いに焦点を当てて説明します。同時に、ジャストインタイム コンパイラ (JIT) で一般的に使用される最適化ソリューションについても説明します。
コンパイラとは何ですか?
簡単に言うと、コンパイラはプログラミング言語プログラムを入力として受け取り、別の実行可能な言語プログラムを出力として受け取ります。 Javac は最も一般的なコンパイラです。すべての JDK に存在します。 Javac は Java コードを出力として受け取り、それを JVM 実行可能コード (バイトコード) に変換します。これらのバイトコードは、.class で終わるファイルに保存され、Java プログラムの起動時に Java ランタイム環境にロードされます。
バイトコードは CPU によって直接読み取ることはできません。また、現在のプラットフォームが理解できる機械命令言語に変換する必要があります。 JVM には、バイトコードをターゲット プラットフォームで実行可能な命令に変換する別のコンパイラがあります。一部の JVM コンパイラでは、複数のレベルのバイトコード コード ステージが必要です。たとえば、コンパイラは、バイトコードを機械命令に変換する前に、いくつかの異なる形式の中間段階を通過する必要がある場合があります。
プラットフォームに依存しない観点から、コードは可能な限りプラットフォームに依存しないようにしたいと考えています。
これを達成するために、私たちは、実行可能コードを特定のプラットフォームのアーキテクチャに真にバインドする、最下位のバイトコード表現から実際のマシン コードまでの変換の最終レベルで作業します。最も高いレベルから、コンパイラを静的コンパイラと動的コンパイラに分けることができます。 ターゲットの実行環境、望む最適化結果、満たす必要のあるリソースの制約に基づいて、適切なコンパイラを選択できます。前回の記事では静的コンパイラと動的コンパイラについて簡単に説明しましたが、次のセクションではそれらについてさらに詳しく説明します。
静的コンパイル VS 動的コンパイル
前述の javac は静的コンパイルの例です。静的コンパイラでは、入力コードは一度解釈され、出力はプログラムが将来実行される形式になります。ソース コードを更新して (コンパイラを介して) 再コンパイルしない限り、プログラムの実行結果は決して変わりません。これは、入力が静的入力であり、コンパイラが静的コンパイラであるためです。
静的コンパイルでは、次のプログラムが実行されます。
次のようにコードをコピーします。
staticint add7(int x ){ return x+7;}
次のようなバイトコードに変換されます。
次のようにコードをコピーします。
iload0 bipush 7 iadd ireturn
動的コンパイラは、ある言語を別の言語に動的にコンパイルします。いわゆる動的とは、プログラムの実行中にコンパイルすること、つまり実行中にコンパイルすることを指します。動的コンパイルと最適化の利点は、アプリケーションのロード時に一部の変更を処理できることです。 Java ランタイムは、予測不可能な環境や変化する環境で実行されることが多いため、動的コンパイルは Java ランタイムに非常に適しています。ほとんどの JVM は、JIT コンパイラなどの動的コンパイラを使用します。動的コンパイルとコードの最適化には、追加のデータ構造、スレッド、CPU リソースの使用が必要であることに注意してください。オプティマイザーまたはバイトコード コンテキスト アナライザーが高度であればあるほど、より多くのリソースを消費します。しかし、パフォーマンスの大幅な向上に比べれば、これらのコストは取るに足らないものです。
JVM の種類と Java のプラットフォーム非依存性
すべての JVM 実装の共通の機能は、バイトコードを機械語命令にコンパイルすることです。一部の JVM はアプリケーションのロード時にコードを解釈し、パフォーマンス カウンターを使用して「ホット」コードを見つけます。その他の JVM はコンパイルを通じてこれを行います。コンパイルの主な問題は、一元化には多くのリソースが必要になることですが、それによってパフォーマンスの最適化も向上します。
Java を初めて使用する場合は、JVM の複雑さによって間違いなく混乱するでしょう。しかし、良いニュースは、それを理解する必要がないということです。 JVM がコードのコンパイルと最適化を管理するため、マシン命令や、プログラムが実行されているプラットフォームのアーキテクチャに最もよく適合するコードの書き方について心配する必要はありません。
Javaバイトコードから実行可能ファイルへ
Java コードがバイトコードにコンパイルされたら、次のステップはバイトコード命令をマシンコードに変換することです。このステップは、インタプリタまたはコンパイラを通じて実装できます。
説明する
解釈は、バイトコードをコンパイルする最も簡単な方法です。インタプリタは、ルックアップ テーブルの形式で各バイトコード命令に対応するハードウェア命令を見つけ、それを実行のために CPU に送信します。
インタプリタは辞書のように考えることができます。特定の単語 (バイトコード命令) ごとに、それに対応する特定の翻訳 (マシンコード命令) があります。インタプリタは命令を読み取るたびにすぐに実行するため、この方法では命令セットを最適化できません。同時に、バイトコードが呼び出されるたびに即座に解釈する必要があるため、インタープリターの実行は非常に遅くなります。インタプリタは非常に正確な方法でコードを実行しますが、出力命令セットが最適化されていないため、ターゲット プラットフォームのプロセッサにとって最適な結果が得られない可能性があります。
コンパイル
コンパイラは、実行されるすべてのコードをランタイムにロードします。こうすることで、バイトコードを変換するときにランタイム コンテキストのすべてまたは一部を参照できます。決定は、コードグラフ分析の結果に基づいて行われます。さまざまな実行ブランチの比較やランタイム コンテキスト データの参照など。
バイトコード シーケンスがマシン コード命令セットに変換された後、このマシン コード命令セットに基づいて最適化を実行できます。最適化された命令セットは、コード バッファーと呼ばれる構造に格納されます。これらのバイトコードが再度実行されると、最適化されたコードがこのコード バッファーから直接取得されて実行されます。場合によっては、コンパイラはコードの最適化にオプティマイザを使用せず、新しい最適化シーケンスである「パフォーマンス カウント」を使用します。
コード キャッシュを使用する利点は、再解釈やコンパイルを必要とせずに、結果セットの命令をすぐに実行できることです。
これにより、特にメソッドが複数回呼び出される Java アプリケーションの場合、実行時間を大幅に短縮できます。
最適化
動的コンパイルの導入により、パフォーマンス カウンターを挿入できるようになりました。たとえば、コンパイラは、バイトコードのブロック (特定のメソッドに対応する) が呼び出されるたびに増加するパフォーマンス カウンターを挿入します。コンパイラーはこれらのカウンターを使用して「ホット ブロック」を見つけ、どのコード ブロックを最適化してアプリケーションのパフォーマンスを最大に向上できるかを判断できます。実行時のパフォーマンス分析データは、コンパイラーがオンライン状態でより多くの最適化決定を行うのに役立ち、それによってコードの実行効率がさらに向上します。より正確なコード パフォーマンス分析データが得られるため、より多くの最適化ポイントを見つけて、命令をより適切にシーケンスする方法や、より効率的な命令セットを使用するかどうかなど、より適切な最適化の決定を下すことができます。冗長な操作を排除するかどうかなど。
例えば
次の Java コードを検討してください。 コードをコピーします。 コードは次のとおりです。
staticint add7(int x ){ return x+7;}
Javac はそれを次のバイトコードに静的に変換します。
次のようにコードをコピーします。
iload0
バイプッシュ7
iadd
帰ります
このメソッドが呼び出されると、バイトコードは機械語命令に動的にコンパイルされます。このメソッドは、パフォーマンス カウンター (存在する場合) が指定されたしきい値に達すると最適化される場合があります。最適化された結果は、次のマシン命令セットのようになります。
次のようにコードをコピーします。
リア・ラックス、[rdx+7] レット
異なるアプリケーションには異なるコンパイラが適しています
アプリケーションが異なれば、ニーズも異なります。エンタープライズサーバー側アプリケーションは通常、長時間実行する必要があるため、よりパフォーマンスの最適化が必要ですが、クライアント側アプレットは、より速い応答時間とより少ないリソース消費を必要とする場合があります。 3 つの異なるコンパイラとその長所と短所について説明します。
クライアント側コンパイラ
C1 はよく知られた最適化コンパイラです。 JVM を起動するときに、 -client パラメータを追加してコンパイラを起動します。その名前から、C1 がクライアント コンパイラであることがわかります。これは、利用可能なシステム リソースがほとんどない、または高速起動が必要なクライアント アプリケーションに最適です。 C1 は、パフォーマンス カウンターを使用してコードの最適化を実行します。これは、ソース コードへの介入が少ないシンプルな最適化方法です。
サーバー側コンパイラ
長時間実行されるアプリケーション (サーバー側のエンタープライズ アプリケーションなど) の場合、クライアント側のコンパイラーの使用だけでは不十分な場合があります。現時点では、C2 のようなサーバー側コンパイラを選択する必要があります。オプティマイザは、JVM 起動行にサーバーを追加することで起動できます。通常、ほとんどのサーバー側アプリケーションは実行時間が長いため、C2 コンパイラを使用すると、実行時間が短い軽量のクライアント側アプリケーションよりも多くのパフォーマンス最適化データを収集できます。したがって、より高度な最適化手法やアルゴリズムを適用することもできます。
ヒント: サーバー側コンパイラをウォームアップします。
サーバー側の展開の場合、コンパイラがこれらの「ホット」コードを最適化するのに時間がかかる場合があります。したがって、サーバー側の展開では、多くの場合、「ウォームアップ」フェーズが必要になります。したがって、サーバー側のデプロイメントでパフォーマンス測定を実行するときは、アプリケーションが定常状態に達していることを常に確認してください。コンパイラーにコンパイルに十分な時間を与えると、アプリケーションに多くのメリットがもたらされます。
サーバー側コンパイラーは、クライアント側コンパイラーよりも多くのパフォーマンス チューニング データを取得できるため、より複雑な分岐分析を実行し、パフォーマンスが向上する最適化パスを見つけることができます。パフォーマンス分析データが多ければ多いほど、アプリケーション分析結果はより適切になります。もちろん、広範なパフォーマンス分析を実行するには、より多くのコンパイラ リソースが必要です。たとえば、JVM が C2 コンパイラを使用する場合、より多くの CPU サイクル、より大きなコード キャッシュなどを使用する必要があります。
マルチレベルのコンパイル
多層コンパイルでは、クライアント側のコンパイルとサーバー側のコンパイルが混合されます。 Azul は、Zing JVM にマルチレイヤー コンパイルを初めて実装しました。最近、このテクノロジーは Oracle Java Hotspot JVM (Java SE7 以降) に採用されました。マルチレベルのコンパイルは、クライアント側とサーバー側のコンパイラの利点を組み合わせたものです。クライアント コンパイラは、アプリケーションの起動時と、パフォーマンスの最適化を実行するためにパフォーマンス カウンターが下位レベルのしきい値に達したときの 2 つの状況でアクティブになります。また、クライアント コンパイラはパフォーマンス カウンターを挿入し、高度な最適化のためにサーバー側コンパイラが後で使用できるように命令セットを準備します。マルチレイヤーコンパイルは、リソース使用率の高いパフォーマンス分析方法です。影響の少ないコンパイラ アクティビティ中にデータが収集されるため、このデータは後でより高度な最適化に使用できます。このアプローチでは、解釈コードを使用してカウンターを分析するよりも多くの情報が得られます。
図 1 は、インタープリター、クライアント側コンパイル、サーバー側コンパイル、およびマルチレイヤー コンパイルのパフォーマンスの比較を示しています。 X 軸は実行時間 (時間の単位)、Y 軸はパフォーマンス (単位時間あたりの操作の数) です。
図 1. コンパイラのパフォーマンスの比較
純粋に解釈されたコードと比較して、クライアント側コンパイラーを使用すると、パフォーマンスが約 5 ~ 10 倍向上します。得られるパフォーマンスの向上の程度は、コンパイラーの効率、利用可能なオプティマイザーの種類、およびアプリケーションの設計がターゲット プラットフォームにどの程度適合するかによって異なります。しかし、プログラム開発者にとって、最後のものは無視できることがよくあります。
クライアント側コンパイラと比較して、サーバー側コンパイラは多くの場合 30% ~ 50% のパフォーマンス向上をもたらします。ほとんどの場合、パフォーマンスの向上にはリソースの消費が伴います。
マルチレベル コンパイルは、両方のコンパイラの利点を組み合わせたものです。クライアント側のコンパイルは起動時間が短く、高速な最適化を実行できます。サーバー側のコンパイルは、後続の実行プロセス中により高度な最適化操作を実行できます。
いくつかの一般的なコンパイラの最適化
これまで、コードを最適化することの意味と、JVM がコードの最適化をいつどのように実行するかについて説明してきました。次に、実際にコンパイラで使用される最適化手法をいくつか紹介してこの記事を終わります。 JVM の最適化は実際にはバイトコード段階 (または低レベル言語表現段階) で行われますが、ここではこれらの最適化方法を説明するために Java 言語を使用します。もちろん、このセクションですべての JVM 最適化手法をカバーすることは不可能ですが、これらの紹介が、より高度な最適化手法を学び、コンパイラ テクノロジを革新するきっかけになれば幸いです。
デッドコードの除去
デッドコードの削除は、その名前が示すように、決して実行されないコード、つまり「デッド」コードを削除することです。
コンパイラが動作中に冗長な命令を見つけた場合、それらの命令は実行命令セットから削除されます。たとえば、リスト 1 では、変数の 1 つは代入後に使用されないため、実行中に代入ステートメントは完全に無視できます。バイトコード レベルでの操作に対応して、変数値をレジスタにロードする必要はありません。ロードする必要がないということは、消費される CPU 時間が少なくなり、コードの実行が高速化され、最終的にアプリケーションが高速化されます。ロードするコードが 1 秒間に何度も呼び出される場合、最適化の効果はより明白になります。
リスト 1 は、Java コードを使用して、決して使用されない変数に値を割り当てる例を示しています。
リスト 1. デッド コード コピー コード コードは次のとおりです。
int timeToScaleMyApp(boolean unlimitedOfResources){
int reArchitect =24;
int patchByClustering =15;
int useZing =2;
if(無限のリソース)
reArchitect + useZing を返します。
それ以外
useZing を返します。
}
バイトコード フェーズ中に、変数がロードされたものの使用されなかった場合、リスト 2 に示すように、コンパイラーはデッド コードを検出して削除できます。このロード操作をまったく実行しない場合、CPU 時間を節約し、プログラムの実行速度を向上させることができます。
リスト 2. 最適化されたコードのコピー コードは次のとおりです。
int timeToScaleMyApp(boolean unlimitedOfResources){
int reArchitect =24; //不要な操作はここで削除されました…
int useZing =2;
if(無限のリソース)
reArchitect + useZing を返します。
それ以外
useZing を返します。
}
冗長性の削除は、重複した命令を削除することでアプリケーションのパフォーマンスを向上させる最適化手法です。
多くの最適化では、マシン命令レベルのジャンプ命令 (x86 アーキテクチャの JMP など) を排除しようとします。ジャンプ命令は命令ポインタ レジスタを変更するため、プログラムの実行フローが変更されます。このジャンプ命令は、他の ASSEMBLY 命令と比較して、非常にリソースを消費するコマンドです。だからこそ、私たちはこの種の指導を減らしたり、なくしたりしたいのです。コード埋め込みは、転送命令を排除するための非常に実用的でよく知られた最適化方法です。ジャンプ命令の実行にはコストがかかるため、頻繁に呼び出されるいくつかの小さなメソッドを関数本体に埋め込むと、多くの利点が得られます。リスト 3-5 は、埋め込みの利点を示しています。
リスト 3. メソッドのコピー コードの呼び出し コードは次のとおりです。
int whenToEvaluateZing(int y){ return daysLeft(y)+ daysLeft(0)+ daysLeft(y+1);}
リスト 4. 呼び出されるメソッドのコピー コードは次のとおりです。
int daysLeft(int x){ if(x ==0) return0; else return x -1;}
リスト 5. インライン メソッドのコピー コード コードは次のとおりです。
int whenToEvaluateZing(int y){
int temp =0;
if(y==0)
温度 +=0;
それ以外
温度 += y -1;
if(0==0)
温度 +=0;
それ以外
温度 +=0-1;
if(y+1==0)
温度 +=0;
それ以外
温度 +=(y +1)-1;
戻り温度;
}
リスト 3-5 では、別のメソッド本体で小さなメソッドが 3 回呼び出されていることがわかります。ここで説明したいのは、呼び出されたメソッドをコードに直接埋め込むコストは、3 回のジャンプを実行するコストよりも低いということです。転送指示。
あまり呼び出されないメソッドを埋め込んでも大きな違いは生じないかもしれませんが、いわゆる「ホット」メソッド (頻繁に呼び出されるメソッド) を埋め込むと、パフォーマンスが大幅に向上する可能性があります。リスト 6 に示すように、埋め込みコードはさらに最適化できることがよくあります。
リスト 6. コードを埋め込んだ後、次のようにコードをコピーすることでさらに最適化できます。
int whenToEvaluateZing(int y){ if(y ==0)return y; elseif(y ==-1)return y -1;}
ループの最適化
ループの最適化は、ループ本体の実行にかかる追加コストを削減する上で重要な役割を果たします。ここでの追加コストとは、高価なジャンプ、多くの条件チェック、および最適化されていないパイプライン (つまり、実際の操作を行わず、余分な CPU サイクルを消費する一連の命令セット) を指します。ループの最適化にはさまざまな種類があります。最も一般的なループの最適化をいくつか示します。
ループ本体のマージ: 2 つの隣接するループ本体が同じ数のループを実行すると、コンパイラーは 2 つのループ本体をマージしようとします。 2 つのループ本体が互いに完全に独立している場合、それらを同時に (並列的に) 実行することもできます。
反転ループ: 最も基本的な方法では、while ループを do-while ループに置き換えます。この do-while ループは if ステートメント内に配置されます。この置き換えにより 2 つのジャンプ操作が減りますが、条件判断が増加するためコード量が増加します。この種の最適化は、より効率的なコードを得るためにより多くのリソースをトレードする好例です。コンパイラーはコストと利点を比較検討し、実行時に動的に決定を下します。
ループ本体を再編成する: ループ本体全体をキャッシュに格納できるようにループ本体を再編成します。
ループ本体を拡張します。ループ条件のチェックとジャンプの数を減らします。これは、条件チェックを行わずに複数の反復を「インライン」で実行するものと考えることができます。ループ本体を展開すると、パイプラインや多数の冗長な命令フェッチに影響を与えてパフォーマンスが低下する可能性があるため、一定のリスクも伴います。繰り返しになりますが、実行時にループ本体をアンロールするかどうかはコンパイラ次第であり、パフォーマンスが大幅に向上する場合はアンロールする価値があります。
上記は、バイトコード レベル (またはそれより低いレベル) のコンパイラがターゲット プラットフォーム上のアプリケーションのパフォーマンスを向上させる方法の概要です。ここまで説明してきたのは、一般的で一般的な最適化方法です。スペースが限られているため、ここでは簡単な例のみを示します。私たちの目標は、上記の簡単な議論を通じて、最適化についての深い研究への興味を喚起することです。
結論:反省点とポイント
目的に応じてさまざまなコンパイラを選択してください。
1. インタプリタは、バイトコードを機械語命令に変換する最も単純な形式です。その実装は命令ルックアップ テーブルに基づいています。
2. コンパイラはパフォーマンス カウンターに基づいて最適化できますが、追加のリソース (コード キャッシュ、最適化スレッドなど) を消費する必要があります。
3. クライアント コンパイラは、インタプリタと比較して 5 ~ 10 倍のパフォーマンス向上をもたらします。
4. サーバー側コンパイラーは、クライアント側コンパイラーと比較して 30% ~ 50% のパフォーマンス向上をもたらしますが、より多くのリソースが必要になります。
5. マルチレイヤーコンパイルは両方の利点を組み合わせます。クライアント側のコンパイルを使用して応答時間を短縮し、サーバー側のコンパイラを使用して頻繁に呼び出されるコードを最適化します。
ここでコードを最適化する方法は数多くあります。コンパイラーの重要な仕事は、考えられるすべての最適化方法を分析し、さまざまな最適化方法のコストと、最終的な機械語命令によってもたらされるパフォーマンスの向上を比較検討することです。