Using Native Memory by JVM

2019.11.21

はじめに

こんにちは。事業開発部のこむろ@さっぽろです。

最近、諸事情から所属部署でどこにでも顔を出す人として活動しています。

今回はJVMのメモリ周りについて初めて調べました。

背景

Javaアプリケーションを利用している場合、最近ではContainerを利用してアプリケーションを起動しているところも多いかと思います。わたしの所属する事業開発部では、ECSを利用して複数のJavaアプリケーション(Spring Boot)をContainerで稼働させています。

Containerで稼働させるため一つのホストのリソースをすべて割り当てられるわけではありません。Containerには利用できるリソースにハードリミットが設けられているため、リソースの配分には少々気を使う必要があります。

今まであまり意識してチューニング等していなかったのですが(富豪的にメモリを割り当てたりしてて深く考えていなかった)、Containerで運用する上でこのあたりリソースの制御についての知識がほとんどなかったため苦労しています。実際にOOM KillerによってContainerが殺される事象もちらほら散見されています。これを改善しないといけない。

JVMの様々なチューニングフラグに纏わる情報に関しては、古くから様々な人達が知見を共有してくれているため、とても助かっています。今回改めてどのようなパラメータがどのように影響するのかを確認しました。今回はNativeメモリの割当についてのフラグを中心に調べました。

何が問題か

Containerで起動したJavaアプリケーションがOOM Killerで殺される事象が過去何度か発生していました。

上記がOOM Killerで殺されていた環境の設定です。直感的に危ないだろ、というのはわかるのですが、じゃあ具体的にどれくらい割り当てれば良いのかというのに答えられませんでした。

実際にどれくらい確保するのかを計測する

OOMで死んでるアプリケーションは本番環境なのでちょっと調査が難しいため、別の同じようなアプリケーションで調べてみました。

結果思ったような数値になっていませんでした ?

コンテナのハードリミットを1024MB, ヒープサイズを512MB で指定しているはずなのですが、想定よりも数値が大きいようです。

Reservedに至っては 2 GB近く、Committedもヒープサイズを遥かに超える 850MB 近くを確保しています。コンテナハードリミットが 1024 MB にも関わらずReservedできているのは、ハードリミットは物理メモリの限界値として動作し、スワップを含めた仮想メモリの関係で見た目上確保できているからと思われます。

# jcmd <PROCESS_ID> VM.native_memory summary
<PROCESS_ID>:

Native Memory Tracking:

Total: reserved=2088136KB, committed=849028KB
...

どうやらNativeメモリを確保しようと頑張った結果、OOM Killerによって殺されてるのが確定っぽい。

つまりこういうイメージでしょうか。

ヒープ以外にJVMが消費するメモリについてあまり詳細を把握していなかったのですが、よく考えるとヒープが消費するメモリ以外にもNativeメモリを確保するものや、Container OSの消費するメモリも少なからず存在するはずです。今までは根拠なく、なんとなくこれくらいで大丈夫そうといった数値が割り当てられていました。これは確かに気持ち悪い。

そもそも何にNativeメモリを消費しているのか?JVMで利用するNativeメモリは高々いくつなのか?ひとまず正体を知らないと対処のしようがありません。

JVMが消費するメモリ

JVMを利用するにあたってヒープの割当は説明するまでも無いかと思います。アプリケーションコードの多くはこのヒープを消費して実行されるため、Java初心者でも必ず意識するリソースです。しかし、JVMのヒープの他にメモリを消費します。まずはこれら何が利用しているのかを確認しました。

ロードされたクラスのメタデータ

JVMで実行されるJavaアプリケーションはロードしたクラスデータを利用します。そこでJava8以降ではこのクラスのメタデータをMetaspaceと呼ばれる、非ヒープ領域(つまりNativeメモリの領域)を使用します。ここはあくまでクラスのメタデータを格納する箇所であって、インスタンス情報はヒープ領域に格納されます。

非常に単純なJavaアプリケーションの場合は、ロードされるクラスデータは高々数十程度のものです。従ってロードする対象はそこまで多くはありません。しかし、SpringBootなどのフレームワークや様々なライブラリを組み合わせて利用する時、ロードする対象のクラスが非常に多くなります。

基本的には起動時に大量のクラスを読み込むのが多いかと思いますが、モノによっては動的にクラスデータをロードするものなどがある場合、実行中に値が前後するようです。そしてこのMetaspaceのデフォルトの最大値は以下の通り。 *1

$ docker run --rm --memory 1024m classmethod/openjdk-with-git:8-jdk java -XX:+PrintFlagsFinal -version | grep -w MaxMetaspaceSize
    uintx MaxMetaspaceSize                          = 18446744073709547520                    {product}

18446744073709547520バイト = 18エクサバイト がデフォルトの最大値です。つまり実質物理メモリがある限り使いまくる設定になっています(とはいえ無尽蔵にクラスがロードされるアプリケーションというのがどういうものか想像がつかないですが)

Metaspaceが枯渇するとJVMはOutOfMemoryErrorを発生します。最大値は -XX:MaxMetaspaceSize で明示的に指定可能です。

$ docker run --rm --memory 1024m classmethod/openjdk-with-git:8-jdk java -XX:+PrintFlagsFinal -XX:MaxMetaspaceSize=512m -version | grep -w MaxMetaspaceSize
    uintx MaxMetaspaceSize                         := 536870912                           {product}
openjdk version "1.8.0_232"
OpenJDK Runtime Environment (build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM (build 25.232-b09, mixed mode)

Code Cache

JVMはルールに従ってJITを使い、実行時にバイトコードからマシン語へコンパイルを行っています。これにより高速な処理が可能になっています。このコンパイル結果を保持しているのがCode Cacheです。これも非ヒープ領域、つまりNativeメモリを利用しています。

この領域はいくつかの関連パラメータに従って動作します。枯渇しそうになるとJITコンパイルが停止され、Code Cacheの利用可能な領域がある一定以上確保できるまで不要なコンパイル結果を削除します。そのため、JITコンパイルが再開されるまでインタプリタによる動作になるため非常に低速な処理となるようです。

$ docker run --rm --memory 1024m classmethod/openjdk-with-git:8-jdk java -XX:+PrintFlagsFinal -XX:MaxMetaspaceSize=512m -version | grep CodeCache
    uintx CodeCacheExpansionSize                    = 65536                               {pd product}
    uintx CodeCacheMinimumFreeSpace                 = 512000                              {product}
    uintx InitialCodeCacheSize                      = 2555904                             {pd product}
     bool PrintCodeCache                            = false                               {product}
     bool PrintCodeCacheOnCompilation               = false                               {product}
    uintx ReservedCodeCacheSize                     = 251658240                           {pd product}
     bool UseCodeCacheFlushing                      = true                                {product}
openjdk version "1.8.0_232"
OpenJDK Runtime Environment (build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM (build 25.232-b09, mixed mode)

UseCodeCacheFlushing

デフォルトで有効になっています。

これが有効な場合、 ReservedCodeCacheSizeCodeCacheMinimumFreeSpace を下回るとJITコンパイルが一時停止され、不要とされたコンパイル済みのコードを破棄します。

Java7までは CodeCacheFlushingMinimumFreeSpace というパラメータが存在し、これをReservedCodeCacheSizeが上回るまで削除し続けるという記事を何件か見つけたのですが、Java8以降ではこのパラメータが見当たりません。そのため、JITコンパイルが再開されるのがどの条件になったらというのが見つけられませんでした。

ReservedCodeCacheSize

CodeCacheを格納できる最大のサイズです。OpenJDK 64-Bit1.8.9_232 ではデフォルトで 240 MiBが割り当てられています。最悪この値までNativeメモリが必要になると思って見積もっておいた方が良さそう。

Thread

スレッドはスタックと呼ばれる領域を確保し、1スレッドにつき1スタックを必要とします。そしてこのスタックはデフォルトでは1MBが割り当てられます。

つまり、1スレッドにつき1MBのNativeメモリを確保します。従って最大のスレッド数がいくつかになるかによってThreadが必要とする最大のメモリ量は変化します。

算出については以下の一連のTweetが非常に参考になりました。

Spring Bootアプリケーションで組み込みのTomcatを利用している場合は、デフォルトのスレッド数が200。これに余裕を見て50ほど加えたものを最大の同時利用スレッド数と考えると

  • 250 Threads * 1 MB = 250 MB

とするようです。

スタックのサイズと動作については以下のリンクに詳細が記載されていました。

Akira's Tech Notes - 2015-04-22-[調査]jvmのスタックサイズについて

$ docker run --rm --memory 1024m classmethod/openjdk-with-git:8-jdk java -XX:+PrintFlagsFinal -XX:MaxMetaspaceSize=512m -version | grep -w ThreadStackSize
     intx ThreadStackSize                           = 1024                                {pd product}
openjdk version "1.8.0_232"
OpenJDK Runtime Environment (build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM (build 25.232-b09, mixed mode)

特にリクエスト等を受けてない状態でのSpring Bootアプリケーションの状態を確認してみると以下のようになっていました。

# jcmd <PROCESS_ID> VM.native_memory summary.diff
<PROCESS_ID>:

Native Memory Tracking:

Total: reserved=2088136KB +32222KB, committed=849028KB +55486KB

-                 Java Heap (reserved=524288KB, committed=524288KB)
                            (mmap: reserved=524288KB, committed=524288KB)

-                     Class (reserved=1146355KB +13127KB, committed=110499KB +13639KB)
                            (classes #19325 +1577)
                            (malloc=3571KB +839KB #35531 +9767)
                            (mmap: reserved=1142784KB +12288KB, committed=106928KB +12800KB)

-                    Thread (reserved=76373KB +10325KB, committed=76373KB +10325KB)
                            (thread #75 +10)
                            (stack: reserved=76044KB +10280KB, committed=76044KB +10280KB)
                            (malloc=242KB +33KB #374 +50)
                            (arena=87KB +12 #146 +20)
                            ...

75個のスレッドが動作しており、メモリは75 MBほど利用しているのが分かります。

GC

Gabage CollectionもNativeメモリを消費する登場人物の一人です。JVMは用途に応じていくつかのアルゴリズムを切り替えることができます。またこれらはGCのアルゴリズムによって消費するメモリ量も変化するようです。今回のアプリケーションではCMSを選択しています。

Java Platform, Standard Edition HotSpot Virtual Machineガベージ・コレクション・チューニング・ガイド

Java9以降標準となるG1GCに比べ、Heapが4GBの環境であれば高速に動作するとのこと。この選択によって使用するメモリ量が変化します。CMSの場合以下のような数値を計測しました。

-                        GC (reserved=4209KB, committed=4209KB)
                            (malloc=2321KB #553)
                            (mmap: reserved=1888KB, committed=1888KB)

4MB弱を利用するようです。Serial GC等の比較的単純なアルゴリズムを選ぶと1 MB弱の利用で済むとのこと。

JVM Anatomy Quark #12: Native Memory Tracking

その他

SymbolといったStringテーブル、定数値のPoolといったものもありました。これらはどちらかというと稼働後に成長することはあまり無いのではないかと思っています。

またNIOやJNIを利用している場合は、プログラマが直接Nativeメモリへアクセスすることがあります。この場合もメモリは消費するため場合によっては成長の早い値かもしれません。

まとめ

JVMが利用するヒープ以外のメモリについての詳細を調べてみました。アプリケーションエンジニアで実際に稼働まで持っていかない人とかだとあまり馴染みがないかもしれません。

Container技術が席巻している今、より効率よくリソースを使うためにこういった稼働環境のリソース見積もりは思っている以上に重要になっているのではないでしょうか。

これら計測に利用したツールや他のチューニングフラグ等についてはまたいつか別の機会に記載したい。

他にもちゃんと知識を得るならOracleから出ているドキュメントが参考になりそう。

Oracle - The Java® Virtual Machine Specification Java SE 11 Edition

参照

脚注

  1. 確認のコマンドはFolioscope - JVMのヒープサイズとコンテナ時代のチューニング こちらを参照させていただきました :pray: ​