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

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

Java のJIT コンパイルについて

はじめに

今回は JavaJIT コンパイルについて。
Java パフォーマンスと各種信頼できるドキュメントを参照しつつまとめていく。

JIT コンパイルとは

Java のコードは javac コマンドでコンパイルすると中間バイトコードに変換され、それを JVM が解釈することによってアプリケーションが実行される。
中間バイトコードJVM が解釈できる形になるため、Javaインタープリター言語のようなプラットフォームにおける独立性を確保している。
この後 JVMインタープリターのように中間バイトコードを解釈するが、その時に JVM を実行しているプラットフォーム毎にバイナリにコンパイルされる場合がある。
これを Just-In-Time コンパイルという。
f:id:RabbitFoot141:20201121152307p:plain

JIT コンパイルによる効果を確かめる

エラトステネスの篩を使って素数を求める以下のコードを利用する。

public class SampleAlgorithm {
	
	private static final int LIMIT = 1000000;
	
	public static void main(String... args) {
		int primeCount = 2;
		boolean isPrime = false;
		for (int i = 5; i <= LIMIT; i++) {
			for (int j = 2; j * j <= i; j++) {
				if (i % j == 0) {
					isPrime = false;
					break;
				}
				isPrime = true;
			}
			if (isPrime) {
				primeCount += 1;
			}
		}
		System.out.println(primeCount);
	}
}

このコードを -Xint オプションをつけた場合、つけなかった場合で処理時間を比較する。

❯ time java -Xint SampleAlgorithm.java
78498
java -Xint SampleAlgorithm.java  1.80s user 0.05s system 98% cpu 1.876 total
❯ time java SampleAlgorithm.java 
78498
java SampleAlgorithm.java  1.14s user 0.07s system 160% cpu 0.753 total

1000000 までの素数であれば微々たる差だが、桁を増やすと差が明確になっていく。
1億以下の素数を計算すると以下のようになる。

❯ time java -Xint SampleAlgorithm.java 
664579
java -Xint SampleAlgorithm.java  21.35s user 0.13s system 99% cpu 21.590 total
❯ time java SampleAlgorithm.java 
664579
java SampleAlgorithm.java  6.18s user 0.08s system 108% cpu 5.769 total

JIT コンパイルの動作

実行時のオプションに -XX:+PrintCompilation をつけることで JIT コンパイルが何を行っているかわかる。
このフォーマットには列ごとに意味があるが、それは次の JIT コンパイラのチューニング part1 でまとめる。

❯ java -XX:+PrintCompilation SampleAlgorithm.java
   .....
   530  958       3       com.sun.tools.javac.tree.JCTree::hasTag (14 bytes)
    530  957       1       com.sun.tools.javac.code.Type$JCPrimitiveType::isPrimitive (2 bytes)
    530  959       3       com.sun.tools.javac.code.Type$ClassType::accept (9 bytes)
    532  960       3       java.lang.invoke.DirectMethodHandle::make (263 bytes)
    533  961       3       java.lang.invoke.MemberName::isField (7 bytes)
    533  962       3       java.util.ArrayList::isEmpty (13 bytes)
    534  963       3       com.sun.tools.javac.jvm.Code::typecode (141 bytes)
    534  964       3       com.sun.tools.javac.jvm.Code::width (42 bytes)
    534  965       3       com.sun.tools.javac.jvm.Code::width (16 bytes)
    535  966       3       com.sun.tools.javac.code.Type::typeNoMetadata (19 bytes)
    536  734       4       java.lang.StringLatin1::indexOf (121 bytes)   made not entrant
    537  309       3       jdk.internal.jimage.ImageStringsReader::stringFromByteBuffer (28 bytes)   made not entrant
    537  828       4       java.lang.ref.Reference::reachabilityFence (1 bytes)
    537  169       3       java.lang.ref.Reference::reachabilityFence (1 bytes)   made not entrant
    537  814       4       com.sun.tools.javac.util.ListBuffer::toList (10 bytes)
    537  635       3       com.sun.tools.javac.util.ListBuffer::toList (10 bytes)   made not entrant
    537  755       4       java.lang.String::toString (2 bytes)
    537  480       3       java.lang.String::toString (2 bytes)   made not entrant
    537  967 %     3       SampleAlgorithm::main @ 15 (65 bytes)
    538  968       3       SampleAlgorithm::main (65 bytes)
    538  969 %     4       SampleAlgorithm::main @ 15 (65 bytes)
    539  967 %     3       SampleAlgorithm::main @ 15 (65 bytes)   made not entrant
    745  969 %     4       SampleAlgorithm::main @ 15 (65 bytes)   made not entrant
  • XX:+LogCompilation をつけるとより詳細な以下のようなログを確認することができる。
<<<一部抜粋>>>
<nmethod compile_id='1' compiler='c1' level='3' entry='0x000000011d1911c0' size='1056' address='0x000000011d191010' relocation_offset='376' insts_offset='432' stub_offset='816' scopes_data_offset='872' scopes_pcs_offset='936' dependencies_offset='1032' nul_chk_table_offset='1040' metadata_offset='864' method='java.lang.StringLatin1 hashCode ([B)I' bytes='42' count='101' backedge_count='2096' iicount='101' stamp='0.054'/>
<writer thread='9731'/>
<task_queued compile_id='2' method='java.util.concurrent.ConcurrentHashMap tabAt ([Ljava/util/concurrent/ConcurrentHashMap$Node;I)Ljava/util/concurrent/ConcurrentHashMap$Node;' bytes='22' count='256' iicount='256' level='3' stamp='0.054' comment='tiered' hot_count='256'/>
<task_queued compile_id='3' method='jdk.internal.misc.Unsafe getObjectAcquire (Ljava/lang/Object;J)Ljava/lang/Object;' bytes='7' count='256' iicount='256' level='3' stamp='0.054' comment='tiered' hot_count='256'/>
<task_queued compile_id='4' method='java.lang.String coder ()B' bytes='15' count='256' iicount='256' level='3' stamp='0.054' comment='tiered' hot_count='256'/>
<writer thread='42755'/>
<nmethod compile_id='2' compiler='c1' level='3' entry='0x000000011d191660' size='1376' address='0x000000011d191490' relocation_offset='376' insts_offset='464' stub_offset='1168' scopes_data_offset='1240' scopes_pcs_offset='1288' dependencies_offset='1368' oops_offset='1216' metadata_offset='1224' method='java.util.concurrent.ConcurrentHashMap tabAt ([Ljava/util/concurrent/ConcurrentHashMap$Node;I)Ljava/util/concurrent/ConcurrentHashMap$Node;' bytes='22' count='260' iicount='260' stamp='0.055'/>
<writer thread='9731'/>
<task_queued compile_id='5' method='java.lang.String isLatin1 ()Z' bytes='19' count='256' iicount='256' level='3' stamp='0.055' comment='tiered' hot_count='256'/>
<writer thread='42755'/>
<nmethod compile_id='3' compiler='c1' level='3' entry='0x000000011d191bc0' size='856' address='0x000000011d191a10' relocation_offset='376' insts_offset='432' stub_offset='720' scopes_data_offset='776' scopes_pcs_offset='800' dependencies_offset='848' metadata_offset='768' method='jdk.internal.misc.Unsafe getObjectAcquire (Ljava/lang/Object;J)Ljava/lang/Object;' bytes='7' count='263' iicount='263' stamp='0.055'/>
<nmethod compile_id='5' compiler='c1' level='3' entry='0x000000011d191f40' size='880' address='0x000000011d191d90' relocation_offset='376' insts_offset='432' stub_offset='752' scopes_data_offset='808' scopes_pcs_offset='824' dependencies_offset='872' metadata_offset='800' method='java.lang.String isLatin1 ()Z' bytes='19' count='259' iicount='259' stamp='0.055'/>
<nmethod compile_id='4' compiler='c1' level='3' entry='0x000000011d1922c0' size='816' address='0x000000011d192110' relocation_offset='376' insts_offset='432' stub_offset='688' scopes_data_offset='744' scopes_pcs_offset='760' dependencies_offset='808' metadata_offset='736' method='java.lang.String coder ()B' bytes='15' count='262' iicount='262' stamp='0.055'/>
<writer thread='9731'/>
<task_queued compile_id='6' method='java.lang.Object &lt;init&gt; ()V' bytes='1' count='512' iicount='512' level='3' stamp='0.057' comment='tiered' hot_count='512'/>
<writer thread='42755'/>

この時に重要になるのが level の値。これは階層型コンパイルが行われ、その際にどのコンパイルが適用されたかを示している。
レベルは以下の通り。

クライアントコンパイルとサーバコンパイル

最近の JVMJIT でクライアントコンパイルとサーバコンパイル*1を併用する。
クライアントコンパイルとサーバコンパイルの違いはコンパイルをどの程度積極的に行うかにあり、クライアントコンパイラは可能な限り該当コードをコンパイルし、サーバコンパイラインタープリター的に実行しつつ実行しているコードについての情報を用いて最適化したコンパイルを行う。
JVM によっては -client, -server, -d64 オプションをつけることで片方を明示的に指定できる。(あまり使うことはないと思うが)
最近の JVM だとこの辺りがベンダー、プラットフォーム依存になっていて -client を指定してもサーバコンパイルが適用される場合もある。

トレードオフの存在

クライアントコンパイルは、最適化を施したコンパイルを行わないため少ないコストでネイティブコードを吐けるという性能上の特徴があり、反対にサーバコンパイルは最適化も含めてネイティブコードにするのでネイティブコードになるまでにコストはかかるがその分実行時パフォーマンスを向上させやすいという特徴がある。
またインタープリターと JIT コンパイルでは、繰り返し利用されないメソッドなどはインタープリターで実行した方がコンパイルにかかるコストを削減でき、反対によく利用される箇所はコストをかけてコンパイルした方が実行時のパフォーマンスが向上するなどそれぞれトレードオフが存在している。

階層型コンパイル

階層型コンパイルはクライアントコンパイルとサーバコンパイルを併用する。アプリケーションが起動する最初の頃はクライアントコンパイルが適用されて、繰り返し利用されているコードなどを検出した場合はサーバコンパイルが適用される。
この階層型コンパイルJava 8 以降ではデフォルトとなっている。

*1:それぞれ C1, C2 コンパイラと呼ばれる場合がある