祖先システムで GC チューニングを行い、一時停止時間を 90% 短縮しました | JD Cloud テクニカル チーム

問題の説明

同社のルール エンジン システムは、バージョンが起動されるたびに手動で予熱されます。予熱完了後、トラフィックが切り替わると、時折、Young GC が 1 ~ 2 秒間表示されます (トラフィックは大きくなく、 LB 下の時間 この状況はすべてのノードで発生します)

この長い一時停止の後、各若い世代の GC 一時停止時間は 20 ~ 100 ミリ秒以内に戻りました。

2 秒という時間はそれほど長い時間ではないように見えますが、ルール エンジンの各実行にかかる時間はわずか数ミリ秒です。これを誰が許容できるでしょうか。そして、これがタイムアウトになると、注文もタイムアウトして失敗する可能性があります。

問題分析

システムの GC ログを分析した結果、Young GC フェーズで 2 秒の一時停止が発生し、Young GC で長い一時停止が発生するたびに、新しい世代のオブジェクトの昇格が伴うことがわかりました。

コア JVM パラメータ (Oracle JDK7)

-Xms10G 
-Xmx10G 
-XX:NewSize=4G 
-XX:PermSize=1g 
-XX:MaxPermSize=4g 
-XX:+



なぜそんなにメモリが多いのかと疑問に思う人もいるかもしれません。メモリが小さすぎるため、祖先のコードは実行できません。

起動後の最初の若い世代の GC ログ

2023-04-23T16:28:31.108+0800: [GC2023-04-23T16:28:31.108+0800: [ParNew2023-04-23T16:28:31.229+0800: [SoftReference, 0 refs, 0.0000950 secs]2023-04-23T16:28:31.229+0800: [WeakReference, 1156 refs, 0.0001040 secs]2023-04-23T16:28:31.229+0800: [FinalReference, 10410 refs, 0.0103720 secs]2023-04-23T16:28:31.240+0800: [PhantomReference, 286 refs, 2 refs, 0.0129420 secs]2023-04-23T16:28:31.253+0800: [JNI Weak Reference, 0.0000000 secs]
Desired survivor size 214728704 bytes, new threshold 1 (max 15)
- age   1:  315529928 bytes,  315529928 total
- age   2:   40956656 bytes,  356486584 total
- age   3:    8408040 bytes,  364894624 total
: 3544342K->374555K(3774912K), 0.1444710 secs] 3544342K->374555K(10066368K), 0.1446290 secs] [Times: user=1.46 sys=0.09, real=0.15 secs] 


長い一時停止の若い世代の GC ログ

2023-04-23T17:18:28.514+0800: [GC2023-04-23T17:18:28.514+0800: [ParNew2023-04-23T17:18:29.975+0800: [SoftReference, 0 refs, 0.0000660 secs]2023-04-23T17:18:29.975+0800: [WeakReference, 1224 refs, 0.0001400 secs]2023-04-23T17:18:29.975+0800: [FinalReference, 8898 refs, 0.0149670 secs]2023-04-23T17:18:29.990+0800: [PhantomReference, 600 refs, 1 refs, 0.0344300 secs]2023-04-23T17:18:30.025+0800: [JNI Weak Reference, 0.0000210 secs]
Desired survivor size 214728704 bytes, new threshold 15 (max 15)
- age   1:   79203576 bytes,   79203576 total
: 3730075K->304371K(3774912K), 1.5114000 secs] 3730075K->676858K(10066368K), 1.5114870 secs] [Times: user=6.32 sys=0.58, real=1.51 secs] 


この長い一時停止の GC ログから判断すると、昇格が発生しました。若い GC の後、363M+ オブジェクトが古い世代に昇格されました。この昇格操作には時間がかかるはずです (追記: セーフポイントの理由を確認しましたが、例外はありませんでした)。 )

ログパラメータには設定パラメータがないため-XX:+PrintHeapAtGC、手動で計算されたプロモーションサイズは次のとおりです。

年轻代年轻变化 - 全堆容量变化 = 晋升大小
(304371K - 3730075K) - (676858K - 3730075K) = 372487K(363M)


次世代の若い世代の GC ログ

2023-04-23T17:23:39.749+0800: [GC2023-04-23T17:23:39.749+0800: [ParNew2023-04-23T17:23:39.774+0800: [SoftReference, 0 refs, 0.0000500 secs]2023-04-23T17:23:39.774+0800: [WeakReference, 3165 refs, 0.0002720 secs]2023-04-23T17:23:39.774+0800: [FinalReference, 3520 refs, 0.0021520 secs]2023-04-23T17:23:39.776+0800: [PhantomReference, 150 refs, 1 refs, 0.0051910 secs]2023-04-23T17:23:39.782+0800: [JNI Weak Reference, 0.0000100 secs]
Desired survivor size 214728704 bytes, new threshold 15 (max 15)
- age   1:   17076040 bytes,   17076040 total
- age   2:   40832336 bytes,   57908376 total
: 3659891K->90428K(3774912K), 0.0321300 secs] 4032378K->462914K(10066368K), 0.0322210 secs] [Times: user=0.30 sys=0.00, real=0.03 secs] 


一見何の問題もないように見えますが、よく考えてみるとやはり何かがおかしいのですが、なぜ2回目のgcが始まった直後にプロモーションが行われたのでしょうか?

画像.png

これは動的な年齢決定が原因であると推測されており、GC における昇進年齢のしきい値は 15 に固定されておらず、各 GC の後に jvm によって動的に計算されます。

若年層育成推進の仕組み

さまざまなプログラムのメモリ条件に適切に適応するために、仮想マシンは、古い世代に昇格する前に、オブジェクトの年齢が MaxTenuringThreshold に達する必要があるとは限りません。 Survivor 空間内の同じ年齢が Survivor 空間の半分より大きい場合、この年齢以上のオブジェクトは、MaxTenuringThreshold で必要な年齢を待たずに、古い世代に直接入ることができます。

『Java 仮想マシンの詳細』という本には、オブジェクトの昇格期間のしきい値が動的に決定されることが記載されています。

しかし、他の情報を参考に検証した結果、「Java仮想マシンの徹底理解」の説明との間にいくつかの齟齬があることが分かりました。

実際には、オブジェクトは年齢ごとにグループ化されており、合計が最も大きい年齢グループ (累積値、現在の年齢のオブジェクトの合計サイズ以下) が選択されます。グループの合計がより大きい場合は、生存者の半分の場合、昇進年齢のしきい値はグループの年齢に更新されます。

注: サバイバーの過半数を超えたときに昇格が発生するわけではなく、サバイバーの過半数が昇格のしきい値 (しきい値) をリセットするだけであり、新しいしきい値は次の GC で使用されます。

3544342K->374555K(3774912K), 0.1444710 secs] 年轻代

3544342K->374555K(10066368K), 0.1446290 secs] 全堆


この結論は、上記の最初の GC ログからも証明できますが、この GC では、ヒープ全体のメモリ変更と若い世代のメモリ変更が等しいため、オブジェクトの昇格は発生しません。

上記のログと同様に、最初の GC はしきい値を 1 に設定するだけです。この時点で生存者の半分は 214728704 バイトであり、年齢 1 のオブジェクトの総数は 315529928 バイトであり、望ましい生存者のサイズを超えているためです。この GC の後、年齢 1 のオブジェクトのしきい値を年齢 1 に設定します

这里更新了对象晋升年龄阈值为1
Desired survivor size 214728704 bytes, new threshold 1 (max 15)
- age   1:  315529928 bytes,  315529928 total
- age   2:   40956656 bytes,  356486584 total
- age   3:    8408040 bytes,  364894624 total


この年齢分布の出力の説明は次のとおりです。

- age   1:  315529928 bytes,  315529928 total 


- age 1年齢 1 のオブジェクトのグループ化を示し、315529928 bytes年齢 1 のオブジェクトが占有するメモリ サイズを示します。

315529928 totalこれは累積値であり、現在のグループ年齢以下のオブジェクトの合計サイズを示します。まず、オブジェクトを年齢別にグループ化します。年齢 1 のグループ合計は、年齢 1 の合計サイズ (前の xxx バイト) です。年齢 2 のグループ合計は、合計サイズです。年齢 n のグループ合計は、合計サイズage 1 + age 2です。age 1 + age 2 + ... +age n蓄積ルールは下図のとおりです。

画像.png

合計が最も大きいグループの合計値がsurvivor/2を超えると昇格閾値が更新されます。

2 番目の若い世代 GC の「長い一時停止の若い世代 GC ログ」では、新しい昇格年齢のしきい値が 1 であるため、GC が発生して生き残り、まだ到達可能なオブジェクトが昇格されます。

この GC では 363M のオブジェクトがプロモートされたため、長い一時停止が発生しました。

考える

JVM におけるこの「動的なオブジェクトの年齢決定」は本当に合理的なのでしょうか?

個人的には、このメカニズムは優れており、さまざまなプログラムのメモリ状況にうまく適応できると思いますが、すべてのシナリオに適しているわけではありません。たとえば、この記事では、直後に GC が開始されないシナリオでは問題が発生します。起動する。

プログラムが最初に開始されるとき、ほとんどのオブジェクトの年齢は 0 または 1 であるため、年齢が 1 の存続オブジェクトが多数存在することが容易です。この「動的なオブジェクトの年齢決定」メカニズムでは、新しい昇格のしきい値は次のようになります。 1 に設定すると、プロモートされるべきでないオブジェクトがプロモートされます。

たとえば、ヤング GC は、プログラムがさまざまなリソースを初期化およびロードしているときに発生します。ロード ロジックはまだ実行されており、新しく作成されたオブジェクトの多くは、この GC 中にも到達可能です。

この GC が発生した後、これらのオブジェクトの年齢は 1 に更新されます。ただし、「動的オブジェクト年齢決定」メカニズムの影響により、昇格年齢のしきい値は「最大のオブジェクト年齢グループ」の年齢に更新されます。つまり、このバッチは GC を経験したばかりです。

この GC の直後に、リソースの初期化が完了し、関連するオブジェクトに到達できない可能性がありましたが、プロモーション年齢のしきい値が 1 に更新されたため、次の通常のヤング GC では、この年齢のあるオブジェクトのバッチが1 のいずれかが直接発生します。昇進、昇進は早期または誤って発生しました

解決

ドキュメントと情報を確認した結果、「動的年齢決定」メカニズムを無効にすることはできないことがわかりました。そのため、この問題を解決するには、この計算ルールを「バイパス」するしかありません。

動的年齢の決定は、Survivor 空間内の同じ年齢のすべてのオブジェクトのサイズの合計が Survivor 空間の半分より大きいという事実に基づいているため、このメカニズムに基づく解決策は非常に簡単です。

私たちはシステムを十分に理解しており、リソースのロードに必要なおおよそのメモリを知っているため、これらの一時的に到達可能なオブジェクトの合計よりも大きい値を生存者の容量として設定できます。

たとえば、上記のログでは、最初の GC 後の経過時間 1 のオブジェクトのサイズは 315529928 バイト (300M) で、希望の生存サイズは (生存サイズ /2) 214728704 バイト (204M) です。その後、生存サイズをさらに大きく設定できます。 600M以上。

ただし、安全のため、サバイバーを 800M に調整して、望ましいサバイバー サイズが約 400M になるようにします。最初のヤング GC の後は、年齢 1 のオブジェクトの合計が望ましいサバイバーを超えるため、昇格年齢のしきい値は更新されません。サイズが大きいため、早期または間違ったプロモーションによって引き起こされる長い GC 一時停止の問題は発生しません。

サバイバーはサイズを直接指定することはできませんが、比率 -XX:SurvivorRatio を調整することでサバイバーのサイズを調整できます。

-XX:SurvivorRatio=8


Survivor と Edgen の 2 つの領域の比率を示します。8 は 2 Survivor:Eden=2:8、つまり、Survivor 1 つが新世代の 1/10 を占めることを示します。

計算方法は次のとおりです。
クリーンショット 2023-12-08 09.24.23@2x.png

少し変形すると、エデンのサイズを計算する式は次のようになります。

クリーンショット 2023-12-08 09.28.35@2x.png

ここでは、積み上げヒストグラムを使用して、さまざまな SurvivorRatio 値での Eden/Survivor のスペース比を詳細に説明しています。

画像.png

さて、比率を使用して直接サバイバーのサイズを強制的に増加させましょう。

-XX:SurvivorRatio=3


調整後の合計生存率は 40%、サイズは 1717829632 バイトです。単一の S0/S1 の半分も 10% の 429457408 バイトを占め、age=1 のグループの合計サイズ 315529928 バイトよりもはるかに大きくなります。

この方法では、Young GC (最大年齢グループ) の後に Survivor にコピーされたオブジェクトは全体の割合の 50% を占めなくなり、MaxTenuringThreshold は 1 に更新されなくなり、この「ランダム プロモーション」問題は自然に解決されます。

修正が完了して一日が終わり、バージョンを再リリースして手動で予熱した後、カット後の長い一時停止の問題はなくなり、若い GC は 30 ~ 100 ミリ秒で安定し、正常に解決されました。

拡大する

若い世代において 300M へのプロモーションが 3G のリサイクルよりも何倍も遅いのはなぜですか?

コピー アルゴリズムの特性によれば、コピー アルゴリズムの時間消費は、主に、全体のスペースのサイズではなく、生き残ったオブジェクトのサイズに依存します。

たとえば、上記の若い世代の 4G (実際には Eden+S0 のみが利用可能) では、GC 中に、GC ROOTS から開始してオブジェクト グラフを走査し、到達可能なオブジェクトを S1 にコピーするだけでよく、全体を走査する必要はありません。若い世代。

コピー アルゴリズムの詳細については、私の別の記事「ガベージ コレクション アルゴリズムの実装 - コピー アルゴリズム (完全な実行可能な C 言語コード)」を参照してください。

上記の長期停止 GC ログでは、3 億 6,300 万のプロモーションと約 3 億のリサイクルが発生しており、最初の GC と比較すると、費やされた 1.5 秒は基本的にプロモーション操作であると結論付けることができます。

プロモーション作業にこれほど時間がかかるのはなぜですか?

結局のところ、昇格には世代間のコピーが含まれます (実際、若い世代も古い世代もヒープであり、コピーに本質的な違いはありません。これらはすべて単なる memcpy ですが、さらに多くのロジックを追加で処理する必要があります)。

を実行すると、ポインターの更新やその他の操作など、処理に必要なロジックがより複雑になり、時間がかかります。

ネイティブコードエミュレーション

ここには問題をローカルでシミュレートできるコードも添付されており、テストは Oracle JDK7 で直接実行できます。

//jdk7.。

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

public class PromotionTest {
    public static void main(String[] args) throws IOException {
        //模拟初始化资源场景
        List<Object> dataList = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            dataList.add(new InnerObject());
        }
        //模拟流量进入场景
        for (int i = 0; i < 73; i++) {
            if(i == 72){
                System.out.println("Execute young gc...Adjust promotion threshold to 1");
            }
            new InnerObject();
        }
        System.out.println("Execute full gc...dataList has been promoted to cms old space");
        //这里注意dataList中的对象在这次Full GC后会进入老年代
        System.gc();
    }
    public static byte[] createData(){
        int dataSize = 1024*1024*4;//4m
        byte[] data = new byte[dataSize];
        for (int j = 0; j < dataSize; j++) {
            data[j] = 1;
        }
        return data;
    }
    static class InnerObject{
        private Object data;

        public InnerObject() {
            this.data = createData();
        }
    }
}


JVM オプション

-server -Xmn400M -XX:SurvivorRatio=9 -Xms1000M -Xmx1000M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintHeapAtGC -XX:+PrintReferenceGC -XX:+PrintGCApplicationStoppedTime -XX:+UseConcMarkSweepGC


この記事のガベージ コレクション関連のメカニズムの説明はすべて、HotSpot JVM、Parallel New + CMS Old に基づいていることに注意してください。

参考

著者:JD Insurance Jiang Xin

出典:JD Cloud Developer Community 転載の際は出典を明記してください

IntelliJ IDEA 2023.3 と JetBrains Family Bucket の年次メジャー バージョン アップデート 新しいコンセプト「防御型プログラミング」: 安定した仕事に就く GitHub.com では 1,200 を超える MySQL ホストが稼働していますが、8.0 にシームレスにアップグレードするにはどうすればよいですか? Stephen Chow の Web3 チームは来月、独立したアプリをリリースする予定ですが、 Firefox は廃止されるのでしょうか? Visual Studio Code 1.85 リリース、フローティング ウィンドウ Yu Chengdong: ファーウェイは来年破壊的な製品を発売し、業界の歴史を書き換えるだろう 米国 CISA はメモリ セキュリティの脆弱性を排除するために C/C++ の廃止を勧告 TIOBE 12 月: C# がプログラミングになると予想30年前 雷軍が書いた論文「コンピュータウイルス判定エキスパートシステムの原理と設計」
{{名前}}
{{名前}}

おすすめ

転載: my.oschina.net/u/4090830/blog/10320978