Javaにはエスケープ解析という最適化の仕組みがあります。
インスタンスの参照がメソッドの外にエスケープするかどうかを解析し、その参照がメソッド内に限定して使用される場合、以下のような最適化が行われます。
- インスタンスをヒープではなくスタックに確保する
- メソッド内のみでインスタンスが使用されるならばメソッド終了時に領域が解放されるスタックに確保するほうが効率的
- メソッド実行に際に不要なsynchronizedを無視する
- 単一のスレッドからのみ参照されるならば同期化は不要
今回は、このエスケープ解析がどのような場合に有効になり、どの程度性能に影響を与えるのか実験したいと思います。
実験方法
エスケープ解析はデフォルトでONになっていますので、実行時に以下のオプションを付与してエスケープ解析をOFFにしました。
-XX:-DoEscapeAnalysis
また、今回の実験ではGCログを見るために -verbose:gc オプションも付与しています。
実験環境
Windows10、Java15(open jdk 15-ea)の環境で試しています。
実験(1) 大量のインスタンスを生成してみる
まずは、メソッド内で大量のインスタンスを確保して、性能にどの程度の差がつくのか試してみます。
コードは以下の通りです。
public class Sample1 {
public static void main(String[] args) {
long before = System.currentTimeMillis();
Hoge hoge = new Hoge();
for (int i = 0; i < 10_000_000; i++) {
hoge.doHoge();
}
long after = System.currentTimeMillis();
System.out.println("elapsed time " + (after - before));
}
}
public class Hoge {
public void doHoge() {
Fuga fuga = new Fuga();
fuga.doFuga();
}
}
public class Fuga {
void doFuga() {
}
}
繰り返し呼び出されているHoge#doHoge内でFugaのインスタンスを生成していますが、このFugaの参照はメソッド内で閉じています。つまりエスケープしていません。
このコードをエスケープありの場合となしの場合、それぞれ実行し性能の差を確認します。
実験結果
Sample1の実行結果です。
エスケープ解析あり
[0.014s][info][gc] Using G1
[0.037s][info][gc] Periodic GC disabled
elapsed time 8
エスケープ解析なし
[0.013s][info][gc] Using G1
[0.041s][info][gc] Periodic GC disabled
[0.114s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->0M(128M) 1.572ms
[0.126s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 35M->0M(128M) 0.947ms
[0.151s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 75M->0M(128M) 1.074ms
elapsed time 58
エスケープ解析ありの場合は8msで、エスケープ解析なしの場合には58msでした。ありとなしではずいぶんパフォーマンスに差が出ました。
これは、インスタンスがヒープに確保されたかスタックに確保されたかによるものでしょうか。
エスケープ解析なしの場合にはGCログも出ていますし、エスケープ解析ありとなしではメモリの使い方に差があるのは確かでしょうね。
実験(2) synchronizedつけてみる
エスケープ解析は不要なsynchronizedを無効にする(らしい?)です。
この影響を試すため、繰り返し実行するメソッドにsynchronizedをつけて実験してみます。
修正したコードは以下の通りです。
public class Fuga {
synchronized void doFuga() {
}
}
実験結果
エスケープ解析あり
[0.014s][info][gc] Using G1
[0.036s][info][gc] Periodic GC disabled
elapsed time 10
エスケープ解析なし
[0.014s][info][gc] Using G1
[0.036s][info][gc] Periodic GC disabled
[0.117s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->0M(128M) 1.694ms
[0.150s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 35M->0M(128M) 1.077ms
[0.225s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 75M->0M(128M) 1.378ms
elapsed time 155
今度はさらに顕著に差がつきました。エスケープ解析なしの場合が3倍近く処理時間が増えています。
一方、エスケープ解析ありの場合には、synchronizedがついていない場合とほぼ同じ性能です。
実験(1) との差はsynchronizedのみですので、エスケープ解析によってsynchronizedがほぼ無視されていると見ていいでしょうね。
実験(3) メソッド内で生成したインスタンスを戻り値として返してみる
ここからは、逆に、どのような場合に最適化が有効/無効になるのかを確認していきたいと思います。
とりあえずは、メソッド内で生成したインスタンスを戻り値として返し、「エスケープしている」と見なされるのかを確認します。
サンプルコードを以下のように変更しました。
public class Sample2 {
public static void main(String[] args) {
long before = System.currentTimeMillis();
Hoge2 hoge = new Hoge2();
for (int i = 0; i < 10_000_000; i++) {
Fuga fuga = hoge.doHoge();
}
long after = System.currentTimeMillis();
System.out.println("elapsed time " + (after - before));
}
}
public class Hoge2 {
public Fuga doHoge() {
Fuga fuga = new Fuga();
fuga.doFuga();
return fuga; // 戻り値として返す
}
}
メソッド内で確保したFugaインスタンスをメソッドの戻り値として返すHoge2を実装し、これまでと同じようにそれを繰り返し呼び出します。
実験結果
エスケープ解析あり
[0.017s][info][gc] Using G1
[0.055s][info][gc] Periodic GC disabled
elapsed time 6
エスケープ解析なし
[0.014s][info][gc] Using G1
[0.037s][info][gc] Periodic GC disabled
[0.129s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->0M(128M) 1.453ms
[0.163s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 35M->0M(128M) 1.181ms
[0.243s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 75M->0M(128M) 1.505ms
elapsed time 163
実験(2)と比較して差がほとんどありません。
意外にも、まだエスケープ解析が効いているようですね。
申し訳程度にメソッドの戻り値としてみても、無駄みたいです。そのくらいはJava VMもお見通しなんでしょうか。小手先でだますのはNGということのようです。
実験(4) メソッド内で生成したインスタンスを呼び出しもとでリストに格納してみる
メソッドの戻り値にしても無駄ということでしたので、そのインスタンスを呼び出し元のメソッドで参照を保持し続けるようにします。
コードは以下の通りです。
public class Sample2 {
public static void main(String[] args) {
long before = System.currentTimeMillis();
List<Fuga> fugas = new ArrayList<>();
Hoge2 hoge = new Hoge2();
for (int i = 0; i < 10_000_000; i++) {
Fuga fuga = hoge.doHoge();
fugas.add(fuga); // 参照をリストに格納する
}
long after = System.currentTimeMillis();
System.out.println("elapsed time " + (after - before));
}
}
見ての通り参照をリストに詰めて残すようにしたので、これならエスケープしているとみなされるでしょう。
実験結果
エスケープ解析あり
[0.015s][info][gc] Using G1
[0.037s][info][gc] Periodic GC disabled
[0.253s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 46M->44M(128M) 65.509ms
[0.300s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 67M->68M(128M) 25.617ms
[0.326s][info][gc] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 77M->78M(128M) 14.031ms
[0.326s][info][gc] GC(3) Concurrent Cycle
[0.367s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 104M->104M(128M) 15.866ms
[0.382s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 108M->109M(384M) 9.313ms
[0.402s][info][gc] GC(3) Pause Remark 140M->117M(384M) 0.465ms
[0.468s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 138M->138M(384M) 23.783ms
[0.477s][info][gc] GC(3) Pause Cleanup 145M->145M(384M) 0.065ms
[0.479s][info][gc] GC(3) Concurrent Cycle 152.288ms
[0.623s][info][gc] GC(7) Pause Young (Concurrent Start) (G1 Humongous Allocation) 231M->233M(384M) 52.873ms
[0.623s][info][gc] GC(8) Concurrent Cycle
elapsed time 566
[0.794s][info][gc] GC(8) Pause Remark 298M->247M(415M) 0.637ms
[0.891s][info][gc] GC(8) Pause Cleanup 247M->247M(415M) 0.141ms
[0.893s][info][gc] GC(8) Concurrent Cycle 269.556ms
エスケープ解析なし
[0.013s][info][gc] Using G1
[0.035s][info][gc] Periodic GC disabled
[0.218s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 46M->44M(128M) 68.775ms
[0.268s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 67M->68M(128M) 28.708ms
[0.298s][info][gc] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 78M->79M(128M) 17.377ms
[0.298s][info][gc] GC(3) Concurrent Cycle
[0.342s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 105M->105M(128M) 15.463ms
[0.356s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 109M->110M(384M) 8.594ms
[0.387s][info][gc] GC(3) Pause Remark 141M->118M(384M) 0.309ms
[0.452s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 139M->140M(384M) 23.378ms
[0.461s][info][gc] GC(3) Pause Cleanup 147M->147M(384M) 0.071ms
[0.463s][info][gc] GC(3) Concurrent Cycle 165.032ms
[0.605s][info][gc] GC(7) Pause Young (Concurrent Start) (G1 Humongous Allocation) 233M->235M(384M) 49.099ms
[0.605s][info][gc] GC(8) Concurrent Cycle
elapsed time 576
[0.751s][info][gc] GC(8) Pause Remark 300M->249M(419M) 0.825ms
[0.849s][info][gc] GC(8) Pause Cleanup 249M->249M(419M) 0.126ms
[0.851s][info][gc] GC(8) Concurrent Cycle 245.840ms
今回は、エスケープ解析ありでもなしでも性能に差がつかなくなりました。
実際、参照がエスケープしていますので、当然といえば当然の結果です。
まとめ
エスケープ解析はデフォルトで有効になっているため、普段そのご利益を感じることはないですが、
あらためてエスケープ解析を無効にした場合と速度を比較してみると顕著に差が出ました。
同じ振る舞いをするコードでも参照がエスケープしているかどうかによって、性能に差が出る場合もあるかもしれません。効率のいいコードを書くためにはこの辺りを気を付ける必要がありそうですね。