Javaパフォーマンス 4章 JIT コンパイラのしくみ


O'Reilly Japan - Javaパフォーマンス
こちらの本の4章まとめ

1章 イントロダクション - Qiita
2章 パフォーマンステストのアプローチ - Qiita
3章 Javaパフォーマンスのツールボックス - Qiita ←前回記事
4章 JIT コンパイラのしくみ - Qiita←今回記事
5章 ガベージコレクションの基礎 - Qiita←次回記事

4.1 JIT コンパイラの概要

コンパイラ言語とインタプリタ言語の違いの例

メインメモリから2つの値を読み込んで加算する場合…
優れたコンパイラは、データを読み込む文を実行してから、何か別の命令を実行してから加算する。
インタプリタは一度に1つの行しか見ていないため、それはできない。

インタプリタにはポータビリティという利点がある

新しいバージョンのCPUは、以前のバージョンのCPUの命令をほぼすべて実行できるが、逆はできない(IntelのSandyBridgeプロセッサのAVX命令など)。
パフォーマンスが重要になる処理は、CPU毎に用意した共有ライブラリの中で行うようにするなどの解決策がある。

Javaは中間的な立場で、Javaバイトコードへコンパイル後、実行と同時に各プラットフォームへコンパイルが行われる。

ホットスポットのコンパイル

  • ほとんどのプログラムは、コード中のほんの一部分だけが頻繁に実行されます
  • JVMはコードの実行を開始しても、すぐにコンパイルを行うわけではない。以下2つの理由がある。
    • コンパイルしても1回しか実行しなかったら無駄。頻繁に呼び出されてからコンパイルされはじめる。
    • コンパイル前の実行回数が多いほど、最適化のための情報が多く得られるため

例えば、b = obj1.equals(obj2)のようなコードを実行する場合、
どのequals()メソッドが実行するべきかを調べるために、obj1の型(クラス)を調べる。
このコードが実行される場合はobj1が常にjava.lang.String#equalsが呼ばれていれば、
そのメソッドを直接呼ぶように最適化する。

レジスタとメインメモリ

コンパイラの最適化で、いつメインメモリからレジスタに保存するか?が挙げられる

public class RegisterTest {
  private int sum;
  public void calculateSum(int n) {
    for (int i = 0; i < n; i++) {
      sum += i;
    }
  }
}

このコードの場合、sumをメインメモリから読み込む前に、
sumをレジスタで持っておいてループさせて計算結果をメインメモリのほうのsumと合算させるという最適化が行われることがある。

別スレッドが使っているレジスタは読み取れない(9章参照)。
エスケープ分析(この章の最後に解説します)が有効化されている場合、レジスタは特に積極的に使われます。

4.2 基本的なチューニング(クライアントコンパイラとサーバーコンパイラ)

クライアント型とサーバー型がある。-client-serverというコマンドライン引数からそう呼ばれる。

コンパイラを指定するためのフラグは-XXが使われないことがほとんど。
しかし、階層的コンパイル(tiered compilation)はその例外。
-XX:+TieredCompilation
階層的コンパイルではサーバーコンパイラが必須。

クライアントコンパイラは早期にコンパイルを開始するため、初期の段階では高速。
一方で、サーバーコンパイラは最適化に時間をかける。

コードが「ホット」になってきたらサーバーコンパイラが改めてコンパイルする方式が階層的コンパイル。
階層的コンパイルはJava8ではデフォルトで有効化されている。

Java7での階層的コンパイルにはクセがあり、例えば、JVMのコードキャッシュのサイズをすぐに超過してしまうと言った問題があった。

4.3 Java と JIT コンパイラのバージョン

バージョンは以下の3種類ある

  • 32bitのクライアントコンパイラ( -client )
  • 32bitのサーバーコンパイラ( -server )
  • 64bitのサーバーコンパイラ( -d64 )

32bitのOSだと、JVMも32bit版でないといけない。
64bitのOSだと、JVMはどちらでも使うことができる。

ヒープが3GB以下の場合は32bit版のほうがメモリ使用量も少なく、高速。
64bitよりも32bitのほうがメモリ参照コストが低いためらしい。

8章では、オブジェクトへのポインタ(ordinary object pointer)の圧縮について議論していて、
64bitのJVMでも32bitのアドレスが利用できるようになっている。
しかし、実行される際のネイティブコードは64bitのアドレスを使っているので、メモリ使用量は多い。

8byteの型(long, double)を多用するプログラムでは、32bitJVMはCPUの64bitレジスタが使えなくて遅い。

32bitOSは4Gbyte(2^32)の壁がある

↓関連公式資料
CompressedOops

↓でデフォルトのJavaコンパイラが表示される

java -version

↓関連公式資料
java

4.4 中級のチューニング

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

JVMがコードをコンパイルすると、アセンブリ言語による一連の命令がコードキャッシュに格納される。
コードキャッシュのサイズは固定。いっぱいになるとそれ以上はコンパイルできなくなり、インタプリタで動作する。

Java7で階層的コンパイルを行う場合、デフォルトのサイズではコードキャッシュが不足することがよくある(未だにJavaのバージョンを8以降に上げさせてくれないSIerさんもいて、そういう場合にこういう苦労があるある…)

アプリケーションがどの程度のコードキャッシュを必要としているかを知る方法は無いので、実際に実行してみて、足りているかをチェックするしかない(チェック方法は後述)。

  • -XX:XX:InitialCodeCacheSize-N: 初期コードキャッシュサイズ。私の環境下ではデフォルト2,555,904byteだった

  • -XX:XX:ReservedCodeCacheSize=N: 最大サイズ。私の環境下ではデフォルト251,658,240byteだった

  • -XX:XX:CodeCacheExpansionSize=N: コードキャッシュの拡張サイズ。私の環境下ではデフォルト65,536byteだった

※私の環境下

momose@momose-pc:~$ java -version
openjdk version "11.0.3" 2019-04-16
OpenJDK Runtime Environment (build 11.0.3+7-Ubuntu-1ubuntu219.04.1)
OpenJDK 64-Bit Server VM (build 11.0.3+7-Ubuntu-1ubuntu219.04.1, mixed mode, sharing)
momose@momose-pc:~$ 

コードキャッシュ不足に困らないように、ReservedCodeCacheSizeを1Gbyteと大きなサイズを指定したらどうなるか?
JVMはネイティブメモリ領域を1Gbyte分予約してしまう。しかし、使われるまで割当はしない。

  • 予約メモリが多くても、パフォーマンス状は問題無い
  • 物理メモリ+仮想メモリを超えた予約メモリを予約はできない。JVMを複数起動したときに、他JVMでメモリが予約されきっている場合、メモリを予約できないという問題もある

※メモリの予約と割当ては違う(8.1章で詳しく説明される)

↓参考資料
[tips][Java]CodeCache領域使用状況の確認方法 - Akira's Tech Notes

jconsoleを使うと、コードキャッシュのサイズを監視できる。
Memoryパネルで Memory Pool Code Cacheを選択すると、グラフが表示される(Java8までは)。

Java9以降はCodeHeapという領域で管理されるとのこと。
↓jconsoleでのMemoryタブ

これ( https://docs.oracle.com/javase/jp/9/tools/java.htm )の-XX:+SegmentedCodeCacheのとこによると、
セグメントを分けて処理することでコードの断片化を防ぎ効率アップするらしい。

Java9(OracleJVMに基づく)キャッチアップ - Qiitaによるとコードタイプによって3つのセグメントに分けているらしい。

コンパイルのしきい値

コンパイルがいつ発生するかは、実行回数が影響する。

コンパイルのしきい値を調整するべきケースは1つしかない。
以下2つを足した値がしきい値を超えていたら、コンパイラのためのキューに入れられます。これを「スタンダードコンパイル」(正式名称ではない)と呼ぶ。

  • 呼び出しカウンター: メソッド呼び出し回数
  • バックエッジカウンター: ループ内のコードから処理が戻ってくる回数(ループ内のコードが実行された回数とほぼ同じ)

スタンダードコンパイルだと、1ループで長い処理、1メソッドが長い場合は、うまく最適化されない、
バックエッジカウンターがしきい値を超えていたら、ループだけがコンパイル対象となる。このコンパイルを「OSR (on-stack replacement)」と呼ぶ。

スタンダードコンパイルでのしきい値は-XX:CompileThreshold=Nフラグで指定可能
デフォルト値は、クライアントコンパイラでは1500、サーバーコンパイラでは10000。

OSRコンパイルされるしきい条件

バックエッジカウンター値 > CompileThreshold(OnStackReplacePercentage 
 - InterpreterProfilePercentage) / 100
  • -XX:InterpreterProfilePercentage=Nのデフォルトは33
  • -XX:OnStackReplacePercentage=Nのデフォルトはクライアントコンパイラは933、サーバーコンパイラは140

なので、クライアントコンパイラの場合のしきい値は、

1500 * (933 - 33) / 100 = 13500

で、サーバーコンパイラの場合のしきい値は、

10000 * (140 - 33) / 100 = 10700

となります
※階層的コンパイルでは別のフラグが使われる。

JVMがセーフポイントに到達するたびに、それぞれのカウンターの値は減少される。そのため、いつかは全部のメソッドがコンパイルされるわけではない。
なので、そこそこ頻繁に実行されるが、コンパイルされない(ホットではなく)「生ぬるい」メソッドも存在する(階層的コンパイルが速い理由の一つでもある)。

-XX:+PrintCompilationフラグ(デフォルトfalse)です。
コンパイルの度に

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

といった内容のログが出ます。

属性は

  • %: OSRコンパイル
  • s: synchronizedメソッド
  • !: メソッドにthrowsあり
  • b: ブロッキングモードでコンパイル(現在のJavaでは出力されることはない)
  • n: ネイティブメソッドのラッパーがコンパイラにて生成された

サイズはJavaバイトコードのサイズ
非最適化された場合は、非最適化されたというメッセージがでる

既に起動しているJavaプログラムのコンパイルの情報を得ることもできる

jstat -compiler ${プロセスID}

また、最後にコンパイルされたものを1000ミリ秒毎に表示するということもできる

jstat -printcompilation ${プロセスID} 1000

OSRコンパイルは時間がかかることがよくある。

4.5 上級のチューニング

マニアックな内容で、JVMエンジニア向けらしい。
ここに書いてあるチューニング内容を実施することはほぼ無いでしょう…。

コンパイラのスレッド

コンパイルが非同期で走っていて、CPUの数とコンパイラの種類によってコンパイラのスレッド数が変わってくる。
スレッド数は-XX:CICompilerCount=Nで変更ができる。
階層的コンパイルの場合は、1/3がクライアントコンパイルで残りがサーバーコンパイル。

-XX:+BackgroundCompilationを指定すると、コンパイルが非同期じゃなくなる。

インライン化

Getter/Setterを通じてアクセスするコードは現代のコンパイラではインライン化を行ってくれる。
インライン化はデフォルトで有効化されている。
-XX:-Inlineで無効化もできる。

インライン化が行われる条件は、ホットさとバイトコードのサイズ。

ホットになっていて且つ、バイトコードのサイズが325byte(-XX:MaxFreqInlineSize=Nで変更可能)以下であれば、インライン化が行われる。
サイズが 35byte(-XX:MaxInlineSize=Nで変更可能)以下であれば、インライン化が無条件で行われる。

エスケープ分析

-XX:+DoEscapeAnalysis(デフォルト値はtrue)が有効な場合の最適化です。
いろいろ行うようなのですが、例えば、

public class Factorial {
    private BigInteger factorial;
    private int n;
    public Factorial(int n) {
        this.n = n;
    }
    public synchronized BigInteger getFactorial() {
        if (factorial == null)
            factorial = ...;
        return factorial;
    }
}

に対して

ArrayList<BigInteger> list = new ArrayList<BigInteger>();
for (int i = 0; i < 100; i++) {
    Factorial factorial = new Factorial(i);
    list.add(factorial.getFactorial());
}
  • getFactorial()メソッドに同期は不要
  • 変数nやfactorialの値はメモリではなくレジスタに格納。
  • factorialオブジェクト本体は割当を行わず、フィールドだけを管理

とするなど、高度な最適化が行われる(まれにバグがあることがある)

非最適化

非最適化は、entrantではなくなり、ゾンビ化されGCが走るという流れ

entrantではなくなる場合

2つあり、1つ目は、特定のインターフェイスに対して実装クラスとの紐付けを行って最適化を行うが、その前提が崩れた場合に非最適化される。
2つ目は階層的コンパイルの実装によるもので、サーバーコンパイラによるコンパイルが完了したらentrantではないという印をつけ非最適化を行う。

ゾンビのコード

made zombieがコンパイルログに流れた場合は、entrantではないコードが放棄され、GCが走ることになる。

↓参考資料
Java-JA13-Architect-evans.pdf

4.7 階層的コンパイルのレベル

  • 0: インタプリタが実行するコード
  • 1: クライアントコンパイラがシンプルモードでコンパイルしたコード
  • 2: クライアントコンパイラが制限モードでコンパイルしたコード
  • 3: クライアントコンパイラが完全モードでコンパイルしたコード
  • 4: サーバーコンパイラがコンパイルしたコード

初期状態で0のレベルで動作し、ほとんどの場合、まずレベル3でコンパイルされ、その後にレベル4でコンパイルされるらしい。
レベル1やレベル2はコンパイラのキューがいっぱいのときに使われたりするとのこと(プロファイラを使わないため高速でコンパイルされる)。
当然、非最適化されたコードはレベル0へ戻る。

4.8 まとめ

  • 階層的コンパイル最強
  • 小さなメソッドはインライン化される
  • コンパイルはキューにより処理される
  • コードキャッシュのサイズは上限がある。
  • コードをシンプルにすれば、最適化の恩恵を受けやすい

あと、final修飾子はパフォーマンスに 影響しない らしい。