yoskhdia’s diary

DDDとかプログラミングとかアーキテクチャとか雑多に

JVMアプリケーションを運用する際のメジャーどころチューニングポイントメモ

JVMにチューニング項目は多々あれど、プロダクションで運用する際に予めおさえておきたい項目をまとめてみるエントリです。*1 勿論、OSもJVMもデフォルトである程度のパフォーマンスは発揮でき、計測を伴わないチューニングは悪手であることはよく知られています。 しかし、設定しておかないとパフォーマンスにそのまま影響すると分かるものを調べないのは裸で戦場に赴くようなものです。*2 どんな項目をどう変更すれば良いのか知っていることは重要な武器なのです。

なぜ調べるのか

今回、チューニングポイントを調べるにあたって、私のモチベーションはどこにあるのかを考えると、以下の要件を満たしたいということがあげられます。

  • アプリケーションとして求められる品質水準として動作する → 性能目標
  • 異常時に事象を追うことができる

ここでいう品質水準・異常とは、パフォーマンスが明らかに低い、アプリケーションがクラッシュする、などの(JVM・アプリケーション双方の)期待しない振る舞いを指します。

性能目標

やみくもにチューニングしても終わりがなく、何をチューニングすれば良いかも判断しづらいものです。 性能目標を定めることで、このラインをクリアし続けることを目指す方が健全です。 例えば以下のような項目があげられるかと思います。

TL;DR

だいたいのことはJavaパフォーマンスに書いてあるので、これを読もう。

www.oreilly.co.jp


なお、今回の主な関心範囲はウェブアプリケーションJVMおよびそのうえで動くアプリケーション)を想定しています。*3 代表的なものをあげつつ、これは最低限チェックしたいというものに ★ を付けています。

Kernel

JVMの前に土台についても少し考えます。 ホストのリソースを十分解放しないと、当然JVMもパフォーマンスを発揮できません。 昨今ではDockerなどのコンテナ技術も当たり前になりつつあり、ホストだけでなくコンテナのチューニングも必要になっているように思います。

transparent huge page

メモリのページサイズが大きくなれば、それだけ少ない回数でI/Oができるため高速化につながります。 JVM以外にもシステム上のすべてのプログラムが使用するようになる点に注意が必要です。 JVM側へのオプションの指定は不要(-XX:+UseLargePagesは別のものなので無効のままにしておく)です。

# cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never
# echo always > /sys/kernel/mm/transparent_hugepage/enabled
# cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

Kubernetes上で動く際のリソース制限

コンテナオーケストレーションのためにKubernetesを使うケースも多くなってきました。 アプリケーションはPodの上のコンテナ単位で動作しますが、このコンテナにリソースのrequest/limitを指定できます。明示しなくても動作させられますが、指定した方がアプリ側の見積もりもしやすいかなと思っています。 このlimitを元にアプリケーション(JVM)の設定を決めていけば良いでしょう。*4

  • CPUは絶対値指定
    • いくつCPUを使うかを指定
    • milli単位で指定可能
    • NodeにいくつvCPUがあっても絶対値指定 1 cpu = 1 vCPU
  • Memoryも絶対値指定

いずれも、リソースを指定しない場合、あるだけ使おうとするため、複数のPodを一つのNodeに載せたときの動作が不安定となる恐れがあります。*5 大きい数を指定してもNodeのリソースが足りないと起動すらできません。

see also:

JDK 10のコンテナサポート

JDK 10からコンテナサポートが強化されます。 -XX:-UseContainerSupport がデフォルトで有効となり、JDK 10からはDockerの設定*6から値を取得するようになるようです。

DockerではいくつかのCPU設定が可能ですが、JVMmin(cpuset-cpus, cpu-shares/1024, cpus) という計算により active_processor_count を決定するようです。端数の場合は切り上げされて整数になります。 cpusは cpu_quota / cpu_period によって求められる値です。 この値は -XX:ActiveProcessorCount=N によって上書きすることができます。

メモリはJDK 10からFraction(1/N)による指定ではなくPercentage(%)による指定ができるようになります。 使用可能なホストメモリの割合を指定します。

Kubernetes上で動作する場合、Kubernetesのドキュメントによると、次のようにコンテナにパラメータが渡されるとあります。

  • spec.containers[].resources.requests.cpu はコア値に変換し1024を掛けた値が --cpu-shares として渡される
  • spec.containers[].resources.limits.cpu はミリコア値に変換し100を掛けた値が --cpu-quota として渡される*7
    • コンテナが100ms*8に使用できるCPU時間の合計量
  • spec.containers[].resources.limits.memory はそのまま --memory として渡される

たとえば、request.cpuとlimits.cpuの両方を1とした場合は、cpu-shares: 1 * 1024 / 1024 = 1cpucpu-quota: 1000m * 100 / 100ms = 1cpu/ms となるのでJVMにはCPU 1としてセットされます。 つまり、Kubernetesの定義する1cpuは、そのままJVMから見るCPU数となります。 ですので、両者の値に差があるとき、JDK 10では前述の計算式で最小値がCPUとなると思われます。※未検証

see also:

JVM

書籍「Javaパフォーマンス(オライリー)」は、一般に必要になることを網羅しており、とりあえずコレを読んでおけばだいたいのケースでは十分でしょうと思える一冊です。

JIT

  • -server -XX:+TieredCompilation
    • 階層コンパイル(client compiler的に最初は動きながら、情報が溜まってきたらserver compilerとして動作する)
    • clientは早期からコンパイルし、serverは情報が溜まってからより有効なコンパイルを行う(後述の閾値による)
    • 64bit OS Java8だとデフォルト有効
  • -XX:ReservedCodeCacheSize=N
    • コンパイルしたコードのキャッシュサイズ
    • このサイズまでしかコンパイルしないので小さすぎてもNG
    • 64bit server(+Tiered) Java8だと240MBがデフォルト
    • 指定したサイズがいきなり割り当てられるわけではなくメモリの予約を行う
    • キャッシュがいっぱいになると VM warning: CodeCache is full. のような警告が表示される。(見落としやすい)
  • -XX:-UseCodeCacheFlushing
  • -XX:CompileThreshold=N
    • コンパイラは、コールの回数やループの回数などをみて、これが閾値を超えるとコンパイル対象とする
    • このカウントは時間の経過(セーフポイントに達する度)で減算されるため、閾値に満たないが重要なコードが現れる可能性がある。コンパイルしたい場合は、このオプションで閾値を下げる。*9
    • デフォルトは、client compilerは1500、server compilerは1万
  • -XX:+DoEscapeAnalysis
    • エスケープ分析をおこなって最適化を行う。*10
    • デフォルト有効
    • 最適化がうまくいかない場合に無効にすることで安定することがある。
    • ただし、コードをシンプルにする方がよっぽど効果的な解決策であることも多い。

Memory/GC

  • -Xms<N> -Xmx<N>
  • -XX:NewRatio=N
    • young領域に対するold領域の比率
    • デフォルトは2(young領域は33%)
  • -XX:+UseSerialGC or -XX:+UseParallelGC or -XX:+UseConcMarkSweepGC -XX:+UseParNewGC or -XX:+UseG1GC
    • アプリケーションの性質により適切なGCを選択する必要がある。
    • Java8デフォルトはスループットGC(ParallelGC)
    • SerialGCとParallelGCではSTW(Stop the world)がほぼ避けられない。
    • CMS GCではアプリケーションスレッドと並行してヒープを探索するためCPUにそれなりの余裕が必要で、コンパクト化を行わないためヒープが断片化したままとなる。バックグラウンドスレッドのCPUリソースが確保できない場合や断片化によりオブジェクトの割当ができなくなると、シリアル型と同じくすべてのアプリケーションスレッドを停止して、単一のスレッドでGCが行われる。
      参考:Concurrent Mark-Sweep Garbage Collection
    • G1 GCではヒープをリージョンに分割して管理し、CMSと同じくコンカレント型であり、ほとんどの場合でアプリケーションスレッドは停止しない。オブジェクトをリージョン移動させることでヒープの断片化も起きにくいが、CPU時間は多く消費されるようになる。4GB以下のヒープサイズの場合はCMSの方が基本的に高速。
      参考:Garbage First Garbage Collection
      • Java9で標準GC化
      • -XX:InitiatingHeapOccupancyPercent 大きなヒープを前提に作られているのでヒープサイズが小さい場合はこのオプションで調整する。
  • -XX:+HeapDumpOnOutOfMemoryError
  • -XX:+CrashOnOutOfMemoryError or -XX:+ExitOnOutOfMemoryError
  • -XX:OnOutOfMemoryError='bin/kill -ABRT %p'
    • OutOfMemoryErrorが発生した際にコマンドを実行できる
    • Java 8u91以前などで前述のオプションが使用できないときや、他に行いたいことがある場合に使用する
  • -XX:HeapDumpPath=<path>
    • ヒープダンプの出力先
    • デフォルトはアプリケーションのカレントディレクトリのjava_pid<PID>.hprof
    • ディレクトリ名だけの指定も可能
  • -XX:+HeapDumpBeforeFullGC
    • フルGCの前にヒープダンプを生成する。
    • ヒープサイズが大きい程ヒープダンプには時間がかかるため使用に注意。

CMS GCはJava9で非推奨となっているため、現実的にはスループットGCまたはG1 GCのいずれを選択するかになるように思います。 どちらを選択した方が良いかは、CPUの余力とヒープメモリのサイズによって変わります。両方が十分に大きい(多く必要とする)のであればG1 GCの方が基本的に適しています。フルGCに伴う停止を極力なくしたい場合はCPUとメモリを積んでG1 GCを選択しましょう。

see also: HotSpot Virtual Machineガベージ・コレクション・チューニング・ガイド

スループットGC(ParallelGC)の設定

  • -XX:MaxGCPauseMillis=N
    • 最大一時停止時間目標(許容可能な停止時間の最大値)
    • マイナーGCとフルGCの両方に適用される。
    • あまりに低い値を指定すると、少ししかGCが行われないためヒープが空かず、かえってGCを頻発させることとなる。現実的な時間を指定する必要がある。
    • デフォルト指定なし(無視される)
  • -XX:GCTimeRatio=N
    • スループット目標
    • アプリケーションスレッドと比べてGCにどの程度の時間がかかってよいかを指定する。
    • デフォルトは99(1 / (1 + GCTimeRatio)で計算されるので、この場合1%がGCの実行時間目標)
    • 19くらいで始めることがオススメ(GC時間5%目標)

最大一時停止時間目標→スループット目標→最少フットプリント目標(-Xmx)の順に達成するよう動作します。 JVMはadaptive sizingがデフォルトで有効で、指定された目標値になるように自動的にメモリの拡張など調整を行います。 そのため、必要なヒープサイズが予め分かる場合には-Xms -Xmxを同値に指定するとGCが最小限に留められるためパフォーマンスにプラスとなるようです。しかし、GC時間以上にスループットOps)の目標を十分に満たすことが重要であり、適切なサイズのヒープ(-Xmx)とGCTimeRatioを指定してJVMに委ねても手作業でチューニングする場合と同等のスループットを達成できることがある点には留意が必要です。(上記Oracleのガイドでもデフォルトより必要なヒープサイズが大きい場合を除きXmxを指定しないことを勧めている。)

G1 GCの設定

  • -XX:MaxGCPauseMillis=N
    • 前述の通り
    • G1 GCの場合デフォルトは200
    • ほとんどこのフラグのチューニングが主となる(G1は予測モデルを用いて、この値をもとに動作を自動調整する)。
  • -XX:ParallelGCThreads=N
    • GCスレッドの数
    • デフォルトはCPU1つにつき1スレッド(9CPU以降は(8 + (CPU数 - 8) * (5 / 8)))だが、スレッドが多すぎても効果が薄くなるため小さくする。
  • -XX:ConcGCThreads=N
    • マーク付けを行うバックグラウンドスレッドの数
    • デフォルトは(ParallelGCThreads + 2) / 4
    • 十分なCPUが搭載されているなら、マーク付けを行うスレッドを増やすことでパフォーマンスにプラスとなる。
  • -XX:InitiatingHeapOccupancyPercent=N
    • GCを開始するヒープの使用率
    • デフォルト45
    • このフラグは常に一定で自動調整されない。
    • 大きすぎるとフルGCが発生しやすくなり、小さすぎるとバックグラウンドでのGCが必要以上に発生する。コンカレントサイクルの終了後のヒープが設定値より下回っていることを確認すべき。

まずはMaxGCPauseMillisのチューニングから始め、それでもフルGCが発生する場合に他のチューニングを行った方が良いでしょう。

その他

  • -Duser.timezone
    • アプリケーション/DB間でタイムゾーンは揃えておいた方が事故を減らせる。
    • 個人的にはUTCで統一したい。*11

Application

Thread Pool

  • プールサイズ
    • 最大値と最小値は十分に見積もれるなら同じにしておく。※固定プールの場合
    • 基本はCPUコア数と同じ。最大のスループットが達成されるのは同数のスレッドがプールにあるとき。
    • CPUがボトルネックの場合は、スレッド数をCPU以上に増やしても大きなパフォーマンス悪化は起きにくい。*12
    • CPU以外(DBなど外部リソース)がボトルネックの場合は、クライアント側は余剰CPUがあっても外部リソース側がビジー状態となるため、スレッド数を増やせば増やすほどパフォーマンスが悪化していく。
  • DBアクセスのためのスレッドプールのように、ブロッキングが発生する部分のプールを分離した方が良い。
    • DBにアクセスするクライアント処理があるスレッドプールでブロック状態となるスレッドが増える場合はプール数を増やすことで、そうでないプール(利用側)の処理が玉突きでブロック状態となることを緩和することができる(増やしすぎると前述のとおり外部リソースの負荷が拡大する)。

see also: Understanding Play thread pools

DB Connection Pool

see also: HikariCP設定

JDBC driver

ステートメントキャッシュなどはドライバの設定で調整します。 Aurora(MySQL)への接続にMariaDB Connector/Jを使用しており、ここではそのドライバの設定項目を検討しています。*13

  • SSL
    • 必要に応じて
  • useServerPrepStmts
    • サーバサイドでクエリ実行前にプリペアードステートメントの処理を行うようにする。
    • デフォルト無効
    • HikariCPのドキュメントではMySQLドライバにおいて有効化を推奨しているが、MariaDBドライバでも同様か不明であり過去に有効であったものを無効化された歴史もあるようなので、様子をみながら設定を検討した方がよい。
  • serverTimezone

Aurora(MySQL) failover support

  • jdbc:mysql:aurora://<hostDescription>[,<hostDescription>...]/
    • ドライバレベルでフェイルオーバーがサポートされている。
    • Auroraもフェイルオーバーを備えているが、ドライバ側で死活をチェックするためより早く切り替わる。
  • useBatchMultiSend
    • Aurora failoverを利用する場合はfalseにする。

Coding

計測について

チューニングには計測がセットでなければいけないことは周知の通りです。 例えば、以下のような方法が考えられます。

  • サーバメトリクス
    • CPU
    • Memory
    • Network
    • etc
  • JMXによるアプリケーションでのメモリ、スレッド、DBコネクションリソース
  • -XX:+PrintCompilation
    • JITによって、メソッドやループがコンパイルされるたびにメッセージが出力されるようになる。
    • フルログではないがjstat -compiler <PID>jstat -printcompilation <PID> <refresh rate>でも何がコンパイルされたかを知ることはできる。
  • GCログ ★
    • -verbose:gc (=-XX:+PrintGC)
    • -Xloggc:<path>
    • -XX:+PrintGCDetails
    • -XX:+PrintGCTimeStamps or -XX:+PrintGCDateStamps
    • -XX:+PrintReferenceGC
      • GCの際にソフト参照と弱い参照に対して行われた処理を記録
    • -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=N -XX:GCLogFileSize=N
    • -XX:+PrintTLAB
      • GCの際にTLAB(Thread-Local Allocation Buffer)の情報をGCログに追加
      • スレッドに与えられたオブジェクトを割り当てるための領域(eden領域へ短命オブジェクトを割り当てる際に高速なのはこれを使っているから)。
  • jcmd
  • netstat -an
    • TIME_WAITが大量発生しているとエフェメラルポートが不足している。

おわりに

ここまで見てきたように、最初に検討すべき項目は少なく、基本的にJVMは優秀です。 パフォーマンスに影響する多くのケースではアプリケーションコードが原因であることがほとんどであるように思います。 チューニングポイントを知るだけでなく、内部動作(スレッドモデルやメモリモデル、GCの仕組みなど)を知ることも不可欠です。

また、制約理論によれば局所最適化の罠などもあります。 システム全体のボトルネックを探すことが最重要ですが、パフォーマンス劣化に直面した際はパニックにもなるものです。 予めどのようなポイントがあるのか知ることができるのなら、備えるにこしたことはありません。 アプリケーションの性格によって最適解は異なりますが、最初に考える範囲は似通うのかなと思います。


更新履歴

*1:何番煎じのエントリだという感じですが、自分でまとめる方が学習になるので…

*2:早まった最適化は悪だが、最適化を何もせずに本番にあてろということではないと思う

*3:より具体的にはScala + Play framework、OpenJDK 8〜10

*4:Storageはほぼ使わないので、ここでは考慮外としています

*5:特にCPUのバーストやメモリが溢れた時は巻き添えをくらうことがコワイ

*6:cgroup filesystem?

*7:ドキュメントにquota引数とは書いていないのですが この質問Docker公式ドキュメントおよびZalandoの公開情報 からquotaと推測しました

*8:default period

*9:滅多になさそう

*10:エスケープ分析(逸出分析)とはローカルオブジェクトの参照がヒープに公開されていない(=実質、ロックが不要)箇所を検出して、ランタイムコンパイラがこれをインライン化することで最適化すること。ロック省略ともいう。

*11:色々な都合の結果JSTを指定せざるを得ないケースはある…

*12:4CPUなら32スレッド程度までは90%程度にスループットが落ちるくらいらしい

*13:AWS推奨ぽい点と、MySQL Connector/Jに比べて罠も少ないように感じる