併發編程之對象的共享

本篇文章介紹對象的共享,內容皆總結摘抄自《Java併發編程實戰》和《Java併發編程的藝術》,僅作筆記。

同步代碼塊和同步方法可以確保以原子的方式執行操作,而synchronized不僅可以用於實現原子性或確定臨界區,它還可以保證內存可見性。我們不僅希望防止某個線程在使用對象狀態時而另一個線程在修改該狀態,還希望確保當一個線程修改了對象狀態後,其他線程能看到發生的狀態變化。

 可見性

可見性是一種複雜的屬性,因爲可見性中的錯誤總是會違揹我們的直覺。在單線程環境中,如果向某個變量寫入值,然後在沒有其他寫入操作的情況下讀取這個變量,總能得到相同的值。然而當讀操作和寫操作在不同的線程中執行時,情況卻並非如此。通常,我們無法確保執行讀操作的線程能適時的看到其他線程寫入的值。爲了確保多個線程之間對內存寫入操作的可見性,必須使用同步機制。

以下代碼說明了當多個線程在沒有同步的情況下共享數據時出現的錯誤。

public class MultiThread {

    private static boolean ready;
    private static int number;

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 22;
        ready = true;
    }

    private static class ReaderThread extends Thread{
        public void run(){
            while(!ready){
                Thread.yield();
            }
            System.out.println(number);
        }
    }
}

主線程和讀線程都將訪問共享變量ready和number。主線程啓動讀線程,然後將number設爲22,並將ready設爲true。讀線程一直循環直到發現ready的值變爲true,然後輸出numer的值。雖然看起來會輸出22,但也有可能輸出0,或者無法終止。

以上程序可能會有以下三種結果:

  1. 輸出22,此結果也就是按照程序正常執行的結果。
  2. 無法終止,因爲讀線程可能永遠沒有看到主線程修改的ready的值,因爲在代碼中沒有使用足夠的同步機制,因此無法保證主線程寫入的ready值和numer值對讀線程來說是可見的。
  3. 0,讀線程可能看到了主線程修改的ready的值但沒有看到在之後修改的number的值,因爲修改num與修改ready沒有數據依賴關係,因此編譯器可能會重排序。

上述程序展示了在缺乏同步的程序中可能產生錯誤結果的一種情況:失效數據。當多線程查看ready變量時,可能會得到一個失效的值。除非在每次訪問變量時都使用同步,否則很可能獲得該變量的一個失效值。而且由於重排序的存在,可能會發生一個線程得到了某個變量的最新值,而得到了另一個變量的失效值。

當線程在沒有同步的情況下讀取變量時,可能會得到一個失效值,但至少這個值是由之前某個線程設置的值,而不是一個隨機值。這種安全性保證也被稱爲最低安全性。最低安全性適用於絕大多數變量,但不適用於非volatile類型的64位數值變量(即double類型和long類型)。JMM要求,變量的讀取操作和寫入操作都必須是原子操作,而對於非volatile類型的double和long變量,JVM允許將64位的讀操作或寫操作分解爲兩個32位的操作。

當讀取一個非volatile類型的long變量或double變量時,如果對該變量的讀操作和寫操作在不同的線程中執行,那麼很可能讀取到某個值的高32位和另一個值的低32位。因此,在多線程程序中使用共享且可變的long和double類型的變量是不安全的,除非用volatile來聲明或用鎖保護起來。

內置鎖可以用於確保某個線程以一種可預測的方式來查看另一個線程的執行結果。即當線程A執行某個同步代碼塊時,線程B隨後進入同一個代碼塊,當線程B執行該代碼塊時,線程A之前在此代碼塊中的所有操作結果對線程B都是可見的。如果沒有同步,就無法實現上述保證。

加鎖的含義不僅僅侷限於互斥行爲,還包括內存可見性。爲了確保所有線程都能看到共享變量的最新值,所有執行讀操作和寫操作的線程都必須在同一個鎖上。

Volatile變量

Java語言提供了一種稍弱的同步機制,即volatile變量,用於確保共享變量的更新操作通知到了其他線程。除了保證可見性,volatile還禁止重排序優化,即volatile修飾的變量上的操作不會與其他內存操作一起重排序。

當寫一個volatile變量時,JMM會把該線程對應的本地內存中得共享變量刷新到主內存。當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效,線程接下來將從主內存中讀取共享變量。

雖然volatile變量很方便,但也存在一些侷限性。volatile變量通常用作某個操作完成、發生中斷或狀態的標誌。雖然volatile也可以用於表示其他的狀態信息,但在使用時需要非常小心,因爲volatile只能確保可見性,無法保證原子性。例如遞增操作count++中,即使count使用volatile修飾也只能保證count的修改對其他線程可見,無法保證多個線程同一時間重複執行導致count的值出現偏差。與volatile變量只能確保可見性相比,加鎖機制既可以確保可見性又保證原子性。

當且僅當滿足以下所有條件時,才應該使用volatile變量:

  1. 對變量的寫入操作不依賴變量的當前值,或者確保只有單個線程更新變量的值;
  2. 該變量不會與其他狀態變量一起納入不變性條件中;
  3. 在訪問變量時不需要加鎖。

發佈與逸出

“發佈(publish)”一個對象的意思是指使對象能夠在當前作用域之外的代碼中使用。例如,將一個指向該對象的引用保存到其他代碼可以訪問的地方或者在某一個非私有方法中返回該引用等等。在許多情況下,我們要保證對象及其內部狀態不被髮布。而在某些情況下,我們又需要發佈某個對象,但如果在發佈時要確保線程安全,則可能需要同步。發佈內部狀態可能會破壞封裝性,並使得程序難以維持不變性條件,例如在對象構造完成之前就發佈該對象,就會破壞線程安全性。當某個不應該發佈的對象被髮布時,這種情況就稱爲逸出(Escape)。

發佈對象最簡單的方法就是將對象的引用保存到一個公有的靜態變量中,以便任何類和線程都能看到該對象。當發佈一個對象時,在該對象的非私有域中引用的所有對象同樣會被髮布。一般來說,如果一個已經發布的對象能夠通過非私有的變量引用和方法調用達到其他的對象,那麼這些對象都會被髮布。

線程封閉

當訪問共享的可變數據時,通常需要使用同步。一種避免使用同步的方式就是不共享數據。如果僅在單線程內訪問數據,就不需要同步,這種技術稱爲線程封閉(Thread Confinement)。它是實現線程安全性的最簡單方式之一。當某個對象封閉在一個線程中時,這種用法將自動實現線程安全性,即使被封閉的對象本身不是線程安全的。

線程封閉技術的一種常見應用是JDBC的Connection對象。JDBC規範並不要求Connection對象必須是線程安全的(連接池是線程安全的,連接池通常會由多個線程同時訪問)。在典型的服務器應用程序中,線程從連接池獲得一個Connection對象,並且用該對象來處理請求,使用完後再將對象返回給連接池。由於大多數請求都是由單個線程採用同步的方式來處理,並且在Connection對象返回之前,連接池不會再將它分配給其他線程,因此這種連接管理模式在處理請求時隱含的將Connection對象封閉在線程中。

在Java語言中無法強制將對象封閉在某個線程中。線程封閉是在程序設計中的一個考慮因素,必須在程序中實現。Java語言及其核心庫提供了一些機制來幫助維持線程封閉性,例如局部變量和ThreadLocal類,即便如此,我們仍然需要負責確保封閉在線程中的對象不會從線程中逸出。

Ad-hoc線程封閉

Ad-hoc線程封閉是指維護線程封閉性的職責完全由程序實現來承擔。Ad-hoc線程封閉是非常脆弱的,因爲沒有任何一種語言特性,例如可見性修飾符或局部變量,能將對象封閉到目標線程上。

由於Ad-hoc線程封閉技術的脆弱性,因此在程序中儘量少用,在可能的情況下,應該使用更強的線程封閉技術。

棧封閉

棧封閉是線程封閉的一種特例,在棧封閉中,只能通過局部變量才能訪問對象。正如封裝能使得代碼更容易維持不變性條件,同步變量也能使對象更易於封閉在線程中。局部變量的固有屬性之一就是封閉在執行線程中,它們位於執行線程的棧中,其他線程無法訪問這個棧。棧封閉比Ad-hoc線程封閉更易於維護,也更加健壯。

對於基本類型的局部變量,由於任何方法都無法獲得對基本類型的引用,因此Java的這種語義確保了基本類型的局部變量始終封閉在線程內。例如下面代碼中的count,無論如何都不會破壞棧封閉性。

public static int loadTheArk(List<Integer> intList){
    SortedSet<Integer> intSet;
    int count = 0;
    Integer num = null;

    intSet = new TreeSet<Integer>();
    intSet.addAll(intList);

    for (Integer number:intSet){
        count++;
        if (number % 2 == 0){
            num = 0;
        } else {
            num = number;
        }
        System.out.println(num);
    }
    return count;
}

 而對於維護對象引用的棧封閉性時,我們就需要多做一些工作以確保被引用的對象不會逸出。例如在loadTheArk實例化一個TreeSet對象,並將指向該對象的一個引用保存到inSet中。此處只有一個引用指向集合inSet,這個引用被封閉在局部變量中,因此也被封閉在執行線程中。但如果發佈了集合intSet的引用,那麼封閉性就被破壞,並且導致了intSet的逸出。

如果在線程內部上下文中使用非線程安全的對象,該對象仍然是安全的。然而只有寫這段代碼的人才知道哪些對象需要封閉到線程中,以及被封閉的對象是否是線程安全的。這樣的代碼維護性很差,換一個人維護就很容易錯誤的使對象逸出。

ThreadLocal類

維護線程封閉的一種更規範方式是使用ThreadLocal,這個類能使線程中的某個值與保存值的對象關聯起來。ThreadLocal提供了get與set等訪問接口或方法,這些方法爲每個使用該變量的線程都存有一份獨立的副本,因此get總是返回由當前執行線程在調用set時設置的最新值。

不變性

滿足同步需求的另一種方法是使用不可變對象。如果某個對象在被創建後其狀態就不能被修改,這個對象就稱爲不可變對象。線程安全性是不可變對象的固有屬性之一,因此它們一定是線程安全的。

不可變對象很簡單,它們只有一種狀態,並且該狀態由構造函數來控制。在程序設計中,一個最困難的地方就是判斷複雜對象的可能狀態,然而判斷不可變對象的狀態卻很簡單。

在Java語言規範和JMM中都沒有給出不可變的正式定義,但不可變性不等於將對象的所有域都聲明爲final類型,即使對象中所有的域都是final的,這個對象仍然是可變的,因爲在final的域中可以保存對可變對象的引用。

只有滿足以下條件時,對象纔是不可變的:

  1. 對象創建以後其狀態就不能修改;
  2. 對象的所有域都是final類型;
  3. 對象是正確創建的,即this引用沒有逸出。

final域

關鍵字final用於構造不可變對象,final類型的域是不可修改的(如果final域所引用的對象是可變的,那麼引用對象是可以修改的)。在JMM中,final還能夠確保初始化過程的安全性,從而可以不受限制的訪問不可變對象,並在共享這些對象時無需同步。

安全發佈

到目前爲止,我們重點討論的是如何確保對象不被髮布,例如讓對象封閉在線程或另一個對象內部。在某些情況下我們希望在多個線程間共享對象,此時必須確保安全的進行共享。然而如果像如下代碼中將對象引用保存到公有域中,還不足以安全的發佈這個對象。

public Holder holder;

public void initialize(){
    holder = new Holder(1);
}

由於存在可見性問題,其他線程看到的Holder對象將處於不一致狀態,即使在該對象的構造函數中已經正確的構建了不變性條件。這種不正確的發佈導致其他線程看到尚未創建完成的對象。由於沒有使用同步來確保Holder對象對其他線程可見,因此將Holder稱爲“未被正確發佈”。在未被正確發佈的對象中存在兩個問題。首先除了發佈對象的線程外,其他線程看到的Holder域可能是一個失效值,即看到一個空引用或之前的舊值。更糟糕的情況是,線程看到的Holder引用的值是最新的,但Holder狀態的值卻是失效的。

如果沒有足夠的同步,當在多個線程間共享數據時將發生一些非常奇怪的事情。

不可變對象與初始化安全性

JMM爲不可變對象的共享提供了一種特殊的初始化安全性保證。即使某個對象的引用對於其他線程來說是可見的,也不意味着對象狀態對於使用該對象的線程來說一定是可見的。爲了確保對象狀態能呈現出一致的視圖,就必須使用同步。而在發佈不可變對象的引用時沒有使用同步也仍然可以安全的訪問該對象。

在沒有額外同步的情況下,也可以安全的訪問final類型的域。然而如果final類型的域指向的是可變對象,那麼在訪問這些域所指向的對象狀態時仍然需要同步。

安全發佈的常用模式

可變對象必須通過安全的方式來發布,即在發佈和使用該對象的線程時都必須使用同步。要安全的發佈一個對象,對象的引用和對象的狀態必須同時對其他線程可見。一個正確構造的對象可以通過一下方式來安全的發佈:

  • 在靜態初始化函數中初始化一個對象引用。
  • 將對象的引用保存到volatile類型的域或AtomicReferance對象中。
  • 將對象的引用保存到某個正確構造對象的final類型域中。
  • 將對象的引用保存到一個由鎖保護的域中。

如果線程A將對象x放入一個線程安全的容器,隨後線程B讀取這個對象,可以確保B看到A設置的x狀態,即使在這段讀寫x的代碼中沒有顯式的同步。

通常,要發佈一個靜態構造對象,最簡單和最安全的方式是使用靜態的初始化器:

public static Holder holder = new Holder(1);

靜態初始化器由JVM在類的初始化結果執行,由於JVM內部存在同步機制,因此通過這種方式初始化的任何對象都可以被安全的發佈。

事實不可變對象

如果對象在發佈後就不會被修改,那麼對於其他在沒有額外同步的情況下安全的訪問這些對象的線程來說,安全發佈是足夠的。所有的安全發佈機制都能確保,當對象的引用對所有訪問該對象的線程可見時,對象發佈時的狀態對於所以線程也將是可見的,並且如果對象不再改變,就足以確保任何訪問都是安全的。

如果對象從技術上來看是可變的,但其狀態在發佈後不會再改變,這種對象稱爲“事實不可變對象(Effectively Immutable Object)”。這些對象不需要滿足之前介紹過的不可變性的嚴格定義。在這些對象發佈後,程序只需將它們視爲不可變對象即可。

例如Date本身是可變的,但如果將它作爲不可變對象使用,在多個線程間共享Date對象時,就可以省去對鎖的使用。假設需要維護一個Map對象,其中保存了每位用戶的最近登錄時間:

public Map<String,Date> lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());

如果Date對象的值中被放入Map後就不會改變,那麼synchronizedMap中的同步機制就足以使Date值被安全的發佈,並且在訪問這些Date值時不需要額外的同步。

可變對象

如果對象在構造後可以修改,那麼安全發佈只能確保“發佈當時”狀態的可見性。對於可變對象,不僅在發佈對象時需要使用同步,在每次對象訪問時同樣需要使用同步來確保後續修改操作的可見性。要安全的共享可變對象,這些對象就必須被安全的發佈,並且必須是線程安全的或者由某個鎖保護起來。

對象的發佈需求取決於它的可變性:

  • 不可變對象可以通過任意機制發佈。
  • 事實不可變對象必須通過安全方式來發布。
  • 可變對象必須通過安全方式來發布,並且必須是線程安全的或者由某個鎖保護起來。

安全的共享對象

在併發程序中使用和共享對象時,可以使用一些使用的策略,例如:

  • 線程封閉。線程封閉的對象只能由一個線程擁有,對象被封閉在該線程中,並且只能由這個線程修改。
  • 只讀共享。在沒有額外同步的情況下,共享的只讀對象可以由多個線程併發訪問,但任何線程都不能修改它。共享的只讀對象包括不可變對象和事實不可變對象。
  • 線程安全共享。線程安全的對象在其內部實現同步,因此多個線程可以通過對象的公有接口來進行訪問而不需要進一步的同步。
  • 保護對象。被保護的對象只能通過特有的鎖來訪問。保護對象包括封裝在其他線程安全對象中的對象以及已發佈的並且由某個特定鎖保護的對象。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章