(本部分原文鏈接,譯文鏈接,譯者:蘑菇街-小寶,Greenster,李任 校對:丁一,鄭旭東,李任)
線程間的通信主要是通過共享域和引用相同的對象。這種通信方式非常高效,不過可能會引發兩種錯誤:線程干擾和內存一致性錯誤。防止這些錯誤發生的方法是同步。
不過,同步會引起線程競爭,當兩個或多個線程試圖同時訪問相同的資源,隨之就導致Java運行時環境執行其中一個或多個線程比原先慢很多,甚至執行被掛起,這就出現了線程競爭。線程飢餓和活鎖都屬於線程競爭的範疇。關於線程競爭的更多信息可參考活躍度一節。
本節內容包括以下這些主題:
- 線程干擾討論了當多個線程訪問共享數據時錯誤是怎麼發生的。
- 內存一致性錯誤討論了不一致的共享內存視圖導致的錯誤。
- 同步方法討論了 一種能有效防止線程干擾和內存一致性錯誤的常見做法。
- 內部鎖和同步討論了更通用的同步方法,以及同步是如何基於內部鎖實現的。
- 原子訪問討論了不能被其他線程干擾的操作的總體思路。
下面這個簡單的Counter類:
- class Counter {
- private int c = 0;
- public void increment() {
- c++;
- }
- public void decrement() {
- c--;
- }
- public int value() {
- return c;
- }
- }
Counter類被設計成:每次調用increment()方法,c的值加1;每次調用decrement()方法,c的值減1。如果當同一個Counter對象被多個線程引用,線程間的干擾可能會使結果同我們預期的不一致。
當兩個運行在不同的線程中卻作用在相同的數據上的操作交替執行時,就發生了線程干擾。這意味着這兩個操作都由多個步驟組成,而步驟間的順序產生了重疊。
Counter類實例的操作會交替執行,這看起來似乎不太可能,因爲c上的這兩個操作都是單一而簡單的語句。然而,即使一個簡單的語句也會被虛擬機轉換成多個步驟。我們不去深究虛擬機內部的詳細執行步驟——理解c++這個單一的語句會被分解成3個步驟就足夠了:
- 獲取當前c的值;
- 對獲取到的值加1;
- 把遞增後的值寫回到c;
假設線程A調用increment()的同時線程B調用decrement().如果c的初始值爲0,線程A和B之間的交替執行順序可能是下面這樣:
- 線程A:獲取c;
- 線程B:獲取c;
- 線程A:對獲取的值加1,結果爲1;
- 線程B:對獲取的值減1,結果爲-1;
- 線程A:結果寫回到c,c現在是1;
- 線程B:結果寫回到c,c現在是-1;
2. 內存一致性錯誤
當不同的線程對相同的數據產生不一致的視圖時會發生內存一致性錯誤。內存一致性錯誤的原因比較複雜,也超出了本教程的範圍。不過幸運的是,一個程序員並不需要對這些原因有詳細的瞭解。所需要的是避免它們的策略。
避免內存一致性錯誤的關鍵是理解happens-before關係。這種關係只是確保一個特定語句的寫內存操作對另外一個特定的語句可見。要說明這個問題,請參考下面的例子。假設定義和初始化了一個簡單int字段:
這個counter字段被A,B兩個線程共享。假設線程A對counter執行遞增:
然後,很快的,線程B輸出counter:
如果這兩個語句已經在同一個線程中被執行過,那麼輸出的值應該是“1”。不過如果這兩個語句在不同的線程中分開執行,那輸出的值很可能是“0”,因爲無法保證線程A對counter的改動對線程B是可見的——除非我們在這兩個語句之間已經建立了happens-before關係。
有許多操作會建立happens-before關係。其中一個是同步,我們將在下面的章節中看到。
我們已經見過兩個建立happens-before關係的操作。
當一條語句調用Thread.start方法時,和該語句有happens-before關係的每一條語句,跟新線程執行的每一條語句同樣有happens-before關係。創建新線程之前的代碼的執行結果對線新線程是可見的。
當一個線程終止並且當導致另一個線程中Thread.join返回時,被終止的線程執行的所有語句和在join返回成功之後的所有語句間有happens-before關係。線程中代碼的執行結果對執行join操作的線程是可見的。
要查看建立happens-before關係的操作列表,請參閱java.util.concurrent包的摘要頁面。
3. 同步方法
Java編程語言提供兩種同步方式:同步方法和同步語句。相對較複雜的同步語句將在下一節中介紹。本節主要關注同步方法。
要讓一個方法成爲同步方法,只需要在方法聲明中加上synchronized關鍵字:
- public class SynchronizedCounter {
- private int c = 0;
- public synchronized void increment() {
- c++;
- }
- public synchronized void decrement() {
- c--;
- }
- public synchronized int value() {
- return c;
- }
- }
如果count是SynchronizedCounter類的實例,那麼讓這些方法成爲同步方法有兩個作用:
首先,相同對象上的同步方法的兩次調用,它們要交替執行是不可能的。當一個線程正在執行對象的同步方法時,所有其他調用該對象同步方法的線程會被阻塞(掛起執行),直到第一個線程處理完該對象。
其次,當一個同步方法退出時,它會自動跟該對象同步方法的任意後續調用建立起一種happens-before關係。這確保對象狀態的改變對所有線程是可見的。
注意構造方法不能是同步的——構造方法加synchronized關鍵字會報語法錯誤。同步的構造方法沒有意義,因爲當這個對象被創建的時候,只有創建對象的線程能訪問它。
警告:當創建的對象會被多個線程共享時必須非常小心,對象的引用不要過早“暴露”出去。比如,假設你要維護一個叫instances的List,它包含類的每一個實例對象。你可能會嘗試在構造方法中加這樣一行:
不過其他線程就能夠在對象構造完成之前使用instances訪問對象。
同步(synchronized)方法使用一種簡單的策略來防止線程干擾和內存一致性錯誤:如果一個對象對多個線程可見,對象域上的所有讀寫操作都是通過synchronized方法來完成的。(一個重要的例外:final域,在對象被創建後不可修改,能被非synchronized方法安全的讀取)。synchronized同步策略很有效,不過會引起活躍度問題,我們將在本節後面看到。
4. 內部鎖與同步
同步機制的建立是基於其內部一個叫內部鎖或者監視鎖的實體。(在JavaAPI規範中通常被稱爲監視器。)內部鎖在同步機制中起到兩方面的作用:對一個對象的排他性訪問;建立一種happens-before關係,而這種關係正是可見性問題的關鍵所在。
每個對象都有一個與之關聯的內部鎖。通常當一個線程需要排他性的訪問一個對象的域時,首先需要請求該對象的內部鎖,當訪問結束時釋放內部鎖。在線程獲得內部鎖到釋放內部鎖的這段時間裏,我們說線程擁有這個內部鎖。那麼當一個線程擁有一個內部鎖時,其他線程將無法獲得該內部鎖。其他線程如果去嘗試獲得該內部鎖,則會被阻塞。
當線程釋放一個內部鎖時,該操作和對該鎖的後續請求間將建立happens-before關係。
5. 同步方法中的鎖
當線程調用一個同步方法時,它會自動請求該方法所在對象的內部鎖。當方法返回結束時則自動釋放該內部鎖,即使退出是由於發生了未捕獲的異常,內部鎖也會被釋放。
你可能會問調用一個靜態的同步方法會如何,由於靜態方法是和類(而不是對象)相關的,所以線程會請求類對象(ClassObject)的內部鎖。因此用來控制類的靜態域訪問的鎖不同於控制對象訪問的鎖。
6. 同步塊
另外一種同步的方法是使用同步塊。和同步方法不同,同步塊必須指定所請求的是哪個對象的內部鎖:
- public void addName(String name) {
- synchronized(this) {
- lastName = name;
- nameCount++;
- }
- nameList.add(name);
- }
在上面的例子中,addName方法需要使lastName和nameCount的更改保持同步,而且要避免同步調用該對象的其他方法。(在同步代碼中調用其他方法會產生Liveness一節所描述的問題。)如果不使用同步塊,那麼必須要定義一個額外的非同步方法,而這個方法僅僅是用來調用nameList.add。
使用同步塊對於更細粒度的同步很有幫助。例如類MsLunch有兩個實例域c1和c2,他們並不會同時使用(譯者注:即c1和c2是彼此無關的兩個域),所有對這兩個域的更新都需要同步,但是完全不需要防止c1的修改和c2的修改相互之間干擾(這樣做只會產生不必要的阻塞而降低了併發性)。這種情況下不必使用同步方法,可以使用和this對象相關的鎖。這裏我們創建了兩個“鎖”對象(譯者注:起到加鎖效果的普通對象lock1和lock2)。
- public class MsLunch {
- private long c1 = 0;
- private long c2 = 0;
- private Object lock1 = new Object();
- private Object lock2 = new Object();
- public void inc1() {
- synchronized(lock1) {
- c1++;
- }
- }
- public void inc2() {
- synchronized(lock2) {
- c2++;
- }
- }
- }
使用這種方法時要特別小心,需要十分確定c1和c2是彼此無關的域。
7. 可重入同步
還記得嗎,一個線程不能獲得其他線程所擁有的鎖。但是它可以獲得自己已經擁有的鎖。允許一個線程多次獲得同一個鎖實現了可重入同步。這裏描述了一種同步代碼的場景,直接的或間接地,調用了一個也擁有同步代碼的方法,且兩邊的代碼使用的是同一把鎖。如果沒有這種可重入的同步機制,同步代碼則需要採取許多額外的預防措施以防止線程阻塞自己。
8. 原子訪問
在編程過程中,原子操作是指所有操作都同時發生。原子操作不能被中途打斷:要麼全做,要麼不做。原子操作在完成前不會有看得見的副作用。
我們發現像c++這樣的增量表達式,並沒有描述原子操作。即使是非常簡單的表達式也能夠定義成能被分解爲其他操作的複雜操作。然而,有些操作你可以定義爲原子的:
- 對引用變量和大部分基本類型變量(除long和double之外)的讀寫是原子的。
- 對所有聲明爲volatile的變量(包括long和double變量)的讀寫是原子的。
原子操作不會交錯,於是可以放心使用,不必擔心線程干擾。然而,這並不能完全消除原子操作上的同步,因爲內存一致性錯誤仍可能發生。使用volatile變量可以降低內存一致性錯誤的風險,因爲對volatile變量的任意寫操作,對於後續在該變量上的讀操作建立了happens-before關係。這意味着volatile變量的修改對於其他線程總是可見的。更重要的是,這同時也意味着當一個線程讀取一個volatile變量時,它不僅能看到該變量最新的修改,而且也能看到致使該改變發生的代碼的副效應。
使用簡單的原子變量訪問比通過同步代碼來訪問更高效,但是需要程序員更加謹慎以避免內存一致性錯誤。至於這額外的付出是否值得,得看應用的大小和複雜度。
java.util.concurrent包中的一些類提供了一些不依賴同步機制的原子方法。我們將在高級併發對象這一節中討論它們。