ネイティブ メモリ リークのトラブルシューティング プロセスを覚えておいてください | JD Cloud テクニカル チーム

1 問題となる現象

経路計算サービスは、運送状の経路計画の計算と、実際の運行と計画の照合を担当する、経路システムの中核サービスです。運用保守の過程で、TP99 を長期間再起動しないと、ゆっくりと坂道を登る現象が発生することが判明しました。また、毎週のルーチンスケジューリングを試算すると、メモリの増加がはっきりとわかります。次のスクリーンショットは、これら 2 つの異常な状況を監視しているものです。

TP99登山

メモリランプ

マシン構成は以下の通りです

CPU: 16C RAM: 32G

Jvm の構成は次のとおりです。

-Xms20480m (後に 8GB に切り替え) -Xmx20480m (後に 8GB に切り替え) -XX:MaxPermSize=2048m -XX:MaxGCPauseMillis=200 -XX:+ParallelRefProcEnabled -XX:+PrintReferenceGC -XX:+UseG1GC -Xss256k -XX:ParallelGCThread s = 16 -XX:ConcGCThreads=4 -XX:MaxDirectMemorySize=2g -Dsun.net.inetaddr.ttl=600 -Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector -Dlog4j2.asyncQueueFullPolicy=破棄 -XX : MetaspaceSize=1024M -XX:G1NewSizePercent=35 -XX:G1MaxNewSizePercent=35

ルーチンタスクのスケジュール設定:

実行は毎週月曜日の午前 2 時にトリガーされます。上のスクリーンショットには、合計 2 サイクルのタスクが含まれています。最初の実行では、メモリが 33% から 75% に直接上昇したことがわかります。2 回目の実行では、88% に上昇した後、OOM が異常終了しました。

2 トラブルシューティング

2 つの現象があるため、主に 2 つの調査方針があります。1 つ目は、OOM の原因を追跡する目的でメモリ使用量をチェックすることで、メモリ問題のトラブルシューティングと呼ばれます。2 番目の項目は、TP99 の成長が遅い理由の調査であり、これはパフォーマンス低下のトラブルシューティングと呼ばれます。

2.1 パフォーマンス低下のトラブルシューティング

これはゆっくりとした上昇であり、ランプ サイクルはサービスの再起動に直接関係しているため、外部インターフェイスのパフォーマンスの問題が発生する可能性は排除できます。まずは自分のプログラムから原因を見つけてください。そのため、まずはGCの状況とメモリの状況を確認してください。以下は長期間再起動されていないGCログです。これは YGC であり、合計で 1.16 秒かかります。このうち、Ref Proc リンクには 1150.3 ミリ秒がかかり、JNI 弱参照の回復には 1.1420596 秒かかります。新しく再起動したマシンでは、JNI 弱参照の回復時間は 0.0000162 秒です。したがって、TP99 の増加は、JNI 弱参照のリサイクルサイクルの増加によって引き起こされていることがわかります。

JNI 弱参照は、名前が示すように、ネイティブ メモリの使用に関連している必要があります。ただし、ネイティブ メモリのトラブルシューティングは難しいためです。したがって、ヒープの使用状況から調査を開始し、手がかりが見つかるかどうかを確認することをお勧めします。

2.2 メモリの問題のトラブルシューティング

記憶に戻りますが、Jiange のリマインダーの後、まず問題を再現する必要があります。また、毎週トリガーされるタスクはメモリの増加を安定して再現するため、タスクのスケジュールを立てる方向からの確認が容易になります。@柳岩さんのご協力により、試算環境でいつでも問題を再現できるようになりました。

メモリ問題のトラブルシューティングは、依然としてヒープ内のメモリから始まります。ダンプを複数回実行した後、Java プロセスの合計メモリ使用量は増加し続けましたが、ヒープ メモリ使用量は大幅には増加しませんでした。root 権限を申請し、arthas をデプロイした後、arthas のダッシュボード機能を通じて、ヒープ (ヒープ) と非ヒープ (非ヒープ) が安定したままであることがはっきりとわかります。

アーサスダッシュボード

メモリ使用量が2倍になった

このことから、ネイティブ メモリ使用量の増加は、Java アプリケーション全体のメモリ使用量の増加につながると結論付けることができます。ネイティブを分析する最初のステップは、jvm -XX:NativeMemoryTracking=detail を有効にすることです。

2.2.1 jcmd を使用してメモリの全体的な状況を表示する

jcmd は、Java プロセスのすべてのメモリ割り当てを出力できます。NativeMemoryTracking=detail パラメータが有効な場合、ネイティブ メソッドのコール スタック情報を確認できます。root 権限を申請した後、yum を使用して直接インストールできます。

安装好后,执行如下命令。

jcmd <pid> VM.native_memory detail

jcmd結果表示

上の図には 2 つの部分があり、最初の部分は、合計メモリ使用量と分類使用量を含む全体的なメモリ状況の概要です。カテゴリには、Java ヒープ、クラス、スレッド、コード、GC、コンパイラ、内部、シンボル、ネイティブ メモリ トラッキング、アリーナ チャンク、不明が含まれます。各カテゴリの概要については、このドキュメントを参照してください。2 番目の部分では、各カテゴリを含む詳細が説明されています。セグメントメモリ割り当ての開始アドレスと終了アドレス、特定のサイズ、およびそれが属するカテゴリ。たとえば、スクリーンショットの部分では、Java ヒープに 8GB のメモリが割り当てられていることが説明されています (後で問題を迅速に再現するために、ヒープ サイズは 20GB から 8GB に調整されます)。後ろのインデントされた行は、メモリの特定の割り当てを表します。

比較するには、jcmd dump を 2 時間間隔で 2 回使用します。内部部分が大幅に成長していることがわかります。内部とは何ですか?なぜ成長しているのですか? Google の後、この分野の紹介はほとんどなく、基本的にはコマンドライン分析、JVMTI、その他の呼び出しであることがわかりました。@崔立园に相談したところ、JVMTI が Java エージェントに関連している可能性があることがわかりました。ルーティング計算では、pfinder のみが Java エージェントに関連するはずですが、基盤となるミドルウェアの問題はルーティングに影響するだけではないはずです。そこで、pfinder の調査と開発に失敗し、フォローアップへの投資を継続しませんでした。

2.2.2 pmap と gdb を使用したメモリの分析

まず、この手法の結論を述べておきますが、この分析には比較的推測が多く含まれるため、最初から試すことはお勧めしません。全体的なアイデアは、pmap を使用して Java プロセスによって割り当てられたすべてのメモリを出力し、疑わしいメモリ範囲を選択し、gdb を使用してダンプし、その内容を分析のためにエンコードして視覚化することです。
インターネット上には多数の関連ブログがあり、それらはすべて、多数の 64MB メモリ割り当てブロックの存在を分析することによってリンク漏洩のケースを特定しています。そこでプロセスも確認してみたところ、約 64MB もの大量のメモリ使用量が含まれていました。ブログの紹介によると、メモリがエンコードされた後の内容の大部分は JSF に関連しており、JSF netty が使用するメモリ プールであると推測できます。私たちが使用している JSF バージョン 1.7.4 にはメモリ プール リークがないため、関連する必要はありません。
pmap: https://docs.oracle.com/cd/E56344_01/html/E54075/pmap-1.html
gdb: https://segmentfault.com/a/1190000024435739

2.2.3 strace を使用してシステムコールを解析する

これは一種の運の分析手法と考えるべきでしょう。このアイデアは、strace を使用してメモリ割り当てごとにシステム コールを出力し、それを jstack 内のスレッドと照合することです。どの Java スレッドがネイティブ メモリを割り当てたかを判断するため。この種の効率は最も低く、特に RPC が多いサービスでは、システム コールが非常に頻繁に発生します。したがって、より明らかなメモリ リークを除いて、この方法でトラブルシューティングを行うのは簡単です。この記事のような遅いメモリ リークは、基本的に通常の呼び出しによって埋もれてしまうため、観察が困難になります。

2.3 問題箇所

一連の試みの後、根本原因は特定されませんでした。したがって、jcmd によって検出される内部メモリの増加現象を再度確認することから始めるしかありません。これまでのところ、メモリ割り当ての詳細に関する手がかりはまだ解析されておらず、1.2w 行のレコードがありますが、Internal に関連する手がかりを見つけることを期待してそれを調べることしかできません。

次の段落では、32k の内部メモリ領域を割り当てた後、JNIHandleBlock 関連の 2 つのメモリ割り当て (4GB と 2GB) があり、MemberNameTable 関連の呼び出しで 7GB のメモリが割り当てられていることがわかります。

[0x00007fa4aa9a1000 - 0x00007fa4aa9a9000] reserved and committed 32KB for Internal from
    [0x00007fa4a97be272] PerfMemory::create_memory_region(unsigned long)+0xaf2
    [0x00007fa4a97bcf24] PerfMemory::initialize()+0x44
    [0x00007fa4a98c5ead] Threads::create_vm(JavaVMInitArgs*, bool*)+0x1ad
    [0x00007fa4a952bde4] JNI_CreateJavaVM+0x74

[0x00007fa4aa9de000 - 0x00007fa4aaa1f000] reserved and committed 260KB for Thread Stack from
    [0x00007fa4a98c5ee6] Threads::create_vm(JavaVMInitArgs*, bool*)+0x1e6
    [0x00007fa4a952bde4] JNI_CreateJavaVM+0x74
    [0x00007fa4aa3df45e] JavaMain+0x9e
Details:

[0x00007fa4a946d1bd] GenericGrowableArray::raw_allocate(int)+0x17d
[0x00007fa4a971b836] MemberNameTable::add_member_name(_jobject*)+0x66
[0x00007fa4a9499ae4] InstanceKlass::add_member_name(Handle)+0x84
[0x00007fa4a971cb5d] MethodHandles::init_method_MemberName(Handle, CallInfo&)+0x28d
                             (malloc=7036942KB #10)

[0x00007fa4a9568d51] JNIHandleBlock::allocate_handle(oopDesc*)+0x2f1
[0x00007fa4a9568db1] JNIHandles::make_weak_global(Handle)+0x41
[0x00007fa4a9499a8a] InstanceKlass::add_member_name(Handle)+0x2a
[0x00007fa4a971cb5d] MethodHandles::init_method_MemberName(Handle, CallInfo&)+0x28d
                             (malloc=4371507KB #14347509)

[0x00007fa4a956821a] JNIHandleBlock::allocate_block(Thread*)+0xaa
[0x00007fa4a94e952b] JavaCallWrapper::JavaCallWrapper(methodHandle, Handle, JavaValue*, Thread*)+0x6b
[0x00007fa4a94ea3f4] JavaCalls::call_helper(JavaValue*, methodHandle*, JavaCallArguments*, Thread*)+0x884
[0x00007fa4a949dea1] InstanceKlass::register_finalizer(instanceOopDesc*, Thread*)+0xf1
                             (malloc=2626130KB #8619093)

[0x00007fa4a98e4473] Unsafe_AllocateMemory+0xc3
[0x00007fa496a89868]
                             (malloc=239454KB #723)

[0x00007fa4a91933d5] ArrayAllocator<unsigned long, (MemoryType)7>::allocate(unsigned long)+0x175
[0x00007fa4a9191cbb] BitMap::resize(unsigned long, bool)+0x6b
[0x00007fa4a9488339] OtherRegionsTable::add_reference(void*, int)+0x1c9
[0x00007fa4a94a45c4] InstanceKlass::oop_oop_iterate_nv(oopDesc*, FilterOutOfRegionClosure*)+0xb4
                             (malloc=157411KB #157411)

[0x00007fa4a956821a] JNIHandleBlock::allocate_block(Thread*)+0xaa
[0x00007fa4a94e952b] JavaCallWrapper::JavaCallWrapper(methodHandle, Handle, JavaValue*, Thread*)+0x6b
[0x00007fa4a94ea3f4] JavaCalls::call_helper(JavaValue*, methodHandle*, JavaCallArguments*, Thread*)+0x884
[0x00007fa4a94eb0d1] JavaCalls::call_virtual(JavaValue*, KlassHandle, Symbol*, Symbol*, JavaCallArguments*, Thread*)+0x321
                             (malloc=140557KB #461314)

2 つの期間の jcmd の出力を比較すると、JNIHandleBlock に関連するメモリ割り当てが増加し続けていることがわかります。したがって、リークの原因は JNIHandles::make_weak_global のメモリ割り当てであると結論付けることができます。では、このロジックは何を行っており、何がリークを引き起こしているのでしょうか?

Google を通じて、Jvm God の記事を見つけました。これが私たちの質問全体に答えてくれました。問題の現象は基本的に私たちの現象と一致しています。ブログ: https://blog.csdn.net/weixin_45583158/article/details/100143231

その中で、韓泉子は問題を再現するためのコードを与えました。コードにはほぼ同じセクションがありますが、これには運が関係します。

// 博客中的代码
public static void main(String args[]){

        while(true){

            MethodType type = MethodType.methodType(double.class, double.class);

            try {

                MethodHandle mh = lookup.findStatic(Math.class, "log", type);

            } catch (NoSuchMethodException e) {

                e.printStackTrace();

            } catch (IllegalAccessException e) {

                e.printStackTrace();

            }

        }

    }
}

jvm バグ:https://bugs.openjdk.org/browse/JDK-8152271

これは上記のバグで、MethodHandles 関連のリフレクションを頻繁に使用すると、期限切れのオブジェクトのリサイクルに失敗し、YGC スキャン時間も増加してパフォーマンスが低下します。

3 問題解決

jvm 1.8 では、この問題は 1.8 では対処されないことが明確になっているため、java でリファクタリングされます。しかし、すぐに Java にアップグレードすることはできません。したがって、JVM を直接アップグレードしてこの問題を修正する方法はありません。問題はリフレクションの多用であるため、キャッシュを追加して頻度を減らし、性能低下やメモリリークの問題を解決することが考えられます。スレッド安全性の問題を考慮して、キャッシュは ThreadLocal に配置され、再度漏洩を避けるために LRU の排除ルールが追加されます。

最終的な修復効果は、メモリの増加は通常のヒープメモリ設定範囲(8GB)内に抑えられ、増加率も比較的緩やかです。2 日間再起動した後、JNI 弱参照時間は予想どおり 0.0001583 秒になりました。

4 まとめ

ネイティブ メモリ リークのトラブルシューティングの考え方は、主にタイムシェアリング ダンプと比較に基づいたヒープ メモリのトラブルシューティングの考え方と似ています。異常値または異常な増加を観察して、問題の原因を特定します。ネイティブ メモリのツールとトラブルシューティング プロセスの違いにより、メモリ リークをスレッドに直接関連付けることは困難ですが、strace を使用して運試しをすることができます。また、限られた手がかりを頼りに検索エンジンで検索すると、関連する捜査過程が見つかり、思いがけない驚きが得られるかもしれません。結局のところ、jvm は依然として非常に信頼できるソフトウェアであるため、重大な問題が発生した場合でも、インターネット上で関連する解決策を簡単に見つけることができるはずです。インターネット上のコンテンツが少ない場合でも、ニッチすぎるソフトウェアに依存していないかどうかを検討する必要があるかもしれません。

開発に関しては、主流の開発パターンと設計パターンを使用するようにしてください。良い技術と悪い技術の区別はありませんが、リフレクションや AOP などの実装方法は使用範囲を制限する必要があります。これらのテクノロジはコードの可読性に影響を及ぼし、増加し続ける AOP ではパフォーマンスが徐々に低下しているためです。また、新しい技術に挑戦するという点では、エッジビジネスから始めてみてください。コアアプリケーションでは、まず安定性を意識することで、他の人が遭遇しにくい落とし穴を踏むことがなくなり、無用なトラブルを減らすことができます。

著者: JD Logistics Chen Haolong

出典: JD Cloud 開発者コミュニティ

{{名前}}
{{名前}}

おすすめ

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