Java6中線程優化及基準測試思路 (一)

轉載的文章, 文章裏的思路太NB了。
原文: [url]http://www.infoq.com/cn/articles/java-threading-optimizations-p1[/url]

[b]介紹 — Java 6中的線程優化[/b]
Sun、IBM、BEA和其他公司在各自實現的Java 6虛擬機上都花費了大量的精力優化鎖的管理和同步。諸如偏向鎖(biased locking)、鎖粗化(lock coarsening)、由逸出(escape)分析產生的鎖省略、自適應自旋鎖(adaptive spinning)這些特性,都是通過在應用程序線程之間更高效地共享數據,從而提高併發效率。儘管這些特性都是成熟且有趣的,但是問題在於:它們的承諾真的能實現麼?在這篇由兩部分組成的文章裏,我將逐一探究這些特性,並嘗試在單一線程基準的協助下,回答關於性能的問題。

[b]悲觀鎖模型[/b]
Java支持的鎖模型絕對是悲觀鎖(其實,大多數線程庫都是如此)。如果有兩個或者更多線程使用數據時會彼此干擾,這種極小的風險也會強迫我們採用非常嚴厲的手段防止這種情況的發生——使用鎖。然而研究表明,鎖很少被佔用。也就是說,一個訪問鎖的線程很少必須等待來獲取它。但是請求鎖的動作將會觸發一系列的動作,這可能導致嚴重的系統開銷,這是不可避免的。

[table]
我們的確還有其他的選擇。舉例來說,考慮一下線程安全的StringBuffer的用法。問問你自己:是否你曾經明知道它只能被一個線程安全地訪問,還是堅持使用StringBuffer,爲什麼不用StringBuilder代替呢?
[/table]

知道大多數的鎖都不存在競爭,或者很少存在競爭的事實對我們作用並不大,因爲即使是兩個線程訪問相同數據的概率非常低,也會強迫我們使用鎖,通過同步來保護被訪問的數據。“我們真的需要鎖麼?”這個問題只有在我們將鎖放在運行時環境的上下文中觀察之後,才能最終給出答案。爲了找到問題的答案,JVM的開發者已經開始在HotSpot和JIT上進行了很多的實驗性的工作。現在,我們已經從這些工作中獲得了自適應自旋鎖、偏向鎖和以及兩種方式的鎖消除(lock elimination)——鎖粗化和鎖省略(lock elision)。在我們開始進行基準測試以前,先來花些時間回顧一下這些特性,這樣有助於理解它們是如何工作的。
[b]
逸出分析 — 簡析鎖省略(Escape analysis - lock elision explained)[/b]

逸出分析是對運行中的應用程序中的全部引用的範圍所做的分析。逸出分析是HotSpot分析工作的一個組成部分。如果HotSpot(通過逸出分析)能夠判斷出指向某個對象的多個引用被限制在局部空間內,並且所有這些引用都不能“逸出”到這個空間以外的地方,那麼HotSpot會要求JIT進行一系列的運行時優化。其中一種優化就是鎖省略(lock elision)。如果鎖的引用限制在局部空間中,說明只有創建這個鎖的線程纔會訪問該鎖。在這種條件下,同步塊中的值永遠不會存在競爭。這意味這我們永遠不可能真的需要這把鎖,它可以被安全地忽略掉。考慮下面的方法:


publicString concatBuffer(String s1, String s2, String s3) {,
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}


如果我們觀察變量sb,很快就會發現它僅僅被限制在concatBuffer方法內部了。進一步說,到sb的所有引用永遠不會“逸出”到 concatBuffer方法之外,即聲明它的那個方法。因此其他線程無法訪問當前線程的sb副本。根據我們剛介紹的知識,我們知道用於保護sb的鎖可以忽略掉。

從表面上看,鎖省略似乎可以允許我們不必忍受同步帶來的負擔,就可以編寫線程安全的代碼了,前提是在同步的確是多餘的情況下。鎖省略是否真的能發揮作用呢?這是我們在後面的基準測試中將要回答的問題。

[b]簡析偏向鎖(Biased locking explained)[/b]
大多數鎖,在它們的生命週期中,從來不會被多於一個線程所訪問。即使在極少數情況下,多個線程真的共享數據了,鎖也不會發生競爭。爲了理解偏向鎖的優勢,我們首先需要回顧一下如何獲取鎖(監視器)。

獲取鎖的過程分爲兩部分。首先,你需要獲得一份契約.一旦你獲得了這份契約,就可以自由地拿到鎖了。爲了獲得這份契約,線程必須執行一個代價昂貴的原子指令。釋放鎖同時就要釋放契約。根據我們的觀察,我們似乎需要對一些鎖的訪問進行優化,比如線程執行的同步塊代碼在一個循環體中。優化的方法之一就是將鎖粗化,以包含整個循環。這樣,線程只訪問一次鎖,而不必每次進入循環時都進行訪問了。但是,這並非一個很好的解決方案,因爲它可能會妨礙其他線程合法的訪問。還有一個更合理的方案,即將鎖偏向給執行循環的線程。

將鎖偏向於一個線程,意味着該線程不需要釋放鎖的契約。因此,隨後獲取鎖的時候可以不那麼昂貴。如果另一個線程在嘗試獲取鎖,那麼循環線程只需要釋放契約就可以了。Java 6的HotSpot/JIT默認情況下實現了偏向鎖的優化。

[b]簡析鎖粗化(Lock coarsening explained)[/b]
另一種線程優化方式是鎖粗化(或合併,merging)。當多個彼此靠近的同步塊可以合併到一起,形成一個同步塊的時候,就會進行鎖粗化。該方法還有一種變體,可以把多個同步方法合併爲一個方法。如果所有方法都用一個鎖對象,就可以嘗試這種方法。考慮圖2中的實例。


public static String concatToBuffer(StringBuffer sb, String s1, String s2, String s3) {
sb.append(s1);
sb.append(s2);
sb.append(s3);
return
}


在這個例子中,StringBuffer的作用域是非局部的,可以被多個線程訪問。所以逸出分析會判斷出StringBuffer的鎖不能安全地被忽略。如果鎖剛好只被一個線程訪問,則可以使用偏向鎖。有趣的是,是否進行鎖粗化,與競爭鎖的線程數量是無關的。在上面的例子中,鎖的實例會被請求四次:前三次是執行append方法,最後一次是執行toString方法,緊接着前一個。首先要做的是將這種方法進行內聯。然後我們只需執行一次獲取鎖的操作(爲整個方法),而不必像以前一樣獲取四次鎖了。

這種做法帶來的真正效果是我們獲得了一個更長的臨界區,它可能導致其他線程受到拖延從而降低吞吐量。正因爲這些原因,一個處於循環內部的鎖是不會被粗化到包含整個循環體的。

[b]線程掛起 vs. 自旋(Thread suspending versus spinning)[/b]
在一個線程等待另外一個線程釋放某個鎖的時候,它通常會被操作系統掛起。操作在掛起一個線程的時候需要將它換出CPU,而通常此時線程的時間片還沒有使用完。當擁有鎖的線程離開臨界區的時候,掛起的線程需要被重新喚醒,然後重新被調用,並交換上下文,回到CPU調度中。所有這些動作都會給JVM、 OS和硬件帶來更大的壓力。

在這個例子中,如果注意到下面的事實會很有幫助:鎖通常只會被佔有很短的一段時間。這就是說,如果能夠等上一會兒,我們可以避免掛起線程的開銷。爲了讓線程等待,我們只需將線程執行一個忙循環(自旋)。這項技術就是所謂的自旋鎖。

當鎖被佔有的時間很短時,自旋鎖的效果非常好。另一方面,如果鎖被佔有很長時間,那麼自旋的線程只會消耗CPU而不做任何有用的工作,因此帶來浪費。自從JDK 1.4.2中引入自旋鎖以來,自旋鎖被分爲兩個階段,自旋十個循環(默認值),然後掛起線程。

[b]自適應自旋鎖(Adaptive spinning)[/b]
JDK 1.6中引入了自適應自旋鎖。自適應意味着自旋的時間不再固定了,而是取決於一個基於前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態。如果在同一個鎖對象上,自旋剛剛成功過,並且持有鎖的線程正在運行中,那麼自旋很有可能再次成功。進而它將被應用於相對更長的時間,比如100個循環。另一方面,如果自旋很少發生過,它將被遺棄,避免浪費任何CPU週期

[b]StringBuffer vs. StringBuilder的基準測試[/b]
但是要想設計出一種方法來判斷這些巧妙的優化方法到底多有效,這條路並不平坦。首要的問題就是如何設計基準測試。爲了找到問題的答案,我決定去看看人們通常在代碼中運用了哪些常見的技巧。我首先想到的是一個非常古老的問題:使用StringBuffer代替String可以減少多少開銷?

一個類似的建議是,如果你希望字符串是可變的,就應該使用StringBuffer。這個建議的緣由是非常明確的。String是不可變的,但如果我們的工作需要字符串有很多變化,StringBuffer將是一個開銷較低的選擇。有趣的是,在遇到JDK 1.5中的StringBuilder(它是StringBuffer的非同步版本)後,這條建議就不靈了。由於StringBuilder與 StringBuffer之間唯一的不同在於同步性,這似乎說明,測量兩者之間性能差異的基準測試必須關注在同步的開銷上。我們的探索從第一個問題開始,非競爭鎖的開銷如何?

這個基準測試的關鍵(如清單1所示)在於將大量的字符串拼接在一起。底層緩衝的初始容量足夠大,可以包含三個待連接的字符串。這樣我們可以將臨界區內的工作最小化,進而重點測量同步的開銷。
[b]基準測試的結果[/b]
下圖是測試結果,包括EliminateLocks、UseBiasedLocking和DoEscapeAnalysis的不同組合。
[img]http://dl.iteye.com/upload/attachment/455913/4e7dc9b7-e153-347b-be6a-911a8aa3d836.png[/img]

圖3. 基準測試的結果
[b]關於結果的討論[/b]
之所以使用非同步的StringBuilder,是爲了提供一個測量性能的基線。我也想了解一下各種優化是否真的能夠影響 StringBuilder的性能。正如我們所看到的,StringBuilder的性能可以保持在一個不變的吞吐量水平上。因爲這些技術的目標在於鎖的優化,因此這個結果符合預期。在性能測試的另一欄中我們也可以看到,使用沒有任何優化的同步的StringBuffer,其運行效率比 StringBuilder大概要慢三倍。

仔細觀察圖3的結果,我們可以注意到從左到右性能有一定的提高,這可以歸功於EliminateLocks。不過,這些性能的提升比起偏向鎖來說又顯得有些蒼白。事實上,除了C列以外,每次運行時如果開啓偏向鎖最終都會提供大致相同的性能提升。但是,C列是怎麼回事呢?

在處理最初的數據的過程中,我注意到有一項測試在六個測試中要花費格外長的時間。由於結果的異常相當明顯,因此基準測試似乎在報告兩個完全不同的優化行爲。經過一番考慮,我決定同時展示出高值和低值(B列和C列)。由於沒有更深入的研究,我只能猜測這裏應用了一種以上的優化(很可能是兩種),並且存在一些競爭條件,偏向鎖大多時候會取勝,但不非總能取勝。如果另一種優化佔優了,那麼偏向鎖的效果要麼被抑制,要麼就被延遲了。

這種奇怪的現象是逸出分析導致的。明確了這個基準測試的單線程化的本質後,我期待着逸出分析會消除鎖,從而將StringBuffer的性能提到了與 StringBuilder相同的水平。但是很明顯,這並沒有發生。還有另外一個問題;在我的機器上,每一次運行的時間片分配都不盡相同。更爲複雜的是,我的幾位同事在他們的機器上運行這些測試,得到的結果更混亂了。在有些時候,這些優化並沒有將程序提速那麼多。

[b]前期的結論[/b]
儘管圖3列出的結果比我所期望的要少,但確實可以從中看出各種優化能夠除去鎖產生的大部分開銷。但是,我的同事在運行這些測試時產生了不同的結果,這似乎對測試結果的真實性提出了挑戰。這個基準測試真的測量鎖的開銷了麼?我們的結論成熟麼?或者還有沒有其他的情況?在本文的第二部分裏,我們將會深入研究這個基準測試,力爭回答這些問題。在這個過程中,我們會發現獲取結果並不困難,困難的是判斷出這些結果是否可以回答前面提出的問題。


public class LockTest {
private static final int MAX = 20000000; // 20 million

public static void main(String[] args) throws InterruptedException {
// warm up the method cache
for (int i = 0; i < MAX; i++) {
concatBuffer("Josh", "James", "Duke");
concatBuilder("Josh", "James", "Duke");
}

System.gc();
Thread.sleep(1000);

System.out.println("Starting test");
long start = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
concatBuffer("Josh", "James", "Duke");
}
long bufferCost = System.currentTimeMillis() - start;
System.out.println("StringBuffer: " + bufferCost + " ms.");

System.gc();
Thread.sleep(1000);

start = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
concatBuilder("Josh", "James", "Duke");
}
long builderCost = System.currentTimeMillis() - start;
System.out.println("StringBuilder: " + builderCost + " ms.");
System.out.println("Thread safety overhead of StringBuffer: "
+ ((bufferCost * 10000 / (builderCost * 100)) - 100) + "%\n");

}

public static String concatBuffer(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

public static String concatBuilder(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

}


[b]運行基準測試[/b]
我運行這個測試的環境是:32位的Windows Vista筆記本電腦,配有Intel Core 2 Duo,使用Java 1.6.0_04。請注意,所有的優化都是在Server VM上實現的。但這在我的平臺上不是默認的VM,它甚至不能在JRE中使用,只能在JDK中使用。爲了確保我使用的是Server VM,我需要在命令行上打開-server選項。其他的選項包括:

* -XX:+DoEscapeAnalysis, off by default
* -XX:+UseBiasedLocking, on by default
* -XX:+EliminateLocks, on by default

編譯源代碼,運行下面的命令,可以啓動測試:

java-server -XX:+DoEscapeAnalysis LockTest


[b]資源[/b]

Java theory and practice: Synchronization optimizations in Mustang
[url]http://www.ibm.com/developerworks/java/library/j-jtp10185/[/url]

Java SE 6 Performance White Paper
[url]http://java.sun.com/performance/reference/whitepapers/6_performance.html[/url]

Dave Dice's Weblog:[url]http://blogs.sun.com/dave/entry/biased_locking_in_hotspot[/url]
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章