HotSpot JVM參數 -XX:+DoEscapeAnalysis 有什麼影響?

-XX:+UseBiasedLocking
-XX:-UseBiasedLocking

-XX:+DoEscapeAnalysis
-XX:-DoEscapeAnalysis

-XX:+EliminateLocks
-XX:-EliminateLocks

想知道 只有單線程時,jvm參數 UseBiasedLocking, DoEscapeAnalysis, EliminateLocks 對 synchronized 的優化 產生什麼影響。於是我使用jmh作 微基準測試。將 它們三個 的開啟關閉 組合 到 @Fork參數的jvmArgsAppend,從而 得到 8 種不同的組合,然後,統計方法的耗時。程序代碼 和 最終得到的結果列表如下:

執行上面Benchmark測試,於是得到數據如下:

只有DoEscapeAnalysis不同,我將其分為了4組

現象1:
sycn_NoBiased_NoEscape_Eliminate_1 22.723
sycn_NoBiased_Escape_Eliminate_3 7.007

鎖消除、無偏向鎖:DoEscapeAnalysis影響很大

現象2:
sycn_Biased_NoEscape_NoEliminate_4 12.259
sycn_Biased_Escape_NoEliminate_6 63.063
無鎖消除、有偏向鎖:DoEscapeAnalysis影響很大

現象3:
sycn_NoBiased_Escape_NoEliminate_2 63.209
sycn_NoBiased_NoEscape_NoEliminate_0 63.505
無鎖消除、無偏向鎖:DoEscapeAnalysis影響極小

現象4:
sycn_Biased_Escape_Eliminate_7 8.924
sycn_Biased_NoEscape_Eliminate_5 10.699
鎖消除、有偏向鎖:DoEscapeAnalysis影響很小

我了解的 DoEscapeAnalysis的作用是:
1. 優化對象分配,將對象分配到stack上,從而消除了gc開銷(pop stack frame時,對象直接就丟棄了)
2. 我也試驗了 OnStackReplacement 開啟與關閉,影響很小

問題:
所以,現在很迷惑,現象1 和 現象2 性能差距很是很大的,所以 DoEscapeAnalysis 到底做了什麼?(當然,倒不是真的在乎這幾個納秒,就是很迷惑,DoEscapeAnalysis 到底 會有什麼魔法操作)

**************************************************************************************************
2017-01-12補充代碼,知乎限制,不能將代碼貼進來,所以,貼上了代碼的鏈接

LockElision_LocalVariable.java 測試的是單線程執行時,對 local variable 有什麼影響,代碼是:
https://github.com/marvin263/letscode/blob/master/tntrip/understandlambda/jmh-samples/src/main/java/org/openjdk/jmh/samples/LockElision_LocalVariable.java

我還測試了單線程執行時,對 instance field 有什麼影響(我自己認為,對於local variable JVM更容易知道 根本就不存在 變數訪問競爭,所以很容易的做出 鎖消除 的決定,但是,對於instance field,JVM可能更難確定 是否存在 變數訪問競爭,所以,不能很好的執行鎖消除)。LockElision_InstanceField.java的代碼是:
https://github.com/marvin263/letscode/blob/master/tntrip/understandlambda/jmh-samples/src/main/java/org/openjdk/jmh/samples/LockElision_InstanceField.java

我還使用 UnsyncSB.java 測試了下,不使用 syncrhonized關鍵字時 的耗時。當然,正如這個問題一直所說的,所有的測試都使用單線程時。我的測試結果如下:

也可以直接運行代碼,然後將運行的結果copy到某個編輯器中,滑鼠用列選擇模式,將其copy進Excel,在將數據用 圖表形式顯示出來


這個問題要準確地回答需要講解很多實現細節。我有點懶得碼那麼多字,不然就跟寫產品文檔一樣了…隨便寫點吧,題主先湊合看看,有啥具體問題我再具體補充。

先整理一下題主可能混淆的一些信息:

目前(JDK8u)HotSpot VM里,默認有兩個JIT編譯器,C1(Client Compiler)和C2(Server Compiler)。-XX:+DoEscapeAnalysis 和 -XX:+EliminateLocks 都是C2特有的功能,只在一個方法被C2編譯後才會得到體現。
未來HotSpot VM里C2有可能被Graal替代,而Graal實現的逃逸分析與鎖消除跟C2不一樣(大部分情況下比C2更先進、更有效),所以這個回答提到的實現細節不適用於未來對Graal的討論。

-XX:+OnStackReplacement (OSR)說的「OSR」功能跟本回答所討論的功能沒有任何關係。它講的是在一個有不同優化層級的執行引擎中,在一個方法還在執行的過程中從一個層級跳轉到另一個層級執行的機制。請跳傳送門:OSR(On-Stack Replacement)是怎樣的機制?

HotSpot C2在逃逸分析後對對象內存分配做優化的功能,叫做「scalar replacement」(在其它編譯器中可能叫做SRoA,「Scalar Replacement of Aggregates」)。它由參數 -XX:+EliminateAllocations 所控制。

==========================================

-XX:+DoEscapeAnalysis

HotSpot C2所實現的逃逸分析(escape analysis)基本上是基於下面這篇論文的思路來實現的:
Escape Analysis for Java, OOPSLA 1999
基本上是一個忠於原始論文的實現,只做了比較少量的細節變更。

它所能做到的是一個path-insensitive的分析,看在某個方法內分配的對象,是否只被局部變數所引用,或者只被當作參數傳遞給一些滿足特定條件的方法,等等。
它能判斷的逃逸程度,請跳這個傳送門:JVM 能否實現:找到一定不會「流出」某個代碼塊的對象,並且確定地析構它,甚至把它直接「鑲嵌」在棧上? - RednaxelaFX 的回答 - 知乎
另外附送一個傳送門:逃逸分析為何不能在編譯期進行? - RednaxelaFX 的回答 - 知乎

注意,這裡分析的對象一定是:編譯某個方法(編譯單元)時,在這個方法內分配的對象(也就是在該方法及其被內聯的方法中直接new出來的對象)。而在編譯單元外分配的對象(例如進入該方法之前已經分配出來的對象,或者在未被內聯的方法中分配的對象)並不在分析範圍之內。

當C2編譯某個方法時,完成逃逸分析後,它就可以進一步做一些優化:

  • 標量替換(scalar replacement):對於沒有逃逸出當前方法(當前編譯單元)的對象,徹底消除對象的內存分配,把它的欄位爆開為一個個獨立的局部變數;
  • 鎖消除(lock elision):對於沒有逃逸出當前線程的對象,消除對該對象的鎖。這裡說的鎖限定於synchronized意義上的,不包括j.u.c的Lock。

再提醒一次,分析歸分析,優化歸優化。有些優化依賴於某些分析,但分析自身並不做優化…
HotSpot C2所做的標量替換是由 -XX:+EliminateAllocations 參數所控制的,默認打開但要在-XX:+DoEscapeAnalysis也打開的情況下才起作用。

HotSpot C2所做的逃逸分析大部分情況是只針對被編譯的方法及所有被內聯進來的方法來做分析的。但有時候有些重要的方法沒被內聯,而逃逸分析需要分析整個調用圖(call graph)才能知道一個對象分配有沒有逃逸出線程,怎麼辦?
於是HotSpot VM里還有另一個保守的逃逸分析實現,叫做Bytecode Escape Analysis(bcEscapeAnalyzer),可選用於分析未被C2內聯的方法里各個引用的逃逸狀況。這個就不在這裡詳細說了。

==========================================

-XX:+EliminateLocks

這個參數掌控HotSpot C2的lock coarsening與lock elision兩種優化。這兩個優化分別對應不同的場景。前者不依賴於逃逸分析,而後者依賴於逃逸分析。

Lock coarsening:
當一個方法(編譯單元)內,有多個synchronized塊對同一個對象加鎖的時候,把這些synchronized塊合併為一個。例如說這樣:

void foo() {
doSomething1();
synchronize (this) {
doSomething2();
}
doSomething3();
synchronized (this) {
doSomething4();
}
doSomething5();
}

經過lock coarsening之後,可以變成:

void foo() {
doSomething1();
synchronize (this) {
// coarsened lock region
doSomething2();
doSomething3();
doSomething4();
}
doSomething5();
}

同理,lock coarsening也可以對同一個方法(編譯單元)內的嵌套鎖做優化:

void foo() {
doSomething1();
synchronize (this) {
doSomething2();
synchronized (this) {
doSomething3();
}
doSomething4();
}
doSomething5();
}

同樣可以被優化為:

void foo() {
doSomething1();
synchronize (this) {
// coarsened lock region
doSomething2();
doSomething3();
doSomething4();
}
doSomething5();
}

Lock elision:
針對沒有逃逸出線程的對象,可以直接將對它的鎖徹底消除。例如說:

Class& bar(Object o) {
return o.getClass();
}

void foo() {
Object o = new Object(); // allocation within this method
bar(o); // may call a method that either gets fully inlined,
// or one that doesn"t let the allocation escape the thread
synchronized (o) {
doSomething();
}
}

foo()之中的synchronize是對一個未逃逸出當前線程的對象加鎖,因而可以被徹底消除:

void foo() {
Object o = new Object(); // allocation within this method
bar(o); // may call a method that either gets fully inlined,
// or one that doesn"t let the allocation escape the thread
doSomething();
}

==========================================

-XX:+UseBiasLocking

偏向鎖,也可以叫做「lazy unlocking」。簡單來說就是當一個線程持有了一個對象的鎖之後,在unlock(monitorexit)的時候並不真的釋放這個鎖,而是等到有別的線程要申請對這把鎖的所有權時才釋放(在HotSpot里這叫做revoke bias / rebias)。
這樣如果一個線程A持有了一個對象的鎖之後做了lazy unlocking,別的線程沒有來申請這個對象的鎖,而線程A要再次獲得這個對象的鎖(monitorenter)的話,就不用再重複加鎖了。

顯然,如果一個鎖已經被lock elision消除了,它就沒biased locking什麼事了——都沒鎖了還偏啥向呢。

==========================================

題主的例子的簡單分析

這裡我只要做實驗來看HotSpot C2生成的代碼的狀況,所以稍微簡化一下題主的測試用例,把它從JMH中拿出來單獨跑。
完整的實驗信息我放在Gist上了:https://gist.github.com/rednaxelafx/c5ab8a79fb3fc5ad7cbbc08154cfa92c
下面是代碼:

SynchSB.java

public class SynchSB {
StringBuilder sb = new StringBuilder();

public synchronized int length() {
return sb.length();
}

public synchronized StringBuilder append(String str) {
return sb.append(str);
}

public synchronized StringBuilder delete(int start, int end) {
return sb.delete(start, end);
}
}

這個跟題主的保持一致

TestSynchSB.java

public class TestSynchSB {
public static StringBuilder doTest() {
SynchSB thesb = new SynchSB();
for (int i = 0; i &< 10000; i++) { thesb.append("abc"); thesb.delete(0, thesb.length()); } return thesb.sb; } public static void main(String[] args) throws Exception { for (int i = 0; i &< 200; i++) { doTest(); } System.out.println("done."); System.in.read(); } }

這個只是用來保證觸發我需要的層次的HotSpot C2編譯。

使用合適的VM啟動參數來跑實驗,可以看到HotSpot C2編譯doTeset()時的內聯狀況:

389 48 b 4 TestSynchSB::doTest (45 bytes)
@ 4 SynchSB::& (16 bytes) inline (hot)
@ 1 java.lang.Object::& (1 bytes) inline (hot)
@ 9 java.lang.StringBuilder::& (7 bytes) inline (hot)
@ 3 java.lang.AbstractStringBuilder::& (12 bytes) inline (hot)
@ 1 java.lang.Object::& (1 bytes) inline (hot)
s @ 20 SynchSB::append (9 bytes) inline (hot)
@ 5 java.lang.StringBuilder::append (8 bytes) inline (hot)
@ 2 java.lang.AbstractStringBuilder::append (50 bytes) inline (hot)
@ 10 java.lang.String::length (6 bytes) inline (hot)
@ 21 java.lang.AbstractStringBuilder::ensureCapacityInternal (16 bytes) inline (hot)
@ 12 java.lang.AbstractStringBuilder::expandCapacity (50 bytes) too big
@ 35 java.lang.String::getChars (62 bytes) inline (hot)
@ 58 java.lang.System::arraycopy (0 bytes) (intrinsic)
s @ 27 SynchSB::length (8 bytes) inline (hot)
@ 4 java.lang.StringBuilder::length (5 bytes) inline (hot)
@ 1 java.lang.AbstractStringBuilder::length (5 bytes) accessor
s @ 30 SynchSB::delete (10 bytes) inline (hot)
@ 6 java.lang.StringBuilder::delete (9 bytes) inline (hot)
@ 3 java.lang.AbstractStringBuilder::delete (80 bytes) inline (hot)
@ 65 java.lang.System::arraycopy (0 bytes) (intrinsic)

如果我們人肉把上述內聯樹映射為Java源碼,內聯後的doTest()大致上會是這個樣子的:

public static StringBuilder doTest() {
SynchSB thesb = allocate(SynchSB.class);
{
StringBuilder sb = allocate(StringBuilder.class);
sb.value = new char[16];
thesb.sb = sb;
}
for (int i = 0; i &< 10000; i++) { synchronized (thesb) { StringBuilder sb = thesb.sb; String str = "abc"; int len = 3; // str.value.length; int minimalCapacity = sb.count + len; if (minimalCapacity - sb.value.length &> 0) {
{
int newCapacity = sb.value.length * 2 + 2;
if (newCapacity - minimumCapacity &< 0) newCapacity = minimumCapacity; if (newCapacity &< 0) { if (minimumCapacity &< 0) // overflow throw new OutOfMemoryError(); newCapacity = Integer.MAX_VALUE; } { char[] original = sb.value; char[] copy = new char[newCapacity]; System.arraycopy(original, 0, copy, 0, Math.min(original.length, newCapacity)); sb.value = copy; } } } System.arraycopy(str.value, 0, sb.value, sb.count, len); sb.count += len; } int sblen; synchronized (thesb) { sblen = thesb.sb.length; } synchronized (thesb) { StringBuilder sb = thesb.sb; System.arraycopy(sb.value, sblen, sb.value, 0, 0); sb.count = 0; } } return thesb.sb; }

可以看到,在for循環里會暴露出三個synchronized塊(來自SynchSB上的三個synchronized方法)。

通過 DoEscapeAnalysis ,doTest()中所分配的SynchSB對象會被逃逸分析發現完全不逃逸出doTest();
+ EliminateLocks ,這三個針對thesb(那個SynchSB對象的引用)的synchronized塊的鎖/解鎖操作都會被完全消除掉;
+ EliminateAllocations,這個SynchSB對象會被標量替換,從而不需要在Java堆上分配內存。其欄位(StringBuilder sb)會直接被當作一個獨立的局部變數來看待,可以被分配到寄存器上。


推薦閱讀:

TAG:編譯原理 | HotSpotVM |