Java併發編程學習——《Java Concurrency in Practice》學習筆記 3.對象的共享

3.1 可見性

在沒有同步的情況下,編譯器、處理器以及運行時都可能對操作的執行順序進行一些意想不到的調整。因爲它們會對代碼的執行順序進行“重排序”。在缺乏足夠同步的多線程程序中,要想對內存操作的執行順序進行判斷,幾乎無法得出正確的結論。

重排序
重排序指的是編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的手段
重排序主要分爲兩類:編譯器優化的重排序、指令級別並行的重排序和內存系統的重排序
CSDN-Java併發編程系列之三:重排序與順序一致性

有一個簡單的方式可以解決因重排序引發的問題:只要有數據在多個線程之間共享,就使用正確的同步。

*但是書中程序3-1的例子
完整代碼 在測試中始終都表現出了正確的結果。

3.1.1 失效數據

在缺乏同步的程序中可能產生錯誤的一種情況是:失效數據。當讀線程讀取變量時,可能會得到一個已經失效的值。

這時需要對操作該變量的get和set等方法進行同步。注意get方法也是要同步的,不然調用get的線程仍然會看到失效值

3.1.2 非原子的64位操作

最低安全性 out-of-thin-airsafety
當線程在沒有同步的情況下讀取變量時,可能會得到一個失效值,但至少這個值是由之前某個線程設置的值,而不是一個隨機值。

最低安全性適用於絕大多數變量,但不適用於非volatile類型的64位數值變量(double和long)。

Java內存模型要求,變量的讀取和寫入操作都必須是原子操作,但對於非volatile類型的long和double變量,JVM允許將64位的讀操作或寫操作分解爲兩個32位的操作。因此,當讀取一個非volatile類型的long變量時,如果對該變量的讀操作和寫操作在不同的線程中執行,那麼可能會讀取到某個值的高32位和另一個值的低32位。因此,即使不考慮失效數據的問題,在多線程程序中使用共享且可變的long和double等類型變量也是不安全的。除非用關鍵字volatile聲明,或加鎖。

3.1.3 加鎖與可見性

內置鎖可以用於確保某個線程以一種可預測的方式來查看另一個線程的執行結果。

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

3.1.4 volatile變量

當變量被聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。

在訪問volatile變量時不會執行加鎖操作,因此不會使執行線程阻塞,因此volatile變量時一種比sychronized關鍵字更輕量級的同步機制。

不建議過度依賴volatile變量提供的可見性。如果在代碼中依賴volatile變量來控制狀態的可見性,通常比使用鎖的代碼更脆弱,也更難理解。

僅當volatile變量能簡化代碼的實現以及對同步策略的驗證時,才應該使用它們。如果在驗證正確性時需要對可見性進行復雜的判斷,就不要使用volatile變量。volatile變量的正確使用方式包括:
- 確保它們自身狀態的可見性
- 確保它們引用對象的狀態的可見性
- 標識一些重要的程序聲明週期事件的發生

volatile的一種典型用法是:檢查某個狀態標記以判斷是否退出循環。

volatile的語義不足以確保遞增操作的原子性

加鎖機制既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性。

適合使用volatile變量的場景

  • 對變量的寫入操作不依賴變量的當前值,或能確保只有單個線程更新變量的值
  • 該變量不會與其他狀態變量一起納入不變形條件中
  • 訪問變量時不需要加鎖

3.2 發佈與逸出

發佈 Publish
發佈一個對象是指,使對象能夠在當前作用域之外的代碼中使用。

大多數情況下,我們要確保對象及其內部狀態不被髮布。但某些情況下,需要發佈某個對象,如果在發佈時要確保線程安全性,則可能需要同步。發佈內部狀態可能會破壞封裝性,並使得程序難以維持不變性條件。

逸出 Escape
某個不應該被髮布的對象被髮布。

間接發佈對象的情況

非私有域引用

當發佈一個對象時,在該對象的非私有域中引用的所有對象同樣會被髮布。一般來說,如果一個已經發布的對象能夠通過非私有的變量引用和方法調用到達其他的對象,那麼這些對象也都會被髮布。

外部方法

外部方法 Alien Method
對於類C來說,外部方法是指行爲並不完全由C來規定的方法,包括其他類中定義的方法以及類C中可以被改寫的方法(既不是private也不是final的方法)

當把一個對象傳遞給某個外部方法時,就相當於發佈了這個對象。

當某個對象逸出後,必須假設有某個類或線程可能會誤用該對象。

發佈一個內部的類的實例

內部類包含了對其所在類的隱含引用。

安全的對象構造過程

不要在構造過程中使this引用逸出。因爲僅當對象的構造函數返回時,對象才處於可預測的和一致的狀態。從對象的構造函數中發佈的對象知識一個尚未構造完成的對象。即使發佈對象的語句位域構造函數的最後一行也是如此。如果this引用在構造過程中逸出,那麼這種對象就被認爲是不正確的構造。

在構造過程中使this逸出的一個常見錯誤是,在構造函數中啓動一個線程。當對象在其構造函數中創建一個線程時,無論是顯式創建(通過將它傳給構造函數)還是隱式創建(由於Thread或Runnable是該對象的一個內部類),this引用都會被新創建的線程共享。在對象尚未完全構造之前,新的線程就可以看見它。在構造函數中創建線程並沒有錯誤,但最好不要立即啓動它,而是通過一個strat或initialize方法來啓動。在構造函數中調用一個可改寫的實例方法時(非private非final方法),同樣會導致this引用在構造過程中逸出。

如果想在構造函數中註冊一個事件監聽器或啓動線程,可以使用一個私有的構造函數和一個公共的工廠方法 Factory Method,從而避免不正確的構造過程。

private final EventListener listener;

    private SafeListener() {
        listener = new EventListener() {
            @SuppressWarnings("unused")
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }

    protected void doSomething(Event e) {
        // do something
    }

    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener); // publish

        return safe;
    }

完整代碼

3.3 線程封閉

線程封閉 Thread Confinement
當訪問共享的可變數據時,通常需要使用同步。一種避免使用同步的方式就是不共享數據。如果僅在單線程內訪問數據,就不需要同步。這種技術被稱爲線程封閉

它是實現線程安全性的最簡單方式之一。當某個對象封閉在一個線程中時,這種用法將自動實現線程安全性,即使被封閉的對象本身不是線程安全的。

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

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

3.3.1 Ad-hoc 線程封閉

Ad-hoc 線程封閉
維護線程封閉性的職責完全由程序實現來承擔

Ad-hoc線程封閉是非常脆弱的,因爲沒有任何一種語言特性,能將對象封閉到目標線程上。

由於Ad-hoc線程封閉技術的脆弱性,在程序中儘量少使用。

3.3.2 棧封閉

棧封閉是線程封閉的一種特例,在棧封閉中,只能通過局部變量才能訪問對象。也被稱爲線程內部使用或者線程局部使用。局部變量的固有屬性之一就是封閉在執行線程中。它們位於執行線程的棧中,其他線程無法訪問這個棧。

棧封閉比Ad-hoc線程封閉更易於維護,也更加健壯。

3.3.3 ThreadLocal類

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

ThreadLocal對象通常用於防止對可變的Singleton或全局變量進行共享。

當某個頻繁執行的操作需要一個臨時對象,例如一個緩衝區,而同時又希望避免在每次執行時都重新分配該臨時對象,就可以使用這項技術。但除非這個操作的執行頻率非常高,或者分配操作的開銷非常大,否則這項技術不可能帶來性能提升。在Java 5.0中,這種技術被一種更直接的方式替代,即在每次調用時分配一個新的緩衝區,對於像臨時緩衝區這種簡單的對象,該技術並沒有什麼性能優勢。

當某個線程初次調用ThreadLocal.get方法時,就會調用initialValue來獲取初始值。從概念上看,可以將ThreadLocal視爲包含了Map

3.4 不變性

滿足同步需求的另一種方法是使用 不可變對象。

線程安全性是不可變對象的固有屬性之一,它們的不變性條件是由構造函數創建的,不可變對象一定是線程安全的

當滿足以下條件時,對象纔是不可變的:
- 對象創建以後其狀態就不能修改
- 對象的所有域都是final類型(從技術角度上看不需要這麼做,比如String,String會將散列值的計算推遲到第一次調用hashCode時進行,並將計算得到的散列值緩存到非final類型的域中,但這種方式之所以可行,是因爲這個域有一個非默認的值,並且在每次計算中都得到相同的結果,因爲基於一個不可變的狀態。但自己編寫代碼時不要這麼做)
- 對象時正確創建的(在對象的創建期間,this引用沒有逸出)

在不可變對象的內部仍可以使用可變對象來管理它們的狀態。

public class ThreeStooges {

    private final Set<String> stooges = new HashSet<>();

    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }

    public boolean isStooge(String name) {
        return stooges.contains(stooges);
    }

}

完整代碼

3.4.1 Final域

final類型的域是不能修改的,但如果final域引用的對象是可變的,那麼這些被引用的對象是可以修改的。在Java內存模型中,final域還能確保初始化過程的安全性,從而可以不受限制的訪問不可變對象,並在共享這些對象時無需同步。

與“除非需要更高的可見性,否則應將所有的域都聲明爲私有域”一樣,“除非某個域是可變的,否則應將其聲明爲final域”也是一個良好的編程習慣。

3.4.2 使用Volatile來發布不可變對象

對於在訪問和更新多個相關變量時出現的競爭條件問題,可以通過將這些變量全部保存在一個不可變對象中來消除。

不可變容器類

public class OneValueCache {

    private final BigInteger lastNumber;

    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i, BigInteger[] factors) {
        lastNumber = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }

    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i)) {
            return null;
        } else {
            return Arrays.copyOf(lastFactors, lastFactors.length);
        }
    }

}
public class Client {

    private volatile OneValueCache cache = new OneValueCache(null, null);

    public void doSomething(BigInteger i) {
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
    }

    private BigInteger[] factor(BigInteger i) {
        // do factor

        return null;
    }

}

完整代碼

3.5 安全發佈

在沒有足夠同步的情況下發布對象

public Holder holder;

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

由於存在可見性問題,其他線程看到的Holder對象將處於不一致的狀態,即便在該對象的構造函數中已經正確的構建了不變性條件。這種不正確的發佈導致其他線程看到尚未創建完成的對象。

3.5.1 不正確的發佈

在未被正確發佈的對象中存在兩個問題:
1. 除了發佈對象的線程外,其他線程會看到失效的值,一個空引用或者之前的舊值。
2. 更糟糕的情況是,引用的值是最新的,但狀態的值是失效的。

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

任何線程都可以在不需要額外同步的情況下安全的訪問不可變對象,即使在發佈這些對象時沒有使用同步

這種保證還將眼神到被正確創建對象中所有final類型的域。在沒有額外同步的情況下,也可以安全的訪問final類型的域。然而,如果final類型的域鎖指向的是可變對象,那麼在訪問這些域鎖指向的對象的狀態時仍然需要同步。

3.5.3 安全發佈的常用模式

要安全的發佈一個對象,對象的引用以及對象的狀態必須同時對其他線程可見。一個正確構造的對象可以通過以下方式來安全的發佈:
- 在靜態初始化函數中初始化一個對象引用
- 將對象的引用保存到volatile類型的域或者AtomicReference對象中
- 將對象的引用保存到某個正確構造對象的final類型域中
- 將對象的引用保存到一個由鎖保護的域中

在線程安全容器內部的同步意味着,將滿足上述最後一條需求。

線程安全庫中的容器類提供了以下的安全發佈保證:
- 通過將一個鍵或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全的將它發佈給任何從這些容器中訪問它的線程(無論是直接訪問還是通過迭代器訪問)
- 通過將某個元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中,可以將該元素安全的發佈到任何從這些容器中訪問該元素的線程。
- 通過將某個元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以將該元素安全的發佈到任何從這些隊列中訪問該元素的線程。

類庫中的其他數據傳遞機制(例如Future和Exchanger)同樣能實現安全發佈功能。

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

public static Holder holder = new Holder(42);

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

3.5.4 事實不可變對象

如果對象在發佈後不會被修改,那麼對於其他在沒有額外同步的情況下安全的訪問這些對象的線程來說,安全發佈是足夠的。

事實不可變對象 Effectively Immutable Object
對象從技術上來看是可變的,但其狀態在發佈後不會再改變,那麼把這種對象稱爲“事實不可變對象”

在沒有額外的同步的情況下,任何線程都可以安全的使用被安全發佈的事實不可變對象。通過使用事實不可變對象,不僅可以簡化開發過程,還能減少同步提高性能。

3.5.5 可變對象

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

對象的發佈需求取決於它的可變性:
- 不可變對象可以通過任意機制來發布
- 事實不可變對象必須通過安全方式來發布
- 可變對象必須通過安全方式來發布,並且必須是線程安全的或者由某個鎖保護起來

3.5.6 安全的共享對象

在併發程序中使用和共享對象時,可以使用一些實用的策略,包括

線程封閉

線程封閉的對象只能由一個線程擁有,對象被封閉在該線程中,並且只能由這個線程修改。

只讀共享

在沒有額外同步的情況下,共享的只讀對象可以由多個線程併發訪問,但任何線程都不能修改它。包括不可變對象和事實不可變對象

線程安全共享

線程安全的對象在其內部實現同步,因此多個線程可以通過對象的共有接口來進行訪問而不需要進一步的同步

保護對象

被保護的對象只能通過持有特定的鎖來訪問。保護對象包括封裝在其他線程安全對象的對象,以及已發佈的並且由某個特定鎖保護的對象。

發佈了105 篇原創文章 · 獲贊 35 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章