1. 程式人生 > >Java 並發編程(二)對象的不變性和安全的公布對象

Java 並發編程(二)對象的不變性和安全的公布對象

不一致 字段 更新 要求 nts ava 然而 caching mut

一、不變性

滿足同步需求的還有一種方法是使用不可變對象(Immutable Object)。

到眼下為止,我們介紹了很多與原子性和可見性相關的問題,比如得到失效數據。丟失更新操作或光查到某個對象處於不一致的狀態等等,都與多線程視圖同一時候訪問同一個可變的狀態相關。假設對象的狀態不會改變,那麽這些問題與復雜性也就自然消失了。

假設某個對象在被創建後其狀態就不能被改動,那麽這個對象就被成為不可變對象。線程安全型是不可變對象的固有屬性之中的一個,他們的不變性條件是由構造函數創建的,僅僅要他們的狀態不改變,那麽這些不變性條件就能得以維持。

不可變對象非常easy。他們僅僅有一種狀態,而且該狀態由構造函數來控制

。在程序設計中一個最困難的地方就是推斷復雜對象的可能狀態。然而,推斷不可變對象的狀態卻非常easy。

盡管在 Java 規範和 Java 內存模型中都沒有給出不可變性的正式定義,但不可變性並不等於將對象中全部的域都聲明為 final 類型,即使對象中全部的域都是 final 類型的,這個對象也仍然是可變的,由於在 final 類型的域中能夠保存對可變對象的引用。

當滿足下面條件時,對象才是不可變的:

  • 對象創建完之後其狀態就不能改動
  • 對象的全部與都是 final 類型
  • 對象時正確創建的(創建期間沒有 this 的逸出)
我們來分析以下這個類。

@Immutable
public final class ThreeStooges {
    private final Set<String> stooges = new HashSet<String>();

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

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

在不可變對象的內部仍能夠使用可變對象來管理它們的狀態。如 ThreeStooges 所看到的。雖然保存姓名的Set對象是可變的,但從ThreeStooges的設計中能夠看到。在Set對象構造完畢後無法對其進行改動。stooges是一個final類型的引用變量,因此全部的對象狀態都通過一個final域來訪問。最後一個要求是“正確地構造對象”。這個要求非常easy滿足,由於構造函數能使該引用由除了構造函數及其調用者之外的代碼來訪問。

因為程序的狀態總在不斷地變化,你可能會覺得須要使用不可變對象的地方不多。但實際情況並不是如此。

在“不可變的對象”與“不可變的對象引用”之間存在著差異。

保存在不可變對象中的程序狀態仍然能夠更新,即通過將一個保存新狀態的實例來“替換”原有的不可變對象。

Final 域

keyword final 能夠視為 C++ 中 const 機制的一種受限版本號,用於構造不可變對象。final 類型的域是不能改動的(但假設 final 域所引用的對象時可變的,那麽這些被引用的對象是能夠改動的)。然而,在 Java 內存模型中,final 域還有著特殊的語義。final 域能確保初始化過程的安全性。從而能夠不受限制的訪問不可變對象,並在共享這些對象時無需同步。

註:個人理解為,final 字段一旦被初始化完畢。並且構造器沒有把 this 引用傳遞出去,那麽在其它線程中就能看到 final 字段的值(域內變量可見性,和 volatile 類似),並且其外部可見狀態永遠也不會改變。它所帶來的安全性是最簡單最純粹的。

註:即使對象是可變的,通過將對象的某些域聲明為final類型。仍然能夠簡化對狀態的推斷。因此限制對象的可變性也就相當於限制了該對象可能的狀態集合。僅包括一個或兩個可變狀態的“基本不可變”對象仍然比包括多個可變狀態的對象簡單。通過將域聲明為final類型,也相當於告訴維護人員這些域是不會變化的。

正如“除非須要更高的可見性,否則應將全部的餓域都聲明為私有域”[EJ Item 12]是一個良好的變成習慣,“除非須要某個域是可變的,否則應將其聲明為final域”也是一個良好的變成習慣。

演示樣例:使用 Volatile 類型來公布不可變對象

之前我們講過, volatile 能夠用來保證域的可見性而不能保證變量操作的原子性,更為準確的講。僅僅能保證讀寫操作具有原子性,而不能保證自增 i++ 等運算操作的原子性。

在前面的UnsafeCachingFactorizer類中,我們嘗試用兩個AtomicReferences變量來保存最新的數值及其因數分解結果,但這樣的方式並不是是線程安全的,由於我們無法以原子方式來同一時候讀取或更新這兩個相關的值。相同。用volatile類型的變量來保存這些值也不是線程安全的。然而,在某些情況下,不可變對象能提供一種弱形式的原子性。

因式分解Servlet將運行兩個原子操作:更新緩存的結果,以及通過推斷緩存中的數值是否等於請求的數值來決定是否直接讀取緩存中的因數分解結果。

每當須要對一組相關數據以原子方式運行某個操作時,就能夠考慮創建一個不可變的類來包括這些數據,比如 OneValueCache。

@Immutable
class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    /**
     * 假設在構造函數中沒有使用 Arrays.copyOf()方法。那麽域內不可變對象 lastFactors卻能被域外代碼改變
     * 那麽 OneValueCache 就不是不可變的。
     */
    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);
    }
}

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

假設是一個可變的對象,那麽就必須使用鎖來確保原子性。

假設是一個不可變對象,那麽當線程獲得了對該對象的引用後,就不必操心還有一個線程會改動對象的狀態。假設要更新這些變量。那麽能夠創建一個新的容器對象。但其它使用原有對象的線程仍然會看到對象處於一致的狀態。

在 VolatileCachedFactorizer使用了OneValueCache來保存緩存的數值及其因數。我們將 OneValueCache 聲明為 volatile,這樣當一個線程將cache設置為引用一個新的OneValueCache時,其它線程就會馬上看到新緩存的數據。

@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache =
        new OneValueCache(null, null);

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);//聲明為 volatile 。防止指令重排序,保證可見性
        }
        encodeIntoResponse(resp, factors);
    }
}

與cache相關的操作不會相互幹擾。由於OneValueCache是不可變的。而且在每條對應的代碼路徑中僅僅會訪問它一次。通過使用包括多個狀態變量的容器對象來維持不變性條件。並使用一個volatile類型的引用來確保可見性,使得Volatile Cached Factorizer在沒有顯式地使用鎖的情況下仍然是線程安全的。


二、安全公布

到眼下為止,我們重點討論的是怎樣確保對象不被公布,比如讓對象封閉在線程或還有一個對象的內部。當然,在某些情況下我們希望在多個線程間共享對象,此時必須確保安全地進行共享。然而,假設僅僅是像以下程序那樣將對象引用保存到公有域中,那麽還不足以安全地公布這個對象。

//不安全的公布
public Holder holder;

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

你可能會奇怪。這個看似沒有問題的演示樣例何以會執行失敗。

因為存在可見性問題,其它線程看到的Holder對象將處於不一致的狀態,即便在該對象的構造函數中已經正確地構建了不變性條件。這樣的不對的公布導致其它線程看到尚未創建完畢的對象。

不對的公布:正確的對象被破壞

你不能指望一個尚未被全然創建的對象擁有完整性。某個觀察該對象的線程將看到對象處於不一致的狀態。然後看到對象的狀態突然發生變化,即使線程在對象公布後還沒有改動過它。其實,假設以下程序中的Holder使用前面程序中的不安全公布方式,那麽還有一個線程在調用assertSanity時將拋出AssertionError。


public class Holder {
    private int n;

    public Holder(int n) { this.n = n; }

    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false.");
    }
}

因為沒有使用同步來確保Holder對象對其它線程可見,因此將Holder稱為“未被正確公布”。在未被正確公布的對象中存在兩個問題。

首先,除了公布對象的線程外,其它線程能夠看到的Holder域是一個失效值。因此將看到一個空引用或者之前的舊值。

然而,更糟糕的情況是,線程看到Holder引用的值是最新的,但Holder狀態的值卻是失效的。情況變得更加不可預測的是,某個線程在第一次讀取域時得到失效值,而再次讀取這個域時會得到一個更新值。這也是assertSainty拋出AssertionError的原因。

假設沒有足夠的同步,那麽當在多個線程間共享數據時將發生一些很奇怪的事情。

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

因為不可變對象是一種很重要的對象,因此Java內存模型為不可變對象的共享提供了一種特殊的初始化安全性保證。我們已經知道,即使某個對象的引用對其它線程是可見的,也並不意味著對象狀態對於使用該對象的線程來說一定是可見的。為了確保對象狀態能呈現出一致的視圖,就必須使用同步。

還有一方面。即使在公布不可變對象的引用時沒有使用同步,也仍然能夠安全地訪問該對象。

為了維持這樣的初始化安全性的保證。必須滿足不可變性的全部需求:狀態不可改動,全部域都是final類型。以及正確的構造過程。(假設Holder對象是不可變的,那麽即使Holder沒有被正確地公布。在assertSanity中也不會拋出AssertionError。

不論什麽線程都能夠在不須要額外同步的情況下安全地訪問不可改變對象,即使在公布這些對象時沒有使用同步。

這樣的保證還將延伸到被正確創建對象中全部final類型的域。在沒有額外同步的情況下,也能夠安全地訪問final類型的域。然而。假設final類型的域所指向的是可變對象。那麽在訪問這些域所指向的對象的狀態時仍然須要同步。

安全公布的經常使用模式

可變對象必須通過安全的方式來公布,這通常意味著在公布和使用該對象的線程時都必須使用同步。

如今,我們將重點介紹怎樣確保使用對象的線程可以看到該對象處於已公布的狀態。並稍後介紹怎樣在對象公布後對其可見性進行改動。

安全地公布一個對象。對象的應用以及對象的狀態必須同一時候對其它線程可見。一個正確構造的對象能夠通過下面方式來安全地公布:

  • 在靜態初始化函數中初始化一個對象引用
  • 將對象的應用保存到volatile類型的域或者AtomicReferance對象中
  • 將對象的引用保存到某個正確構造對象的final類型域中
  • 將對象的引用保存到一個由鎖保護的域中。
在線程安全容器內部的同步意味著,在將對象放入到某個容器。比如Vector或synchronizedList時,將滿足上述最後一條需求。假設線程A將對象X放入一個線程安全的容器。隨後線程B讀取這個對象,那麽能夠確保B看到A設置的X狀態,即便在這段讀/寫X的應用程序代碼中沒有包括顯式的同步。雖然Javadoc在這個主題上沒有給出非常清晰的說明,但線程安全庫中的容器類提供了下面的安全公布保證:
  • 通過將一個鍵或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,能夠安全地將它公布給不論什麽從這些容器中訪問它的線程(不管是直接訪問還是通過叠代器訪問)
  • 通過將某個元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,能夠將該元素安全地公布到不論什麽從這些容器中訪問該元素的線程
  • 通過將某個元素放入BlockingQueue或者ConcurrentLinkedQueue中,能夠將該元素安全地公布到不論什麽從這些隊列中訪問該元素的線程。

類庫中的其它數據傳遞機制(比如Future和Exchanger)相同能實現安全公布。在介紹這些機制時將討論它們的安全公布功能。

通常,要公布一個靜態構造的對象,最簡單和最安全的方式是使用靜態的初始化器:
public static Holder holder = new Holder(42);

靜態初始化器由JVM在類的初始化階段運行。

因為在JVM內部存在著同步機制,因此通過這樣的方式初始化的不論什麽對象都能夠被安全地公布[JLS 12.4.2]。

事實不可變對象

假設對象在公布後不會被改動,那麽對於其它在沒有額外同步的情況下安全地訪問這些對象的線程來說,安全公布是足夠的。全部的安全公布機制都能確保。當對象的引用對全部訪問該對象的線程可見時,對象公布時的狀態對於全部線程也將是可見的,而且假設對象狀態不會再改變,那麽就足以確保不論什麽訪問都是安全的。

假設對象從技術上來看是可變的,但其狀態在公布後不會再改變。那麽把這樣的對象稱為“事實不可變對象(Effectively Immutable Object)”。這些對象不須要滿足之前提出的不可變性的嚴格定義。在這些對象公布後。程序僅僅需將它們視為不可變對象就可以。通過使用事實不可變對象。不僅能夠簡化開發過程,並且還能因為降低了同步而提高性能。

在沒有額外的同步的情況下,不論什麽線程都能夠安全地使用被安全公布的事實不可變對象。

比如,Date本身是可變的,但如果將它作為不可變對象來使用,那麽在多個線程之間共享Date對象時,就能夠省去對鎖的使用。

如果須要維護一個Map對象,當中保存了每位用戶的近期登錄時間:
public Map<String, Date> lastLogin =Collections.synchronizedMap(new HashMap<String, Date>());

假設Date對象的值在被放入Map後就不會改變,那麽synchronizedMap中的同步機制就足以使Date值被安全地公布。而且在訪問這些Date值時不須要額外的同步。

可變對象

假設對象在構造後能夠改動,那麽安全公布僅僅能確保“公布當時”狀態的可見性。對於可變對象,不僅在公布對象時須要使用同步,並且在每次對象訪問時相同須要使用同步來確保興許改動操作的可見性。

要安全地共享可變對象。這些對象就必須被安全地公布。並且必須是線程安全的或者由某個鎖保護起來。

對象的公布需求取決於它的可變性:

  • 不可變對象能夠通過隨意機制來公布
  • 事實不可改變必須通過安全方式公布
  • 可變對象必須通過安全方式公布。而且必須是線程安全的或者由某個鎖保護起來

安全的共享對象

當獲得對象的一個引用時,你須要知道在這個引用上能夠運行哪些操作。

在使用它之前是否須要獲得一個鎖?能否夠改動它的狀態,或者僅僅能讀取它?很多並發錯誤都是因為沒有理解共享對象的這些“既定規則”而導致的。當公布一個對象時,必須明白地說明對象的訪問方式。

Java 並發編程(二)對象的不變性和安全的公布對象