1. 程式人生 > >Oracle官方併發教程之同步

Oracle官方併發教程之同步

原文連結譯文連結,譯者:蘑菇街-小寶,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個步驟就足夠了:

  1. 獲取當前c的值;
  2. 對獲取到的值加1;
  3. 把遞增後的值寫回到c;

語句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;

執行緒A的結果因為被執行緒B覆蓋而丟失了。這個交替執行的結果只是其中一種可能性。在不同的環境下,可能是執行緒B的結果丟失了,也可能是不會出任何問題。由於結果是不可預知的,所以執行緒干擾的bug很難檢測和修復。

記憶體一致性錯誤

原文連結

當不同的執行緒對相同的資料產生不一致的檢視時會發生記憶體一致性錯誤。記憶體一致性錯誤的原因比較複雜,也超出了本教程的範圍。不過幸運的是,一個程式設計師並不需要對這些原因有詳細的瞭解。所需要的是避免它們的策略。

避免記憶體一致性錯誤的關鍵是理解happens-before關係。這種關係只是確保一個特定語句的寫記憶體操作對另外一個特定的語句可見。要說明這個問題,請參考下面的例子。假設定義和初始化了一個簡單int欄位:

  int counter =0 ;

這個counter欄位被A,B兩個執行緒共享。假設執行緒A對counter執行遞增:

  counter++;

然後,很快的,執行緒B輸出counter:

  System.out.println(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包的摘要頁面

同步方法

原文地址

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;
    }
}


如果countSynchronizedCounter類的例項,那麼讓這些方法成為同步方法有兩個作用:

  • 首先,相同物件上的同步方法的兩次呼叫,它們要交替執行是不可能的。 當一個執行緒正在執行物件的同步方法時,所有其他呼叫該物件同步方法的執行緒會被阻塞(掛起執行),直到第一個執行緒處理完該物件。
  • 其次,當一個同步方法退出時,它會自動跟該物件同步方法的任意後續呼叫建立起一種happens-before關係。這確保物件狀態的改變對所有執行緒是可見的。

注意構造方法不能是同步的——構造方法加synchronized關鍵字會報語法錯誤。同步的構造方法沒有意義,因為當這個物件被建立的時候,只有建立物件的執行緒能訪問它。

警告:當建立的物件會被多個執行緒共享時必須非常小心,物件的引用不要過早“暴露”出去。比如,假設你要維護一個叫instancesList,它包含類的每一個例項物件。你可能會嘗試在構造方法中加這樣一行:

  instances.add(this);

不過其他執行緒就能夠在物件構造完成之前使用instances訪問物件。

同步(synchronized)方法使用一種簡單的策略來防止執行緒干擾和記憶體一致性錯誤:如果一個物件對多個執行緒可見,物件域上的所有讀寫操作都是通過synchronized方法來完成的。(一個重要的例外:final域,在物件被建立後不可修改,能被非synchronized方法安全的讀取)。synchronized同步策略很有效,不過會引起活躍度問題,我們將在本節後面看到。

內部鎖與同步

原文連結

同步機制的建立是基於其內部一個叫內部鎖或者監視鎖的實體。(在Java API規範中通常被稱為監視器。)內部鎖在同步機制中起到兩方面的作用:對一個物件的排他性訪問;建立一種happens-before關係,而這種關係正是可見性問題的關鍵所在。

每個物件都有一個與之關聯的內部鎖。通常當一個執行緒需要排他性的訪問一個物件的域時,首先需要請求該物件的內部鎖,當訪問結束時釋放內部鎖。線上程獲得內部鎖到釋放內部鎖的這段時間裡,我們說執行緒擁有這個內部鎖。那麼當一個執行緒擁有一個內部鎖時,其他執行緒將無法獲得該內部鎖。其他執行緒如果去嘗試獲得該內部鎖,則會被阻塞。

當執行緒釋放一個內部鎖時,該操作和對該鎖的後續請求間將建立happens-before關係。

同步方法中的鎖

當執行緒呼叫一個同步方法時,它會自動請求該方法所在物件的內部鎖。當方法返回結束時則自動釋放該內部鎖,即使退出是由於發生了未捕獲的異常,內部鎖也會被釋放。

你可能會問呼叫一個靜態的同步方法會如何,由於靜態方法是和類(而不是物件)相關的,所以執行緒會請求類物件(Class Object)的內部鎖。因此用來控制類的靜態域訪問的鎖不同於控制物件訪問的鎖。

同步塊

另外一種同步的方法是使用同步塊。和同步方法不同,同步塊必須指定所請求的是哪個物件的內部鎖:

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是彼此無關的域。

可重入同步

還記得嗎,一個執行緒不能獲得其他執行緒所擁有的鎖。但是它可以獲得自己已經擁有的鎖。允許一個執行緒多次獲得同一個鎖實現了可重入同步。這裡描述了一種同步程式碼的場景,直接的或間接地,呼叫了一個也擁有同步程式碼的方法,且兩邊的程式碼使用的是同一把鎖。如果沒有這種可重入的同步機制,同步程式碼則需要採取許多額外的預防措施以防止執行緒阻塞自己。

原子訪問

原文連結

在程式設計過程中,原子操作是指所有操作都同時發生。原子操作不能被中途打斷:要麼全做,要麼不做。原子操作在完成前不會有看得見的副作用。

我們發現像c++這樣的增量表達式,並沒有描述原子操作。即使是非常簡單的表示式也能夠定義成能被分解為其他操作的複雜操作。然而,有些操作你可以定義為原子的:

  • 對引用變數和大部分基本型別變數(除long和double之外)的讀寫是原子的。
  • 對所有宣告為volatile的變數(包括long和double變數)的讀寫是原子的。

原子操作不會交錯,於是可以放心使用,不必擔心執行緒干擾。然而,這並不能完全消除原子操作上的同步,因為記憶體一致性錯誤仍可能發生。使用volatile變數可以降低記憶體一致性錯誤的風險,因為對volatile變數的任意寫操作,對於後續在該變數上的讀操作建立了happens-before關係。這意味著volatile變數的修改對於其他執行緒總是可見的。更重要的是,這同時也意味著當一個執行緒讀取一個volatile變數時,它不僅能看到該變數最新的修改,而且也能看到致使該改變發生的程式碼的副效應。

使用簡單的原子變數訪問比通過同步程式碼來訪問更高效,但是需要程式設計師更加謹慎以避免記憶體一致性錯誤。至於這額外的付出是否值得,得看應用的大小和複雜度。

java.util.concurrent包中的一些類提供了一些不依賴同步機制的原子方法。我們將在高階併發物件這一節中討論它們。