はじめに
前回は 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 をつける。
おわりに
次はコンパイラスレッドとかについてまとめる。
参考文献
- https://www.oracle.com/webfolder/technetwork/jp/javamagazine/Java-JA13-Architect-evans.pdf
- nmethod クラス関連のクラス (ExceptionCache, PcDescCache, nmethod, nmethodLocker, 及びそれらの補助クラス(DetectScavengeRoot, VerifyOopsClosure, DebugScavengeRoot)) http://hsmemo.github.io/articles/no14LXQg07.html
- 4 コンパイル最適化(リリース2) https://docs.oracle.com/javacomponents/jp/jrockit-hotspot/migration-guide/comp-opt.htm
- Javaの謎のパフォーマンス劣化現象との戦い - Cybozu Inside Out | サイボウズエンジニアのブログ https://blog.cybozu.io/entry/2016/04/13/080000
- 15 Codecache Tuning (Release 8) https://docs.oracle.com/javase/8/embedded/develop-apps-platforms/codecache.htm