與許多其它的編程語言不同,Java語言規范包括對線程和并發的明確支持。語言本身支持并發,這使得指定和管理共享數據的約束以及跨線程操作的計時變得更簡單,但是這沒有使得并發編程的復雜性更易于理解。這個三部分的系列文章的目的在于幫助程序員理解用Java 語言進行多線程編程的一些主要問題,特別是線程安全對 Java程序性能的影響。
請點擊文章頂部或底部的 討論進入由 Brian Goetz 主持的 “Java線程:技巧、竅門和技術”討論論壇,與本文作者和其他讀者交流您對本文或整個多線程的想法。注意該論壇討論的是使用多線程時遇到的所有問題,而并不限于本文的內容。
大多數編程語言的語言規范都不會談到線程和并發的問題;因為一直以來,這些問題都是留給平臺或操作系統去詳細說明的。但是,Java 語言規范(JLS)卻明確包括一個線程模型,并提供了一些語言元素供開發人員使用以保證他們程序的線程安全。
對線程的明確支持有利也有弊。它使得我們在寫程序時更容易利用線程的功能和便利,但同時也意味著我們不得不注意所寫類的線程安全,因為任何類都很有可能被用在一個多線程的環境內。
許多用戶第一次發現他們不得不去理解線程的概念的時候,并不是因為他們在寫創建和管理線程的程序,而是因為他們正在用一個本身是多線程的工具或框架。任何用過 Swing GUI 框架或寫過小服務程序或 JSP 頁的開發人員(不管有沒有意識到)都曾經被線程的復雜性困擾過。
Java 設計師是想創建一種語言,使之能夠很好地運行在現代的硬件,包括多處理器系統上。要達到這一目的,管理線程間協調的工作主要推給了軟件開發人員;程序員必須指定線程間共享數據的位置。在 Java 程序中,用來管理線程間協調工作的主要工具是 synchronized
關鍵字。在缺少同步的情況下,JVM 可以很自由地對不同線程內執行的操作進行計時和排序。在大部分情況下,這正是我們想要的,因為這樣可以提高性能,但它也給程序員帶來了額外的負擔,他們不得不自己識別什么時候這種性能的提高會危及程序的正確性。
synchronized 真正意味著什么?
大部分 Java 程序員對同步的塊或方法的理解是完全根據使用互斥(互斥信號量)或定義一個臨界段(一個必須原子性地執行的代碼塊)。雖然 synchronized
的語義中確實包括互斥和原子性,但在管程進入之前和在管程退出之后發生的事情要復雜得多。
synchronized
的語義確實保證了一次只有一個線程可以訪問被保護的區段,但同時還包括同步線程在主存內互相作用的規則。理解 Java 內存模型(JMM)的一個好方法就是把各個線程想像成運行在相互分離的處理器上,所有的處理器存取同一塊主存空間,每個處理器有自己的緩存,但這些緩存可能并不總和主存同步。在缺少同步的情況下,JMM 會允許兩個線程在同一個內存地址上看到不同的值。而當用一個管程(鎖)進行同步的時候,一旦申請加了鎖,JMM 就會馬上要求該緩存失效,然后在它被釋放前對它進行刷新(把修改過的內存位置寫回主存)。不難看出為什么同步會對程序的性能影響這么大;頻繁地刷新緩存代價會很大。
使用一條好的運行路線
如果同步不適當,后果是很嚴重的:會造成數據混亂和爭用情況,導致程序崩潰,產生不正確的結果,或者是不可預計的運行。更糟的是,這些情況可能很少發生且具有偶然性(使得問題很難被監測和重現)。如果測試環境和開發環境有很大的不同,無論是配置的不同,還是負荷的不同,都有可能使得這些問題在測試環境中根本不出現,從而得出錯誤的結論:我們的程序是正確的,而事實上這些問題只是還沒出現而已。
|
爭用情況定義
爭用情況是一種特定的情況:兩個或更多的線程或進程讀或寫一些共享數據,而最終結果取決于這些線程是如何被調度計時的。爭用情況可能會導致不可預見的結果和隱蔽的程序錯誤。
|
|
另一方面,不當或過度地使用同步會導致其它問題,比如性能很差和死鎖。當然,性能差雖然不如數據混亂那么嚴重,但也是一個嚴重的問題,因此同樣不可忽視。編寫優秀的多線程程序需要使用好的運行路線,足夠的同步可以使您的數據不發生混亂,但不需要濫用到去承擔死鎖或不必要地削弱程序性能的風險。
同步的代價有多大?
由于包括緩存刷新和設置失效的過程,Java 語言中的同步塊通常比許多平臺提供的臨界段設備代價更大,這些臨界段通常是用一個原子性的“test and set bit”機器指令實現的。即使一個程序只包括一個在單一處理器上運行的單線程,一個同步的方法調用仍要比非同步的方法調用慢。如果同步時還發生鎖定爭用,那么性能上付出的代價會大得多,因為會需要幾個線程切換和系統調用。
幸運的是,隨著每一版的 JVM 的不斷改進,既提高了 Java 程序的總體性能,同時也相對減少了同步的代價,并且將來還可能會有進一步的改進。此外,同步的性能代價經常是被夸大的。一個著名的資料來源就曾經引證說一個同步的方法調用比一個非同步的方法調用慢 50 倍。雖然這句話有可能是真的,但也會產生誤導,而且已經導致了許多開發人員即使在需要的時候也避免使用同步。
嚴格依照百分比計算同步的性能損失并沒有多大意義,因為一個無爭用的同步給一個塊或方法帶來的是固定的性能損失。而這一固定的延遲帶來的性能損失百分比取決于在該同步塊內做了多少工作。對一個 空方法的同步調用可能要比對一個空方法的非同步調用慢 20 倍,但我們多長時間才調用一次空方法呢?當我們用更有代表性的小方法來衡量同步損失時,百分數很快就下降到可以容忍的范圍之內。
表 1 把一些這種數據放在一起來看。它列舉了一些不同的實例,不同的平臺和不同的 JVM 下一個同步的方法調用相對于一個非同步的方法調用的損失。在每一個實例下,我運行一個簡單的程序,測定循環調用一個方法 10,000,000 次所需的運行時間,我調用了同步和非同步兩個版本,并比較了結果。表格中的數據是同步版本的運行時間相對于非同步版本的運行時間的比率;它顯示了同步的性能損失。每次運行調用的都是清單 1 中的簡單方法之一。
表格 1 中顯示了同步方法調用相對于非同步方法調用的相對性能;為了用絕對的標準測定性能損失,必須考慮到 JVM 速度提高的因素,這并沒有在數據中體現出來。在大多數測試中,每個 JVM 的更高版本都會使 JVM 的總體性能得到很大提高,很有可能 1.4 版的 Java 虛擬機發行的時候,它的性能還會有進一步的提高。
表 1. 無爭用同步的性能損失
JDK
|
staticEmpty
|
empty
|
fetch
|
hashmapGet
|
singleton
|
create
|
Linux / JDK 1.1 |
9.2 |
2.4 |
2.5 |
n/a |
2.0 |
1.42 |
Linux / IBM Java SDK 1.1 |
33.9 |
18.4 |
14.1 |
n/a |
6.9 |
1.2 |
Linux / JDK 1.2 |
2.5 |
2.2 |
2.2 |
1.64 |
2.2 |
1.4 |
Linux / JDK 1.3 (no JIT) |
2.52 |
2.58 |
2.02 |
1.44 |
1.4 |
1.1 |
Linux / JDK 1.3 -server |
28.9 |
21.0 |
39.0 |
1.87 |
9.0 |
2.3 |
Linux / JDK 1.3 -client |
21.2 |
4.2 |
4.3 |
1.7 |
5.2 |
2.1 |
Linux / IBM Java SDK 1.3 |
8.2 |
33.4 |
33.4 |
1.7 |
20.7 |
35.3 |
Linux / gcj 3.0 |
2.1 |
3.6 |
3.3 |
1.2 |
2.4 |
2.1 |
Solaris / JDK 1.1 |
38.6 |
20.1 |
12.8 |
n/a |
11.8 |
2.1 |
Solaris / JDK 1.2 |
39.2 |
8.6 |
5.0 |
1.4 |
3.1 |
3.1 |
Solaris / JDK 1.3 (no JIT) |
2.0 |
1.8 |
1.8 |
1.0 |
1.2 |
1.1 |
Solaris / JDK 1.3 -client |
19.8 |
1.5 |
1.1 |
1.3 |
2.1 |
1.7 |
Solaris / JDK 1.3 -server |
1.8 |
2.3 |
53.0 |
1.3 |
4.2 |
3.2 |
清單 1. 基準測試中用到的簡單方法
public static void staticEmpty() { }
public void empty() { }
public Object fetch() { return field; }
public Object singleton() {
if (singletonField == null)
singletonField = new Object();
return singletonField;
}
public Object hashmapGet() {
return hashMap.get("this");
}
public Object create() {
return new Object();
}
|
這些小基準測試也闡明了存在動態編譯器的情況下解釋性能結果所面臨的挑戰。對于 1.3 JDK 在有和沒有 JIT 時,數字上的巨大差異需要給出一些解釋。對那些非常簡單的方法( empty
和 fetch
),基準測試的本質(它只是執行一個幾乎什么也不做的緊湊的循環)使得 JIT 可以動態地編譯整個循環,把運行時間壓縮到幾乎沒有的地步。但在一個實際的程序中,JIT 能否這樣做就要取決于很多因素了,所以,無 JIT 的計時數據可能在做公平對比時更有用一些。在任何情況下,對于更充實的方法( create
和 hashmapGet
),JIT 就不能象對更簡單些的方法那樣使非同步的情況得到巨大的改進。另外,從數據中看不出 JVM 是否能夠對測試的重要部分進行優化。同樣,在可比較的 IBM 和 Sun JDK 之間的差異反映了 IBM Java SDK 可以更大程度地優化非同步的循環,而不是同步版本代價更高。這在純計時數據中可以明顯地看出(這里不提供)。
從這些數字中我們可以得出以下結論:對非爭用同步而言,雖然存在性能損失,但在運行許多不是特別微小的方法時,損失可以降到一個合理的水平;大多數情況下損失大概在 10% 到 200% 之間(這是一個相對較小的數目)。所以,雖然同步每個方法是不明智的(這也會增加死鎖的可能性),但我們也不需要這么害怕同步。這里使用的簡單測試是說明一個無爭用同步的代價要比創建一個對象或查找一個 HashMap
的代價小。
由于早期的書籍和文章暗示了無爭用同步要付出巨大的性能代價,許多程序員就竭盡全力避免同步。這種恐懼導致了許多有問題的技術出現,比如說 double-checked locking(DCL)。許多關于 Java 編程的書和文章都推薦 DCL,它看上去真是避免不必要的同步的一種聰明的方法,但實際上它根本沒有用,應該避免使用它。DCL 無效的原因很復雜,已超出了本文討論的范圍(要深入了解,請參閱 參考資料里的鏈接)。
不要爭用
假設同步使用正確,若線程真正參與爭用加鎖,您也能感受到同步對實際性能的影響。并且無爭用同步和爭用同步間的性能損失差別很大;一個簡單的測試程序指出爭用同步比無爭用同步慢 50 倍。把這一事實和我們上面抽取的觀察數據結合在一起,可以看出使用一個爭用同步的代價至少相當于創建 50 個對象。
所以,在調試應用程序中同步的使用時,我們應該努力減少實際爭用的數目,而根本不是簡單地試圖避免使用同步。這個系列的第 2 部分將把重點放在減少爭用的技術上,包括減小鎖的粒度、減小同步塊的大小以及減小線程間共享數據的數量。
什么時候需要同步?
要使您的程序線程安全,首先必須確定哪些數據將在線程間共享。如果正在寫的數據以后可能被另一個線程讀到,或者正在讀的數據可能已經被另一個線程寫過了,那么這些數據就是共享數據,必須進行同步存取。有些程序員可能會驚訝地發現,這些規則在簡單地檢查一個共享引用是否非空的時候也用得上。
許多人會發現這些定義驚人地嚴格。有一種普遍的觀點是,如果只是要讀一個對象的字段,不需要請求加鎖,尤其是在 JLS 保證了 32 位讀操作的原子性的情況下,它更是如此。但不幸的是,這個觀點是錯誤的。除非所指的字段被聲明為 volatile
,否則 JMM 不會要求下面的平臺提供處理器間的緩存一致性和順序連貫性,所以很有可能,在某些平臺上,沒有同步就會讀到陳舊的數據。有關更詳細的信息,請參閱 參考資料。
在確定了要共享的數據之后,還要確定要如何保護那些數據。在簡單情況下,只需把它們聲明為 volatile
即可保護數據字段;在其它情況下,必須在讀或寫共享數據前請求加鎖,一個很好的經驗是明確指出使用什么鎖來保護給定的字段或對象,并在你的代碼里把它記錄下來。
還有一點值得注意的是,簡單地同步存取器方法(或聲明下層的字段為 volatile
)可能并不足以保護一個共享字段。可以考慮下面的示例:
...
private int foo;
public synchronized int getFoo() { return foo; }
public synchronized void setFoo(int f) { foo = f; }
|
如果一個調用者想要增加 foo
屬性值,以下完成該功能的代碼就不是線程安全的:
...
setFoo(getFoo() + 1);
|
如果兩個線程試圖同時增加 foo
屬性值,結果可能是 foo
的值增加了 1 或 2,這由計時決定。調用者將需要同步一個鎖,才能防止這種爭用情況;一個好方法是在 JavaDoc 類中指定同步哪個鎖,這樣類的調用者就不需要自己猜了。
以上情況是一個很好的示例,說明我們應該注意多層次粒度的數據完整性;同步存取器方法確保調用者能夠存取到一致的和最近版本的屬性值,但如果希望屬性的將來值與當前值一致,或多個屬性間相互一致,我們就必須同步復合操作 ― 可能是在一個粗粒度的鎖上。
如果情況不確定,考慮使用同步包裝
有時,在寫一個類的時候,我們并不知道它是否要用在一個共享環境里。我們希望我們的類是線程安全的,但我們又不希望給一個總是在單線程環境內使用的類加上同步的負擔,而且我們可能也不知道使用這個類時合適的鎖粒度是多大。幸運的是,通過提供同步包裝,我們可以同時達到以上兩個目的。Collections 類就是這種技術的一個很好的示例;它們是非同步的,但在框架中定義的每個接口都有一個同步包裝(例如, Collections.synchronizedMap()
),它用一個同步的版本來包裝每個方法。
結論
雖然 JLS 給了我們可以使我們的程序線程安全的工具,但線程安全也不是天上掉下來的餡餅。使用同步會蒙受性能損失,而同步使用不當又會使我們承擔數據混亂、結果不一致或死鎖的風險。幸運的是,在過去的幾年內 JVM 有了很大的改進,大大減少了與正確使用同步相關的性能損失。通過仔細分析在線程間如何共享數據,適當地同步對共享數據的操作,可以使得您的程序既是線程安全的,又不會承受過多的性能負擔。