1. 概要

よく知られている-Xmsおよび-Xmxチューニングフラグを介して、Javaアプリケーションが指定された量よりもはるかに多くのメモリを消費する理由を疑問に思ったことはありませんか。 さまざまな理由と可能な最適化のために、JVMは追加のネイティブメモリを割り当てる場合があります。 これらの追加の割り当てにより、最終的には-Xmxの制限を超えて消費メモリが増加する可能性があります。

このチュートリアルでは、JVMでのネイティブメモリ割り当てのいくつかの一般的なソースとそれらのサイズ調整フラグを列挙し、ネイティブメモリトラッキングを使用してそれらを監視する方法を学習します。

2. ネイティブ割り当て

ヒープは通常、Javaアプリケーションのメモリの最大の消費者ですが、他にもあります。 ヒープのほかに、JVMはネイティブメモリからかなり大きなチャンクを割り当てて、クラスメタデータ、アプリケーションコード、JITによって生成されたコード、内部データ構造などを維持します。次のセクションでは、それらの割り当てのいくつかを調べてください。

2.1. メタスペース

ロードされたクラスに関するメタデータを維持するために、JVMはMetaspaceと呼ばれる専用の非ヒープ領域を使用します。 Java 8より前は、同等のものはPermGenまたはPermanentGenerationと呼ばれていました。 MetaspaceまたはPermGenには、ヒープ内に保持されているインスタンスではなく、ロードされたクラスに関するメタデータが含まれています。

ここで重要なのは、メタスペースはオフヒープデータ領域であるため、ヒープサイズ設定はメタスペースサイズに影響を与えないということです。 メタスペースのサイズを制限するために、他のチューニングフラグを使用します。

  • -XX:MetaspaceSize および-XX:MaxMetaspaceSize は、最小および最大のメタスペースサイズを設定します
  • Java 8より前では、 -XX:PermSize および-XX:MaxPermSize を使用して、PermGenの最小サイズと最大サイズを設定します。

2.2. スレッド

JVMで最もメモリを消費するデータ領域の1つは、各スレッドと同時に作成されるスタックです。 スタックはローカル変数と部分的な結果を格納し、メソッドの呼び出しで重要な役割を果たします。

デフォルトのスレッドスタックサイズはプラットフォームによって異なりますが、最新の64ビットオペレーティングシステムのほとんどでは、約1MBです。 このサイズは、-Xss調整フラグを使用して構成できます。

他のデータ領域とは対照的に、スタックに割り当てられるメモリの合計は、スレッド数に制限がない場合、実質的に制限がありません。 JVM自体が、内部操作を実行するためにいくつかのスレッドを必要とすることにも言及する価値があります。 GCやジャストインタイムコンパイルのように。

2.3. コードキャッシュ

さまざまなプラットフォームでJVMバイトコードを実行するには、マシン命令に変換する必要があります。 JITコンパイラは、プログラムの実行時にこのコンパイルを担当します。

JVMがバイトコードをアセンブリ命令にコンパイルするとき、JVMはそれらの命令をコードキャッシュと呼ばれる特別な非ヒープデータ領域に格納します。 コードキャッシュは、JVMの他のデータ領域と同じように管理できます。 -XX:InitialCodeCacheSize および-XX:ReservedCodeCacheSize 調整フラグは、コードキャッシュの初期および最大可能サイズを決定します。

2.4. ガベージコレクション

JVMには、それぞれがさまざまなユースケースに適した少数のGCアルゴリズムが付属しています。 これらのGCアルゴリズムはすべて、1つの共通の特徴を共有しています。つまり、タスクを実行するためにいくつかのオフヒープデータ構造を使用する必要があります。 これらの内部データ構造は、より多くのネイティブメモリを消費します。

2.5. 記号

アプリケーションおよびライブラリコードで最も一般的に使用されるデータ型の1つである文字列から始めましょう。 それらは遍在しているため、通常、ヒープの大部分を占めます。 これらの文字列の多くに同じコンテンツが含まれている場合、ヒープのかなりの部分が無駄になります。

ヒープスペースを節約するために、各文字列の1つのバージョンを保存し、他の人に保存されたバージョンを参照させることができます。 このプロセスは文字列インターニングと呼ばれます。  JVMはインターンしかできないのでコンパイル時の文字列定数、 手動で呼び出すことができますインターン() インターンする予定の文字列のメソッド。

JVMは、インターンされた文字列を、文字列テーブルと呼ばれる特別なネイティブの固定サイズのハッシュテーブルに格納します。テーブルサイズを構成します(つまり、 -XX:StringTableSize調整フラグを介したバケットの数)。

文字列テーブルに加えて、と呼ばれる別のネイティブデータ領域がありますランタイム定数プール。 JVMはこのプールを使用して、実行時に解決する必要のあるコンパイル時の数値リテラルやメソッドおよびフィールド参照などの定数を格納します。

2.6. ネイティブバイトバッファ

JVMは、かなりの数のネイティブ割り当ての通常の疑いがありますが、開発者がネイティブメモリを直接割り当てることもできます。 最も一般的なアプローチは、JNIおよびNIOの直接ByteBuffersによるmalloc呼び出しです。

2.7. 追加のチューニングフラグ

このセクションでは、さまざまな最適化シナリオにいくつかのJVMチューニングフラグを使用しました。 次のヒントを使用すると、特定の概念に関連するほぼすべてのチューニングフラグを見つけることができます。

$ java -XX:+PrintFlagsFinal -version | grep <concept>

PrintFlagsFinal は、JVMのすべての– XXオプションを出力します。 たとえば、メタスペースに関連するすべてのフラグを検索するには、次のようにします。

$ java -XX:+PrintFlagsFinal -version | grep Metaspace
      // truncated
      uintx MaxMetaspaceSize                          = 18446744073709547520                    {product}
      uintx MetaspaceSize                             = 21807104                                {pd product}
      // truncated

3. ネイティブメモリトラッキング(NMT)

JVMでのネイティブメモリ割り当ての一般的なソースがわかったので、次にそれらを監視する方法を見つけます。 まず、さらに別のJVMチューニングフラグ-XX:NativeMemoryTracking = off | summary|detailを使用してネイティブメモリトラッキングを有効にする必要があります。 デフォルトでは、NMTはオフになっていますが、観測の要約または詳細ビューを表示できるようにすることができます。

典型的なSpring Bootアプリケーションのネイティブ割り当てを追跡したいとします。

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar

ここでは、GCアルゴリズムとしてG1を使用して、300MBのヒープスペースを割り当てながらNMTを有効にしています。

3.1. インスタントスナップショット

NMTが有効になっている場合、jcmdコマンドを使用していつでもネイティブメモリ情報を取得できます。

$ jcmd <pid> VM.native_memory

JVMアプリケーションのPIDを見つけるために、 jps コマンドを使用できます。

$ jps -l                    
7858 app.jar // This is our app
7899 sun.tools.jps.Jps

ここで、適切なpidとともにjcmdを使用すると、 VM.native_memory により、JVMはネイティブ割り当てに関する情報を出力します。

$ jcmd 7858 VM.native_memory

NMT出力をセクションごとに分析してみましょう。

3.2. 合計割り当て

NMTは、予約済みメモリとコミット済みメモリの合計を次のように報告します。

Native Memory Tracking:
Total: reserved=1731124KB, committed=448152KB

予約済みメモリは、アプリが潜在的に使用できるメモリの合計量を表します。 逆に、コミットされたメモリは、アプリが現在使用しているメモリの量と同じです。

300 MBのヒープを割り当てているにもかかわらず、アプリの予約済みメモリの合計はほぼ1.7 GBであり、それをはるかに上回っています。 同様に、コミットされたメモリは約440 MBであり、これも300MBをはるかに上回っています。

合計セクションの後、NMTは割り当てソースごとのメモリ割り当てを報告します。 それでは、各ソースを詳しく調べてみましょう。

3.3. ヒープ

NMTは、予想どおりにヒープ割り当てを報告します。

Java Heap (reserved=307200KB, committed=307200KB)
          (mmap: reserved=307200KB, committed=307200KB)

ヒープサイズ設定と一致する300MBの予約済みメモリとコミット済みメモリの両方。

3.4. メタスペース

ロードされたクラスのクラスメタデータについてNMTが言うことは次のとおりです。

Class (reserved=1091407KB, committed=45815KB)
      (classes #6566)
      (malloc=10063KB #8519) 
      (mmap: reserved=1081344KB, committed=35752KB)

ほぼ1GBが予約され、45MBが6566クラスのロードにコミットされています。

3.5. スレッド

そして、これがスレッド割り当てに関するNMTレポートです。

Thread (reserved=37018KB, committed=37018KB)
       (thread #37)
       (stack: reserved=36864KB, committed=36864KB)
       (malloc=112KB #190) 
       (arena=42KB #72)

合計で、36 MBのメモリが37スレッドのスタックに割り当てられます(スタックあたりほぼ1 MB)。 JVMは、作成時にメモリをスレッドに割り当てるため、予約された割り当てとコミットされた割り当ては等しくなります。

3.6. コードキャッシュ

JITによって生成およびキャッシュされたアセンブリ命令についてNMTが何を言っているか見てみましょう。

Code (reserved=251549KB, committed=14169KB)
     (malloc=1949KB #3424) 
     (mmap: reserved=249600KB, committed=12220KB)

現在、ほぼ13 MBのコードがキャッシュされており、この量は約245MBに達する可能性があります。

3.7. GC

G1GCのメモリ使用量に関するNMTレポートは次のとおりです。

GC (reserved=61771KB, committed=61771KB)
   (malloc=17603KB #4501) 
   (mmap: reserved=44168KB, committed=44168KB)

ご覧のとおり、約60 MBが予約されており、G1の支援に取り組んでいます。

シリアルGCのように、はるかに単純なGCのメモリ使用量がどのようになるかを見てみましょう。

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar

シリアルGCは1MBをほとんど使用しません。

GC (reserved=1034KB, committed=1034KB)
   (malloc=26KB #158) 
   (mmap: reserved=1008KB, committed=1008KB)

明らかに、メモリ使用量だけを理由にGCアルゴリズムを選択するべきではありません。これは、シリアルGCの世界的な性質がパフォーマンスの低下を引き起こす可能性があるためです。 ただし、にはから選択できるいくつかのGCがあり、それぞれメモリとパフォーマンスのバランスが異なります。

3.8. シンボル

文字列テーブルや定数プールなどのシンボル割り当てに関するNMTレポートは次のとおりです。

Symbol (reserved=10148KB, committed=10148KB)
       (malloc=7295KB #66194) 
       (arena=2853KB #1)

シンボルには約10MBが割り当てられています。

3.9. 時間の経過に伴うNMT

NMTを使用すると、メモリ割り当てが時間の経過とともにどのように変化するかを追跡できます。まず、アプリケーションの現在の状態をベースラインとしてマークする必要があります。

$ jcmd <pid> VM.native_memory baseline
Baseline succeeded

次に、しばらくすると、現在のメモリ使用量をそのベースラインと比較できます。

$ jcmd <pid> VM.native_memory summary.diff

NMTは、+および–記号を使用して、その期間中にメモリ使用量がどのように変化したかを示します。

Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB
-             Java Heap (reserved=307200KB, committed=307200KB)
                        (mmap: reserved=307200KB, committed=307200KB)
 
-             Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB)
// Truncated

予約済みメモリとコミット済みメモリの合計は、それぞれ3MBと6MB増加しました。 メモリ割り当ての他の変動は、同じように簡単に見つけることができます。

3.10. 詳細なNMT

NMTは、メモリ空間全体のマップに関する非常に詳細な情報を提供できます。 この詳細レポートを有効にするには、 -XX:NativeMemoryTracking =detail調整フラグを使用する必要があります。

4. 結論

この記事では、JVMでのネイティブメモリ割り当てへのさまざまな貢献者を列挙しました。 次に、実行中のアプリケーションを検査してネイティブ割り当てを監視する方法を学びました。 これらの洞察により、アプリケーションをより効果的に調整し、ランタイム環境のサイズを決定できます。