それが僕には楽しかったんです。

ミドルウェアとかやってます

JIT コンパイラのコードキャッシュ

はじめに

前回は JIT コンパイラの基礎について触れたので、今回は JIT コンパイラのコードキャッシュとそのチューニングついて。

基本的なものだと、どちらのコンパイラを選択するか、階層コンパイルを利用するかどうかがあるらしいが
もう少し踏み込んだ内容について書いていく。

今回も例によって Java パフォーマンスを参考文献にしている。

コードキャッシュとそのチューニング

コードキャッシュとは

コードキャッシュとは JVM がコードをコンパイルすることによって生成されたネイティブコードを持っている領域のことで Java 9 以降ではコードヒープと呼ばれている。
この領域を圧迫すると、一部のホットスポットだけコンパイルが適用され、その他の部分はインタープリターで実行される or コンパイル適用を一旦破棄するなど性能に大きな影響を及ぼす。

コードキャッシュの確認

  • XX:+PrintCodeCache オプションをつけることで確認できる。
❯ java -XX:+PrintCodeCache -version
openjdk version "11.0.8" 2020-07-14 LTS
OpenJDK Runtime Environment Corretto-11.0.8.10.1 (build 11.0.8+10-LTS)
OpenJDK 64-Bit Server VM Corretto-11.0.8.10.1 (build 11.0.8+10-LTS, mixed mode)
CodeHeap 'non-profiled nmethods': size=120032Kb used=15Kb max_used=15Kb free=120016Kb
 bounds [0x0000000126cd2000, 0x0000000126f42000, 0x000000012e20a000]
CodeHeap 'profiled nmethods': size=120028Kb used=97Kb max_used=97Kb free=119931Kb
 bounds [0x000000011f79b000, 0x000000011fa0b000, 0x0000000126cd2000]
CodeHeap 'non-nmethods': size=5700Kb used=972Kb max_used=977Kb free=4728Kb
 bounds [0x000000011f20a000, 0x000000011f47a000, 0x000000011f79b000]
 total_blobs=299 nmethods=71 adapters=141
 compilation: enabled
              stopped_count=0, restarted_count=0
 full_count=0

CodeHeap の種類は 3 つあり

  • non-profiled nmethods
  • profiled nmethods
  • non-nmethods

と分かれている*1
これらを合計しておおよそ 240MB の領域が割り当てられている。

コードキャッシュが圧迫された場合の挙動

このコードキャッシュ(コードヒープ)の挙動で重要となるのは、コードキャッシュが圧迫された場合の挙動で、コンパイルしたネイティブコードをコードキャッシュに配置できなくなると JIT コンパイルを停止する。この時、コードキャッシュがいっぱいになったというログメッセージが出力される。
コードキャッシュにネイティブコードを配置できなくなると JVM は一度最適化したネイティブコードを破棄し、Java のプログラムを実行する際に設定されている最低量のコードキャッシュを確保しようとする。*2
それらの処理が完了した段階で JIT コンパイルを再開する。

単純なチューニング

これらからわかるようにコードキャッシュのことを考慮した場合、重要となるのはコードキャッシュの上限サイズとなる。
上限サイズをどのように設定すればよいといった定量的な基準はなく場当たり的に設定する必要が出てくる。*3

コンパイル時の基準

コードキャッシュ自体のチューニングと言うとサイズを調整するぐらいしか見当たらないが、JIT コンパイルの特性を知る事で間接的にパフォーマンスに影響を与えることができる。
そのうちの一つがどのタイミングでコンパイルが行われるかということを明示的に指定する方法。
前回は JIT コンパイルで C1, C2 コンパイラについてまとめたが、それらがどう言った基準で適用されているかについて知り、それを設定することでコードキャッシュを有効活用できる場合がある。
JIT コンパイル時は呼び出しカウンタとバックエッジカウンタの二つを JVM が記録しておりこの総和が閾値を超えた段階で JIT コンパイルが適用される。
呼び出しカウンタはそのままの意味だが、バックエッジカウンタとはメソッド内のループ内で処理が完了した回数を示している。
やたら複雑で巨大なメソッドを実行する場合 JVM はメソッドの終了を待つ事なくコンパイルを実行するケースがある。その時はバッグエッジカウンタが個別の閾値を超えるとメソッド全体ではなく該当するループ処理がコンパイル対象となる。
こういったコンパイルを OSR *4*5 と呼ぶ。

ここまで長々とまとめたがカウンタについては -XX:CompileThreshold でどれだけその値を変更することができる。デフォルトでは C1 コンパイラが 1500, C2 コンパイラが 10000 と設定されている。

コンパイルプロセスを理解する

コードキャッシュと密接な関わりがある JIT コンパイルを理解することはチューニングとは直接関係しないが大事なことなのでまとめる。
前回も行った -XX:PrintCompilation フラグを利用して JIT コンパイルの挙動を知るという操作。これについてもう少し解説する。
次のログを参考にする。

    460  902       3       com.sun.tools.javac.comp.Flow$BaseAnalyzer::scan (27 bytes)
    460  903       3       com.sun.tools.javac.code.Types::isSignaturePolymorphic (111 bytes)
    462  904       3       com.sun.tools.javac.tree.JCTree::pos (2 bytes)
    462  906       3       com.sun.tools.javac.tree.JCTree::hasTag (14 bytes)
    462  907       3       com.sun.tools.javac.code.Type$ClassType::accept (9 bytes)
    462  905       1       com.sun.tools.javac.code.Type$JCPrimitiveType::isPrimitive (2 bytes)
    463  225   !   3       jdk.internal.jimage.BasicImageReader::slice (32 bytes)   made not entrant
    464  663       4       java.util.HashMap::put (13 bytes)
    464  908       3       java.lang.invoke.DirectMethodHandle::make (263 bytes)
    464   96       3       java.util.HashMap::put (13 bytes)   made not entrant
    465  736       4       java.lang.String::toString (2 bytes)
    465  472       3       java.lang.String::toString (2 bytes)   made not entrant
    465  910       3       java.lang.invoke.MethodHandles$Lookup::<init> (15 bytes)
    465  909       3       java.lang.invoke.MemberName::isField (7 bytes)
    465  911       3       java.util.ArrayList::isEmpty (13 bytes)
    466  912       3       com.sun.tools.javac.jvm.Code::typecode (141 bytes)
    467  913       3       com.sun.tools.javac.jvm.Code::width (42 bytes)
    467  914       3       com.sun.tools.javac.jvm.Code::width (16 bytes)
    467  915       3       com.sun.tools.javac.code.Type::typeNoMetadata (19 bytes)
    468  713       4       java.lang.StringLatin1::indexOf (121 bytes)   made not entrant
    470  916 %     3       SampleAlgorithm::main @ 15 (65 bytes)
    470  917       3       SampleAlgorithm::main (65 bytes)
    470  918 %     4       SampleAlgorithm::main @ 15 (65 bytes)
    472  916 %     3       SampleAlgorithm::main @ 15 (65 bytes)   made not entrant
    672  918 %     4       SampleAlgorithm::main @ 15 (65 bytes)   made not entrant

まず重要なこととしてこのログは以下のフォーマットを取る。

タイムスタンプ コンパイルID 属性 階層型コンパイルレベル メソッド名 サイズ 非最適化

コンパイルレベルに関しては前回の通りなので省略。

タイムスタンプは JVM が起動してからコンパイルが完了するまでの相対的な時間を示している。
コンパイルID は内部的な ID で単調増加するが C2 コンパイラや、非同期処理が利用されると必ずしも昇順になるとは限らない。
属性はコンパイルされるコードの属性を示していて以下の通り

残りで重要なものは非最適化についての記述。
これには made not entrant, made zombie があるがここではまとめない。次の記事でまとめる。

ここにはないがコンパイラログにはコードキャッシュがいっぱいになったことや、コンパイル中に対象クラスが変更されたため再コンパイルが行われることなどが出力される場合もある。
これらログから想定されたコンパイルが行われているかなどの確認が可能なので、性能に思うところがあればみる事がおすすめされる。

非最適化について

PrintCompilation で非最適化される場合の挙動について。
非最適化とはすでに行われて完了している最適化をもとの状態に戻すことで、次の二つのパターンで非最適化が行われる。

entrant ではないコード

entrant *6ではないコードは非最適化の対象となる。
これが発生する原因はクラスとインターフェースの仕組みに起因するものと、階層型コンパイルに起因するものがある。

まずインターフェースに起因する場合。
次のような実装を考える。

HogeRepository repository;
String param = request.getParameter("hoge");
if (param != null && param.equals("hoge")) {
     repository = new HogeRepositoryImpl();
} else {
    repository = new HogeLogger();
}

このように特定のパラメータか何かに強く依存し、インターフェースのインスタンスが異なる場合 param が hoge を取り続け C2 コンパイルが適用されると repository の型が HogeRepositoryImpl だと判断される場合がある。
そうなった場合、 else の処理が何らかの原因で呼ばれ続けると最適化した型情報が変わるため entrant なコードとして非最適化する必要が出てくる。


次に階層型コンパイルが起因となる場合について。
階層型コンパイルでは C1 コンパイルが適用され、その後 C2 コンパイルが行われる場合がある。
この時 C2 コンパイルが行われ利用できる状態になったら JVM は C1 コンパイルの結果であるコードを置き換える必要があり、置き換える対象となるクラスに印的なものとして made not entrant をつける。

zombie コード

この zombie とは entrant ではない古いコードが破棄されたことを示すもの。
entrant になっても対象のオブジェクトは残り続けどこかのタイミングで GC によって開放される。
ゾンビ化したコードはコードキャッシュから削除することができるため、それによって JVM のスペースに新たなコードを置くことができる。
ただし、ゾンビ化したコードが再度ロードされ、頻繁に呼び出された場合は再度最適化を行う必要が出てきてしまう。

おわりに

次はコンパイラスレッドとかについてまとめる。

参考文献

*1:nmethods とは JIT コンパイルによってコンパイルされたメソッドの事

*2:-XX:CodeCacheMinimumFreeSpace で設定できる

*3:一説によると、デフォルトの 2, 4 倍するのが一般的らしい

*4:On-Stack Replacement

*5:これもチューニング可能だが、ここではまとめない

*6:中に入れないという意味らしい