JVM での Just-In-Time (JIT) の仕組み

Java 言語で「一度書けばどこでも実行」を実現するために、コンパイラはソース コードを中間言語 (バイトコード) にコンパイルし、そのバイトコードを対応するマシン コードに変換して Java 仮想マシン (JVM) を介して実行します。そして、この翻訳プロセスは解釈されて実行されるため、C++ などの静的にコンパイルされる言語と比較して、パフォーマンスがある程度低下します。

実行時の Java の実行効率を向上させるために、JVM によって導入されたジャストインタイム コンパイル技術、すなわち JIT (Just-In-Time): バイトコードは引き続き解釈および実行されますが、実行プロセスの分析を通じて、ホットコードは、選択的にマシンコードにコンパイルしてキャッシュし、Java全体の実行パフォーマンスを向上させます.同時に、コンパイルプロセス中にいくつかのコード最適化方法があり、コード実行をより効率的にします.

以下では、JIT 関連の内容と JVM での実践について説明します。

JVM の JIT コンパイラ

最も一般的に使用される JVM は HotSpot です. デフォルトでは、その動作モードは混合モード (混合モード) です。つまり、インタープリターと JIT コンパイラーはランタイム段階で一緒に実行され、その実行モードは JVM パラメーターを介して設定することもできます。

  • -Xint: プログラムを完全にインタープリター モードで実行します。
  • -Xcomp: ジャストインタイムコンパイルモードでプログラムを完全に実行します. ジャストインタイムコンパイルに問題がある場合, インタプリタが介入します.
  • -Xmixed: インタープリター + ジャストインタイム コンパイルの混合モードを使用して、プログラムを共同で実行します。これは、JVM のデフォルトの実行モードです。

一般に、実行モードを変更する必要はありません。混合モードの実行方法を紹介する前に、まず JIT コンパイラーを紹介します.HotSpot は、コード最適化戦略が異なる 2 つの JIT コンパイラーを提供します:

  • C1 Compiler: Client Compiler または Client mode とも呼ばれ、通常、ローカル リソースが少なく、起動時間の影響を受けやすいクライアント プログラムなどのプログラムに使用されます. 通常、コンパイル時間は短く、特定の最適化効果があります.
  • C2 コンパイラ: サーバー コンパイラまたはサーバー モードとも呼ばれ、動作環境のローカル リソースは比較的制限されておらず、より詳細な最適化が実行されますが、通常、コンパイルには長い時間がかかり、コンパイルされたコードの実行効率は低くなります。より良い。

2 つには独自の利点と適用可能なシナリオがあることがわかります. 初期の頃は-client、 C1 コンパイラと-serverC2 コンパイラは JVM パラメーターを介して指定できました。では、両方の利点を組み合わせる方法はありますか? Java 7では、レイヤード コンパイルの概念が導入され、 2 つの利点が統合されました。

階層化されたコンパイル

階層化コンパイルの概念を導入した後、インタープリターの解釈とバイトコードの実行から JIT コンパイラーの介入まで、複数の遷移プロセスがあり、コードの状態も 5 つのレベルに分けられます。

1.png

コンパイルレベル 説明
レベル 0、コードの解釈と実行 最初に起動すると、JVM はすべてのコードを解釈して実行します。
レベル 1、C1 単純なコンパイル済みコード このレベルでは、JVM は C1 を使用してコードをコンパイルし始めますが、メソッドの複雑さのために、C2 コンパイラを使用しても明らかなパフォーマンス上の利点が得られないことが判明したため、そのようなプロファイル情報をさらに収集しません。メソッドを使用し、C1 を直接使用します。コンパイラはコンパイルします。
レベル 2、C1 制限付きコンパイル済みコード このレベルでは、C1 コンパイラは、メソッド カウンターとエッジ カウンターによって提供されるプロファイル情報に基づいてコードをコンパイルするだけです。
レベル 3、C1 の完全にコンパイルされたコード レベル 2 に基づいて、完成したプロファイル情報に基づいてコンパイルされたコード。
レベル 4、C2 コンパイル済みコード このレベルでは、C2 コンパイラでコンパイルされたコードが実行されます。

JVM がバイトコードを解釈して実行する過程で、コード実行中の情報が収集され、必要に応じて専用の JIT コンパイル スレッドが最適化に介入します。上の図のいくつかの遷移状態から、理想的な状況は L0 から直接 L3 へ、そして最終的に L4 へということですが、単純な get/set メソッドなど、メソッドの複雑さが非常に低い場合は、 C2 にコンパイルさせます。これ以上のゲインはありません。L1 レベルで十分です。

コンパイル スレッドが限られているため、キューを介してメソッドがコンパイルされるため、ビジー状態でブロックされる場合があるはずですが、C2 コンパイル スレッドがビジー状態の場合は、コードをできるだけ早く最適化するために、まず L2 に移行します。 C2 コンパイル スレッドがアイドル状態になると、C2 コンパイル スレッドに渡されて L4 に移行します。C1 コンパイル スレッドもビジー状態になることがありますが、この時点でコードの解釈と実行中に十分な情報が収集されると、C2 コンパイル スレッドに直接渡され、L4 に移行します。

Java 8 以降、レイヤード コンパイルがデフォルトで有効になり、C1 または C2 コンパイラの使用を手動で指定するため-client-server

コンパイルスレッド

前述のように、JVM はコンパイル スレッドを開始して JIT コンパイルを実行します. コンパイル スレッドの数はデフォルトで現在のマシンの CPU コアの数に関連しています. CPU コアの数とコンパイル スレッドの関係は次のとおりです.デフォルトでは:

CPU C1 コンパイル スレッド数 C2 コンパイル スレッド
1 1 1
2 1 1
4 1 2
8 1 2
16 2 6
32 3 7
64 4 8
128 4 10

また、JVM パラメータを介してコンパイル スレッドの数を-XX:CICompilerCount手動で。手動で指定した場合、スレッドの 1/3 は C1 であり、残りは C2 です。たとえば、6 つのコンパイル スレッドを手動で指定すると、次のようになります。 C1 の 2 つのコンパイル スレッドと C2 の 4 つのコンパイル スレッドがあります。

ジャストインタイム コンパイルのトリガー タイミング

前述のように、コードは解釈および実行プロセス中に情報を収集し、必要に応じて JIT コンパイラーが介入しますが、何が必要なのでしょうか? または、どのようなコードが最適化に値すると考えられますか? 頻繁に実行されるホットコードに違いないホットコードは、呼び出し回数が多いメソッドと、実行回数が多いループ本体の2種類に分けられます。The JVM will create counters for each method or code block. 実行回数が特定のしきい値に達すると、ホット コードと見なされ、JIT コンパイラが介入します。

メソッド呼び出しカウンター

これは、メソッド呼び出しの相対的な数、つまり、一定期間内にメソッドが呼び出された回数をカウントします. 特定の時間制限を超えた場合、メソッドのカウントがまだサブミットするのに十分でない場合.半分、このプロセスはメソッド呼び出しの減衰 (Counter Decay) と呼ばれ、この期間はこのメソッド統計の半減期サイクル (Counter Half Life Time) と呼ばれます。 .

バックエッジカウンター

そもそもバックサイドとは?バック エッジとは、バイトコードが実行中に制御フローに遭遇したときに逆方向にジャンプする命令を指します。カウンターは、このメソッドのループ実行の絶対数をカウントし、減衰プロセスはありません。

階層化されたコンパイル メカニズムが存在する前に、-XX: CompileThresholdJVM パラメーターを使用してメソッド呼び出しカウンターのしきい値を設定できます。デフォルトは、クライアント モードで 1500 回、サーバー モードで 10000 回です。また、バックエッジカウンタの閾値計算式も固定です。レイヤード コンパイル メカニズムでは、上記のパラメーターは無効になり、しきい値は複数のパラメーターに基づいて動的に計算されます。

方法调用次数 > Tier{X}InvocationThreshold * s ||
方法调用次数 > Tier{X}MinInvocationThreshold * s && 方法调用次数 + 循环回边次数 > Tier{X}CompileThreshold * s

その中で、X は上記のコンパイル レベルを指し、3 または 4 です。上記のパラメータのデフォルト値は次のとおりです。

Tier3InvocationThreshold = 200
Tier4InvocationThreshold = 5000
Tier3CompileThreshold = 2000
Tier4CompileThreshold = 15000
Tier3MinInvocationThreshold = 100
Tier4MinInvocationThreshold = 600

係数 s の計算式は次のとおりです。

s = Level X 层待编译方法数 / (TierXLoadFeedback * 编译线程数) + 1

X はコード コンパイル レベル 3 または 4 も指し、TierXLoadFeedbackデフォルト値は次のとおりです。

Tier3LoadFeedback = 5
Tier4LoadFeedback = 3

計算式が複雑になり、計算式がどこから来たのかを分析することは困難ですが、アプリケーションが起動したばかりで、コンパイルされるメソッドの数が少ない場合、またはゼロである場合、計算式から理解することは難しくありません。 , 最初のいくつかのメソッド呼び出しはすぐにコンパイルされます. アプリケーションの起動に伴い, ますます多くのメソッドが呼び出されます, そしてコンパイルされるメソッドの数も増加します. その後、後で呼び出されるメソッドはより多くを必要とします.最適化する必要があります。

以前の固定しきい値モードと比較して、動的計算しきい値はより柔軟であり、JIT コンパイルの適時性と、JIT コンパイルがアプリケーションに及ぼすパフォーマンスへの影響を可能な限りバランスさせます。

OSR(オンスタック交換)

ここでOSRの概念を補足すると、まずJITコンパイラのコンパイル単位はメソッドですが、リターンカウンターの対象となるホットコードはループ本体のコードなので、コンパイル単位がまだ違いは、メソッドの実行中に、ループ本体が最適化されたコードに置き換えられること、つまり、メソッドのスタック フレームがまだスタック上にあるときに、メソッドが置き換えられることです。これが概念です。 OSR の「スタック上の置換」。

最適化解除について

コンパイラがいつ最適化を開始するかについて話した後、最適化について話しましょう。いわゆるデオプティマイゼーションとは、JIT コンパイラによるコードの最適化が効果を発揮しなくなることを意味し、このとき、このコードはロールバックされ、実行解析から最適化までの解析と実行のモードが再体験されます。 . made not entrant非最適化には との 2 つの状態がありますmade zombie

参加者コードではありません

made not entrant名前が示すように、このコードは後で再利用されます。コードがマークされる 2 つのケースがありますmade not entrant

最初の状況は、積極的な最適化に関連しています。より良い最適化結果を得るために、C2 コンパイラは積極的な最適化を実行します。コードを実行すると、以前の最適化が無効になる場合があります。分岐予測の最適化の例を次に示します。

StockPriceHistory sph;
String log = request.getParameter("log");
if (log != null && log.equals("true")) {
    sph = new StockPriceHistoryLogger(...);
}
else {
    sph = new StockPriceHistoryImpl(...);
}
// Then the JSP makes calls to:
sph.getHighPrice();
sph.getStdDev();

たとえば、サーバーが上記のコードを実行している場合、多数のリクエストが以前にログ パラメータを持っていない場合、それらはすべて else 条件を使用します。このとき、sph変数のStockPriceHistoryImpl。メソッドのインライン処理と同じですが、一度開始すると log パラメータ付きのリクエストがあればロジックは if 条件が true の分岐に進み、このときsph変数のStockPriceHistoryLoggerde -最適化が行われます。

もう 1 つの状況は、階層化コンパイルのメカニズムに関するもので、前述の階層化コンパイルの遷移プロセスから、コードの最終状態は L4 または L1 であり、その過程で生成された L2 および L3 のコード コンパイル結果がコンパイルされます。メソッドが最終状態に到達したとき。

ゾンビコード

この状態は、以前にリサイクル可能とmade not entrantマーク. これらのゾンビ コードは、コード キャッシュ クリーンアップ メカニズムがトリガーされるとクリアされます. 以下に、コード キャッシュの関連コンテンツを紹介します.

コンパイルキャッシュ

JVM には Code Cache と呼ばれるメモリ領域があり、JIT コンパイラのコンパイル結果をキャッシュするために使用されます. その後の実行で、プログラムがメソッドを再度呼び出すと、Code Cache 内のローカル コードを直接使用することができます。コンパイルする。It should note that the size of the Code Cache is fixed. When layered compilation is enabled, the default is 240MB . コード キャッシュ スペースが不十分な場合、JIT コンパイラは新しいコードのコンパイルを続行できず、これにより、アプリケーションのパフォーマンスの低下。

コード キャッシュがいっぱいになると、JVM ログに出力されますCodeCache is full. Compiler has been disabled.JVM パラメータを使用して、Code Cache のデフォルトの領域サイズ-XX:ReservedCodeCacheSizeを調整。

さらに、Code Cache には、JVM パラメータによって-XX:+UseCodeCacheFlushing制御される。このスイッチは、Java 7 以降、デフォルトで有効になっています。Code Cache がほぼいっぱいになると、以前にコンパイルされたメソッドの半分が古いメソッド キューに入れられます。 . , 一定の頻度でチェックをオンにします (デフォルトでは 30 秒). 変更されたメソッドが 2 回の検査後も使用されていない場合、変更されたメソッドは としてマークされ、徐々にクリーンアップされますmade not entrant.

JITコンパイルログの分析と実践

JIT コンパイル ログを観察する

JVM パラメータ-XX:+PrintCompilationを使用して。例として、テスト コードを取り上げます。

public class SomeComputation {
    
    
    public String doSomething(String str) {
    
    
        if (str == null || str.isEmpty()) {
    
    
            return "Hello World!";
        }

        return str.toUpperCase() + str.toLowerCase();
    }
}

public class TrivialObject {
    
    
    private int a;

    private String b;

    public int getA() {
    
    
        return a;
    }

    public void setA(int a) {
    
    
        this.a = a;
    }

    public String getB() {
    
    
        return b;
    }

    public void setB(String b) {
    
    
        this.b = b;
    }
}
public class JITCompilationPlaygroundMain {
    public static void main(String[] args) {
        SomeComputation sth = new SomeComputation();

        for (int i = 0; i < 100000; i++) {
            TrivialObject obj = new TrivialObject();
            obj.setA(i);
            obj.setB(String.valueOf(i));
            sth.doSomething(obj.getB());
        }
    }
}

パラメーターを指定してコードを実行すると、次の出力が得られます (比較的長いため、重要な部分のみが掲載されています)。

149   61       3       me.leozdgao.playground.TrivialObject::setB (6 bytes)
149   62       3       me.leozdgao.playground.SomeComputation::doSomething (39 bytes)
150   72       1       me.leozdgao.playground.TrivialObject::setB (6 bytes)
150   61       3       me.leozdgao.playground.TrivialObject::setB (6 bytes)   made not entrant
150   42       1       me.leozdgao.playground.TrivialObject::getB (5 bytes)
150   56       3       me.leozdgao.playground.TrivialObject::<init> (5 bytes)
150   57       3       me.leozdgao.playground.TrivialObject::setA (6 bytes)
150   58       3       java.lang.String::valueOf (5 bytes)
150   60       3       java.lang.Integer::toString (48 bytes)
150   32       3       java.lang.String::getChars (62 bytes)   made not entrant
150   71       4       java.lang.StringBuilder::append (8 bytes)
151   73       1       me.leozdgao.playground.TrivialObject::setA (6 bytes)
151   57       3       me.leozdgao.playground.TrivialObject::setA (6 bytes)   made not entrant
151   30   !   3       sun.misc.URLClassPath$JarLoader::ensureOpen (36 bytes)
152   33       1       java.net.URL::getProtocol (5 bytes)
152   55  s    1       java.util.Vector::size (5 bytes)
156   82       4       me.leozdgao.playground.SomeComputation::doSomething (39 bytes)
163   80       4       java.lang.String::valueOf (5 bytes)
163   34       3       java.lang.String::<init> (82 bytes)   made not entrant
163   81       4       java.lang.Integer::toString (48 bytes)
171   58       3       java.lang.String::valueOf (5 bytes)   made not entrant
172   60       3       java.lang.Integer::toString (48 bytes)   made not entrant
172   62       3       me.leozdgao.playground.SomeComputation::doSomething (39 bytes)   made not entrant
176   84 %     3       me.leozdgao.playground.JITCompilationPlaygroundMain::main @ 10 (53 bytes)
177   85       3       me.leozdgao.playground.JITCompilationPlaygroundMain::main (53 bytes)

最初にログ形式を説明します。

时间 JVM编译ID 标识符 编译层次 编译的方法名 去优化标记

私たちが出力するログに、アプリケーションの起動からコンパイル トリガーまでの時間と、識別子の意味を説明します。上記のログに表示される 3 つの代表的なもの:

  • %: OSR コンパイルかどうかを示します
  • s: シンクロ方式かどうか
  • !: 例外ハンドラを含めるかどうか

上記の例には表示されない識別子が 2 つあります。

  • n: ネイティブメソッドかどうか
  • b: アプリケーションスレッドをブロックするかどうか

上記の遷移プロセスは、コンパイル レベル列の値を観察することでよりよく確認できます. method をTrivialObject::setB例に Trivial Method に属する単純な setter メソッドであるため、149ms で L3 になり、L1 に遷移します。メソッドをもう一度見てみるとSomeComputation::doSomething、メソッド本体にロジックが定義されており、149msでL3、156msでL4に遷移するという最も一般的な遷移処理となっています。

注意深く観察すると、L1 または L4 に遷移するメソッドには、フォローアップに追加の L3 レコードがあり、最後に値の列があることがわかります.これは、階層化されているため、上記の最適化解除フラグですmade not entrant。コンパイル メカニズムでは、L3 が中間状態として使用されます。「No More Entry」としてマークされます。

アプリケーションのウォーミング

実際の開発プロセスでは、JIT メカニズムの存在により、アプリケーションが再デプロイされて開始されたばかりのときに、JIT コンパイラーがホット コードを最適化するのに間に合わず、サービスの応答時間が短期間で増加する可能性があります。また、多数の JIT コンパイルによって CPU 使用率が高くなり、特に C 側のサービス インターフェイスの場合、サービスの可用性に影響を与える可能性があります。

この問題を解決するには、「アプリケーションのウォームアップ」が考えられます.これは、各アプリケーションの実際の状況に応じて判断する必要があります.Spring Bootアプリケーションを例にとると、イベントに基づいて事前に考えたアプリケーションを呼び出すことができます.アプリケーションが実際に外部サービスを提供する前にApplicationReadyEvent、アプリケーションが実際にサービスを提供するときにホット コードが最適化されているように、事前に JIT コンパイルをトリガーするホット コード。

要約する

筆者もアプリケーションの予熱が必要なシナリオに遭遇したので、レイヤードコンパイルからコードキャッシュ、ログ解析までのJITメカニズムを整理しました.もちろん、アプリケーションの予熱はさまざまなアプリケーションの状況に依存します.実際には、関連するもの.これは、この記事では説明しない JIT コンパイルの単なる要因ではありません。

Java9 で Graal コンパイラが導入されて以来、AOT に基づいてコンパイルされたコードは、コンパイル時にターゲット環境のマシン コードにコンパイルされており、JIT メカニズムはまったく存在しないため、必要性を心配する必要はありません。アプリケーションをウォームアップします. 導入と実践の一部は、後続の記事で紹介されます.

参考文献:

おすすめ

転載: blog.csdn.net/qq_34626094/article/details/130517279
おすすめ