【Java併發編程實戰】03對象的共享

上一篇介紹瞭如何通過同步多個線程避免同一時刻訪問相同數據,本篇介紹如何共享和發佈對象,使它們被安全地由多個進程訪問。

1.可見性

通常,我們無法保證執行讀操作的線程能看到其他線程寫入的值,因爲每個線程都由自己的緩存機制。爲了確保多個線程之間對內存寫入操作的可見性,必須使用同步機制。

public class NoVisibility {
    private static boolean ready;
    private static int number;

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

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

以上代碼,看起來會輸出42,但事實上很可能根本無法終止,因爲讀線程永遠看不到ready的值;很有可能輸出0,因爲讀線程看到了寫入ready的值,卻沒有看到之後寫入number的值,這種現象稱爲“重排序”。在沒有同步的情況下,編譯器、處理器、運行時等都有可能對操作的執行順序進行一些意想不到的調整。

所以,只要有數據在多個線程之間共享時,就應該使用正確的同步。

1.1 失效數據

除非使用同步,否則很可能獲得變量的失效值。失效值可能不會同時出現,一個線程可能獲得一個變量的最新值,而獲得另一個變量的失效值。失效數據還可能導致一些令人困惑的故障,如:意料之外的異常、被破壞的數據結構、不精確的計算、無限循環等等。

1.2 非原子的64位操作

對於非volatile類型的long和double變量,JVM允許將64位的讀操作或寫操作分解爲兩個32位的操作。所以,很可能會讀取到最新值的高32位和失效值的低32值,造成讀取到是一個隨機值。除非用關鍵字volatile來聲明它們,或者用鎖保護起來。

1.3 加鎖和可見性

當某線程執行由鎖保護的同步代碼塊時,可以看到其他線程之前在同一同步代碼塊中的所有操作結果。如果沒有同步,將無法實現上述保證。加鎖的含義不僅僅侷限於互斥行爲,還包括可見性。爲了確保所有線程都能看到共享變量的最新值,所有執行讀操作或寫操作的線程都必須在同一個鎖上同步。

1.4 volatile變量

當把變量聲明爲volatile類型後,編譯器和運行時都不會將該變量上的操作也其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者其他處理器不可見的地方,因此在讀取volatile變量時總會返回最新寫入的值。加鎖機制既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性。

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

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

2. 發佈與泄露

發佈一個對象是指,是對象能夠在當前作用域之外的代碼中使用。發佈對象的方式包括:非私用變量的引用、方法調用返回的引用、發佈內部類對象隱含外部類的引用等等。當某個不應該發佈的對象被髮布是,就被稱爲泄露。

public class ThisEscape {
   private int status;
   public ThisEscape(EventSource source) {
      source.registerListener(new EventListener() {
         public void onEvent(Event e) {
            doSomething(e);
         }
      });
      status = 1;
   }

   void doSomething(Event e) {
      status = e.getStatus();
   }

   interface EventSource {
      void registerListener(EventListener e);
   }

   interface EventListener {
      void onEvent(Event e);
   }

   interface Event {
      int getStatus();
   }
}

由於內部類的實例包含了對外部類實例的隱含引用,當ThisEscape發佈EventListener時,也隱含發佈了ThisEscape實例本身。但在此時,變量status還沒有被初始化,造成了this引用在構造函數中泄露。可以使用一個私有的構造函數和一個公共的工廠方法,避免不正確的構造過程:

public class SafeListener {
    private int status;
    private final EventListener listener;
    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
        status = 1;
    }
    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }

    void doSomething(Event e) {
        status = e.getStatus();
    }

    interface EventSource {
        void registerListener(EventListener e);
    }

    interface EventListener {
        void onEvent(Event e);
    }

    interface Event {
        int getStatus();
    }
}

3. 線程封閉

一種避免使用同步的方式就是不共享。如果僅在單線程內訪問數據,就不需要同步,這就被稱爲線程封閉。線程封閉是程序設計中的考慮因素,必須在程序中實現。Java也提供了一些機制幫助維護線程封閉,比如局部變量和ThreadLocal。

3.1 Ad-hoc線程封閉

Ad-hoc線程封閉是指,維護線程封閉性的職責完全由程序實現來承擔。使用volatile變量是實現Ad-hoc線程封閉的一種方式,只要能保證只有單個線程對共享volatile變量執行寫入操作,那麼就可以安全低在這些變量上進行“讀取-修改-寫入”操作,volatile變量的可見性又保證了其他線程能夠看到最新的值。

Ad-hoc線程封閉是非常脆弱的,因此在程序中儘量少使用。在可能的情況下,使用其他線程封閉技術,比如:棧封閉、ThreadLocal。

3.2 棧封閉

在棧封閉中,只能通過局部變量才能訪問對象。它們位於執行線程的棧中,其他線程無法訪問到。即使這些對象是非線程安全的對象,它們仍然是線程安全的。然而,值得注意的是,只要編寫代碼的人才知道哪些對象是棧封閉的。如果沒有明確的說明,後續的維護人員很容易錯誤的泄露這些對象。

3.3 ThreadLocal類

使用ThreadLocal是一種更規範的線程封閉方式,它能是線程中的某個值與保存值的對象關聯起來。如下代碼,通過將JDBC的連接保存到ThreadLocal對象中,每個線程都會擁有屬於自己的連接:

public class ConnectionDispenser {
    static String DB_URL = "jdbc:mysql://localhost/mydatabase";

    private ThreadLocal<Connection> connectionHolder
        = new ThreadLocal<Connection>() {
            public Connection initialValue() {
                try {
                    return DriverManager.getConnection(DB_URL);
                } catch (SQLException e) {
                    throw new RuntimeException("Unable to acquire Connection, e");
                }
        };
    };

    public Connection getConnection() {
        return connectionHolder.get();
    }
}

從概念上看,你可以將ThreadLocal視爲包含了Map<Thread,T>對象,其中保存了特定於改線程的值,但ThreadLocal的實現並非如此。這些特定於線程的值保存在Thread對象中,當線程終止後,這些值會作爲垃圾被回收。

4. 不變性

如果某個對象在被創建後其狀態就不能被修改,那麼這個對象就被稱爲不可變對象。滿足同步需求的另一種方法就是使用不可變對象。不可變對象一定是線程安全的。當滿足以下條件時,對象纔是不可變的:

  • 對象創建以後其狀態就不能改變
  • 對象的所有域都是final類型
  • 對象是正確創建的,在對象創建期間,this引用沒有泄露
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);
    }
}

上述代碼中,儘管stooges對象是可變的,但在它構造完成後無法對其修改。stooges是一個final類型的引用變量,因此所有的對象狀態都通過一個final域訪問。在構造函數中,this引用不能被除了構造函數之外的代碼訪問到。

4.1 final域

final類型的域是不能修改的,但如果final域所引用的對象是可變的,那麼這些被引用的對象是可以修改的。final域的對象在構造函數中不會被重排序,所以final域也能保證初始化過程的安全性。和“除非需要更高的可見性,否則應將所有的域都聲明爲私用域”一樣,“除非需要某個域是可變的,否則應將其聲明爲final域”也是一個良好的編程習慣。

4.2 使用volatile類型來發布不可變對象

因式分解Sevlet將執行兩個原子操作:

  • 更新緩存
  • 通過判斷緩存中的數值是否等於請求的數值來決定是否直接讀取緩存中的結果

每當需要一組相關數據以原子方式執行某個操作時,就可以考慮創建一個不可變的類來包含這些數據:

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

當線程獲取了不可變對象的引用後,不必擔心另一個線程會修改對象的狀態。如果要更新這些變量,可以創建一個新的容器對象,但其他使用原有對象的線程仍然會看到對象處於一致的狀態。當一個線程將volatile類型的cache設置爲引用一個新的OneValueCache時,其他線程就會立即看到新緩存的數據:

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);
        }
        encodeIntoResponse(resp, factors);
    }
}

5 安全發佈

5.1 不正確的發佈

像這樣將對象引用保存到公有域中就是不安全的:

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

由於存在可見性問題,其他線程看到的Holder對象將處於不一致的狀態。除了發佈對象的線程外,其他線程可以看到Holder域是一個失效值,因此將看到一個空引用或者之前的舊值。

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對象被正確的發佈,assertSanity也有可能拋出AssertionError。因爲線程看到Holder引用的值是最新的,但由於重排序Holder狀態的值卻是時效的。

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

即使在發佈不可變對象的引用時沒有使用同步,也仍然可以安全地訪問該對象。任何線程都可以在不需要額外同步的情況下安全地訪問不可變對象,即使在發佈這些對象時沒有使用同步。在沒有額外同步的情況下,也可以安全地訪問final類型的域。然而,如果final類型的域所指向的是可變對象,那麼在訪問這些域所指向的對象的狀態時仍然需要同步。

5.3 安全發佈的常用模式

要安全地發佈一個對象,對象的引用以及對象的狀態必須同時對其他線程可見。一個正確構造的對象可以通過以下方式來安全發佈:

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

線程安全庫中的容器類提供了以下的安全發佈保證:

  • 通過將一個鍵或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地將它發佈給任何從這些容器中訪問它的線程。
  • 通過將某個對象放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中,可以將該對象安全地發佈到任何從這些容器中訪問該對象的線程。
  • 通過將某個對象放入BlockingQueue或者ConcurrentLinkedQueue中,可以將該對象安全地發佈到任何從這些隊列中訪問該對象的線程。

5.4 事實不可變對象

如果對象從技術上來看是可變的,但其狀態在發佈後不會再改變,那麼把這種對象稱爲事實不可變對象。在沒有額外的同步的情況下,任何線程都可以安全地使用被安全發佈的事實不可變對象。例如維護一個Map對象,其中保存了每位用戶的最新登錄時間:

public Map<String, Date> lastLogin =
Collections.synchronizedMap(new HashMap<String, Date());
如果Date對象的值在被放入Map後就不會改變,那麼synchronizedMap中的同步機制就足以使Date值被安全地發佈,並且在訪問這些Date值時不需要額外的同步。

5.5 可變對象

對於可變對象,不僅在發佈對象是需要使用同步,而且在每次對象訪問時同樣需要使用同步來確保後續修改操作的可見性。對象的發佈需求取決於它的可變性:

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

5.6 安全的共享對象

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

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