原文はこちら。
The original article was written by Poonam Parhar (JVM Sustaining Engineer, Oracle).
https://poonamparhar.github.io/dynamic_compiler_threads/
UseDynamicNumberOfCompilerThreads はJVMのオプションで、Javaバイトコードを最適化されたマシンコードにコンパイルするJava HotSpot JVMのJITコンパイラが利用する動的にコンパイラスレッド数を制御します。
このオプションを有効化すると、HotSpot JVMがアプリケーションのワークロードに基づいて動的にコンパイラスレッドの個数を調整します。これはつまりコンパイル対象のメソッドの多少によってスレッド個数をJVMが自動的に割り当てます。UseDynamicNumberOfCompilerThreadsはJDK 11以後でデフォルトで有効化されています。
このオプションは、利用可能なCPUリソースの活用を最大化することでJavaアプリケーションのパフォーマンスを向上する点で、非常に有用な場合があります。しかしながら、あるワークロードでは動的にコンパイラスレッドを作成・終了することにより、GLIBCアロケータが割り当てるメモリを保持し続けることが予期せず発生する可能性があり、プロセスのResident Set Size (RSS、物理メモリの消費量) の増加を招くことがあります。
このエントリでは、この問題の特定および診断方法について説明していきます。また、この問題の回避策やこの問題の解決策についても説明します。
Monitoring RSS
では、JavaプロセスのRSSを監視する方法を見ていきましょう。Linuxの場合、ps、top、pmap、/proc/<pid>/smapsファイルなどを使ってRSSを監視できます。例えば、以下のスクリプトは指定したプロセスIDのsmapsファイルを読み取り、現在のタイムスタンプをつけて5秒間隔でRSSを出力ファイルに書き出します。
#!/bin/bash
pid=$1
smaps_file=/proc/$pid/smaps
output_file=rss_$pid.out
rm rss_$pid.out
echo 'Monitoring RSS of the Process' $pid. Saving output to file $output_file.
while true
do
# Get the current timestamp
timestamp=$(date +"%FT%T%z")
# Get the current RSS value
rss=$(grep -m 1 "^Rss:" "$smaps_file" | awk '{print $2}')
# write timestamp and rss to the output file
echo "$timestamp: $rss" >> "$output_file"
sleep 5
done
Increased RSS with +UseDynamicNumberOfCompilerThreads
JVM JITコンパイラがコンパイルリクエストを大量に受けると コンパイラスレッドの作成・終了が繰り返し発生するため、RSSの顕著な増加が確認されることがあります。動的なコンパイラスレッドの作成・終了は、TraceCompilerThreadsというJVMオプションでトレースできます。これは診断オプションで、-XX:+UnlockDiagnosticVMOptionsとともに利用する必要があります。
$ java -XX:+UnlockDiagnosticVMOptions -XX:+TraceCompilerThreads <java program>
...
Added compiler thread C2 CompilerThread2 (available memory: 1266MB, available non-profiled code cache: 114MB)
Added compiler thread C2 CompilerThread3 (available memory: 1266MB, available non-profiled code cache: 114MB)
Removing compiler thread C2 CompilerThread3 after 171 ms idle time
Removing compiler thread C2 CompilerThread2 after 364 ms idle time
Removing compiler thread C2 CompilerThread1 after 166 ms idle time
Added compiler thread C2 CompilerThread1 (available memory: 1086MB, available non-profiled code cache: 113MB)
Added compiler thread C2 CompilerThread2 (available memory: 1086MB, available non-profiled code cache: 113MB)
Added compiler thread C2 CompilerThread3 (available memory: 1086MB, available non-profiled code cache: 113MB)
...
DaCapo Benchmark suiteのjythonベンチマークでこの動作を再現できます。動的コンパイラスレッドの有無の条件下でベンチマークを実行すると、以下のようなRSSのトレンドを確認できます。

ごらんの通り、-XX:+UseDynamicNumberOfCompilerThreadsをつけたプロセスのRSSは、-XX:-UseDynamicNumberOfCompilerThreadsの場合に比べて少々RSSが大きいことがわかります。
Why this RSS bloat?
GLIBCはアリーナ (arena) と呼ばれる複数のメモリプールを使用し、そこからmalloc()で行われる割り当て要求を満たします。per-thread malloc arena (スレッドごとの malloc アリーナ) 機能により、GLIBCは各スレッドに新しいアリーナが作成可能であれば、それを使用するようにして、競合を減らし、マルチスレッドの割り当てパフォーマンスを向上させようとします。
MallocInternals
https://sourceware.org/glibc/wiki/MallocInternals#Arenas_and_Heaps
しかしながら、これはコストが伴います。つまり余分にメモリを使用します。複数のアリーナを異なるスレッドで使うと、メモリフラグメンテーションの発生や、free()を使って解放したとしてもGLIBCのアロケータがメモリを長く保持する可能性があります。
さて、これがUseDynamicNumberOfCompilerThreadsとどう関係するのでしょうか?UseDynamicNumberOfCompilerThreadsオプションを有効にすると、JVMは、JITコンパイラのキューのコンパイルリクエストの増減に応じて、コンパイラスレッドを必要に応じて作成したり終了したりできます。このような動的コンパイラースレッドは、アプリケーションが多数の異なるアリーナを使用することで、mallocによるメモリ断片化や過剰なメモリ保持を引き起こす可能性があります。
per-thread malloc arena問題については、以下のglibcバグレポート詳細な説明があります。
Bug 11261 – malloc uses excessive memory for multi-threaded applications
https://sourceware.org/bugzilla/show_bug.cgi?id=11261
Workarounds and Solutions
このRSS増加の問題を回避するために使用できる回避策と解決策を紹介します。
1. -XX:-UseDynamicNumberOfCompilerThreads
-XX:-UseDynamicNumberOfCompilerThreadsで、動的にコンパイラスレッドを増減する機能を無効化できます。
2. -XX:+UnlockDiagnosticVMOptions -XX:-ReduceNumberOfCompilerThreads
診断フラグReduceNumberOfCompilerThreadsを -XX:+UnlockDiagnosticVMOptions -XX:-ReduceNumberOfCompilerThreadsで無効化するのがもう一つの回避策です。これにより、JVMは少数のコンパイラスレッドで開始し、必要に応じてコンパイラスレッドを作成しますが、その個数を減らさないようにできます。
3. Malloc Tunables
GLIBCには、メモリアロケータをチューニングできるしくみがあります。特に、MALLOC_ARENA_MAX環境変数で作成可能なアリーナの最大個数を制御できます。 アリーナの個数が少ないと、多数のスレッドの競合の可能性がありますが、個数が多いとメモリフットプリントの増加につながる可能性があります。MALLOC_ARENA_MAXのデフォルト値はCPU個数に基づいて動的に算出されます。64ビットシステムの場合、CPUの個数×8です。CPU個数のが多いマシンの場合、アリーナの最大個数が非常に多くなる可能性があります。MALLOC_ARENA_MAXを小さく設定する(例えば2)とすることで、フラグメンテーションを制限し、RSSの不必要な増加を回避できます。
GLIBC mallocの環境変数は、そのallocation tunableを使用して設定することもできます。詳細は以下をご覧ください。
Memory Allocation Tunables (The GNU C Library)
https://www.gnu.org/software/libc/manual/html_node/Memory-Allocation-Tunables.html
4. malloc_trim()
GLIBCのmalloc_trim()関数を使って、解放されたネイティブヒープからOSにリリースできます。Javaプロセスの場合、jcmdの診断コマンドSystem.trim_native_heapを使って、JVM内でのmalloc_trim()の呼び出しをトリガーできます。以下の例では、このコマンドの使用例です。
jcmd <process id> System.trim_native_heap
重要なこととして、malloc_trim()は高コストゆえ、過剰または不必要な使用はパフォーマンスに影響を与える可能性がありますので、特定のユースケースに基づくトレードオフを考慮して慎重に使用してください。
Summary
特定のワークロードでは、を有効化すると、多数のスレッドごとのメモリアリーナが生成され、結果としてJavaプロセスのメモリフットプリントが望ましくないほど増加することがあります。この挙動は以下のいずれかの方法で制御できます。
-XX:-UseDynamicNumberOfCompilerThreadsもしくは-XX:-ReduceNumberOfCompilerThreadsを使う- mallocアリーナの最大個数を小さくする
- ‘
jcmd System.trim_native_heap’ をJavaアプリケーションに対して定期的に呼び出す
いずれも方法も、アプリケーションパフォーマンスに影響がある点はご認識ください。