【Java並發.3】對象的共享
本章將介紹如何共享和發布對象,從而使他們能夠安全地由多個線程同時訪問。這兩章合在一起就形成了構建線程安全類以及通過 java.util.concurrent 類庫來構建開發並發應用程序的重要基礎。
3.1 可見性
可見性是一種復雜的屬性,因為可見性中的錯誤總是違背我們的直覺。為了確保多個線程之間對內存寫入操作的可見性,必須使用同步機制。
在下面的清單中 NoVisibility 說明了當多個線程在沒有同步的情況下共享數據出現的錯誤。主線程啟動讀線程,然後將 number 設為 42,並將 ready 設為 true。讀線程一直循環直到發現 ready 的值變為 true,然後輸出 number 的值。雖然看起來會輸出 42,但事實上可能輸出 0,或者根本無法終止。這是因為代碼中沒有使用足夠的同步機制,因此無法保證主線程寫入的ready 值和 nunber 值對於讀線程來說是可見的。
public class NoVisibility { 【皺眉臉-不要這樣做】 private static boolean ready; private static int number; public static void main(String[] args) { new ReaderThread().start(); number = 42; ready = true; } private static class ReaderThread extendsThread { public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } }
NoVisibility 可能會持續循環下去,因為讀線程可能永遠都看不到 ready 值。一種更奇怪的現象是,NoVisibility 可能會輸出 0,因為讀線程可能看到了寫入 ready 值,但卻沒有看到之前寫入 number 值,這種現象稱為“重排序(Reordering)
在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多線程程序中,要想對內存操作的執行順序進行判斷,幾乎無法得出正確的結論。
3.1.1 失效數據
NoVisibility 展示了在缺乏同步的程序中可能產生錯誤結果的一種情況:失效數據。當讀線程查看 ready 變量時,可能會得到一個已經失效的值。除非在每次訪問變量時都使用同步,否則很可能獲得該變量的一個失效值。更糟糕的是,失效值可能不會同時出現:一個程序可能獲得某個變量的最新值,而獲得另一個變量的失效值。
失效數據還可能導致一些令人困惑的故障,例如意料之外的異常、被破壞的數據結構、不精確的計算以及無限循環等。
在如下程序清單 Mutableinteger 不是線程安全的,因為 get 和 set 都是沒有同步的情況下訪問 value 的。如果某個線程調用了 set,那麽另一個在調用的get 線程可能會看到更新後的值,也可能看不到。
public class MutableInteger { private int value; public int get() { return value; } public void set(int value) { this.value = value; } }
在程序清代 SynchronizedInteger 中,通過對 get 和 set 方法進行同步,可以使MutableInteger 成為一個線程安全的類。僅對 set 方法進行同步時不夠的,調用 get 線程仍然會看到失效值。
public class SynchronizedInteger { private int value; public synchronized int get() { return value; } public synchronized void set(int value) { this.value = value; } }
3.1.2 非原子的64位操作
忽略。。。
3.1.3 加鎖與可見性
內置鎖可以用於確保某個線程以一種可預測的方式來查看另一個線程的執行結果。對於同一個鎖,後面進入鎖的線程可以看到之前線程在鎖中的所有操作結果(加鎖可以保證可見性)。
加鎖的含義不僅僅局限於互斥行為,還包括內存可見性。為了確保所有線程都能看到共享變量的最新值,所有執行讀操作或者寫操作的線程都必須在同一個鎖上同步
3.1.4 Volatile變量
對於volatile 關鍵字的詳細介紹,建議大家去仔細觀看 volatile關鍵字解析 ,所以在這不做介紹。
3.2 發布與逸出
“發布(Publish)”一個對象的一起是指,是對象能夠在當前作用域之外的代碼中使用。例如,將一個指向該對象的引用保存到其他代碼可以訪問的地方,或者在某一個非私有的方法中返回該引用,或者將引用傳遞到其他類的方法中。在許多情況中,我們要確保對象及其內部狀態不被發布。而在某些情況下,我們又需要發布某個對象,但如果在發布時要確保線程安全性,則可能需要同步。當某個不應該發布的對象被發布時,這種情況就被稱為逸出(Escape)。
發布對象最簡單的方法就是將對象的引用保存到一個公有的靜態變量中,以便任何類和線程都能看見該對象,如下。發布一個對象
public class KnownSecrets { public static Set<Secret> knownSecrets; public void initialize() { knownSecrets = new HashSet<Secret>(); } }
程序清單:是內部的可變狀態逸出:
public class UnsafeStates { private String[] states = new String[] {"AK","AL"...}; public String[] getStates() { return states; } }
如何按照上述方式來發布 states,就會出現問題,因為任何調用者都能修改這個數組的內容。在這個實例中,數組 states 已經逸出了它所在的作用域,因為這個本應是私有的變量已經被發布了。
當發布一個對象時,在該對象的非私有域中引用的所有對象同樣會被發布。一般來說,如果一個已經發布的對象能夠通過非私有的變量引用和方法調用到達其他的對象,那麽這些對象也都會被發布。
3.3 線程封閉
當訪問共享的可變數據時,通常需要使用同步。一種避免使用同步的方式就是不同享數據。如果僅在單線程內訪問數據,就不需要同步。這種技術稱為線程封閉(Thread Confinement),它是實現線程安全性的最簡單方式之一。
線程封閉技術的常見應用時 JDBC 的 Connection 對象。線程從連接池中獲得一個 Connection 對象,並且用該對象來處理請求,使用完後再將對象返還給連接池。由於大多數請求都是由單個線程采用同步的方式來處理,並且在 Connection 對象返回之前,連接池不會再將它分配給其他線程,因此,這種連接管理模式在處理請求時隱含地將 Connection 對象封閉在線程中。
3.3.1 Ad-hoc線程封閉
略...
3.3.2 棧封閉
棧封閉式線程封閉的一種特例,在棧封閉中,只能通過局部變量才能訪問對象。局部變量的固有屬性之一就是封閉在執行線程中。它們位於執行線程的棧中,其他線程無法訪問這個棧。棧封閉(也被稱為線程內部使用或者線程局部使用,不要與核心類庫中的 ThreadLocal 混淆)。
對於基本類型的局部變量,如下程序清單中 loadTheArk 方法的 numPairs,無論如何都不會破壞棧封閉性,由於任何方法都無法獲得基本類型的引用,因此Java 語言的這種語義就確保了基本來興的局部變量始終封閉在線程內。
public int loadTheArk(Collection<Animal> candidates) { SortedSet<Aniaml> animals; int numPairs = 0; //基本類型的局部變量 Aniaml candidate = null; // animals 被封閉在方法中,不要使它們逸出 animals = new TreeSet<Animal>(new SpeciesGenderComparator()); animals.addAll(candidates); for (Animal a : animals) { numPairs++; } return numPairs; }
3.3.3 ThreadLocal 類
維持線程封閉性的一種更規範方法就是使用 ThreadLocal,這個類能使線程中的某個值與保存值的對象關聯起來。ThreadLocal 提供了 get 和 set 等訪問接口或方法,這些方法為每個使用該變量的線程都存有一份獨立的副本,因此 get 總是返回由當前執行線程在調用 set 時設置的最新值。
ThreadLocal 對象通常用於放置對可變的單實例變量(Singleton)或全局變量進行共享。例如,在單線程應用程序中可能會維持一個全局的數據庫連接,並在程序啟動時初始化這個連接對象,從而避免在調用每個方法時都要傳遞一個 Connection 對象。
private static ThreadLocal<Connection> connectionThreadLocal = new ThreadLocal<>() { @Override protected Object initialValue() { return DriverManager.getConnection(URL); } } public static Connection getConnection() { return connectionThreadLocal.get(); }
當某個線程初次調用 ThreadLocal.get 方法時,就會調用 initialValue 來獲取初始值。從概念上看,你可以將 ThreadLocal<T> 視為包含了 Map<Thread, T> 對象,其中保存了特定於該線程的值,但 ThreadLocal 的實現並非如此。這些特定於線程的值保存在 Thread 對象,當線程終止後,這些值會作為垃圾回收。
3.4 不變性
滿足同步需求的另一種方法時使用不可變對象。到目前為止,我們介紹了許多與原子性和可見性相關的問題,例如得到失效數據,丟失更新操作或者觀察到某個對象處於不一致的狀態等等,都與多線程試圖同時訪問同一個可變的狀態相關。如果對象的狀態不會改變,那麽這些問題與復雜性也就自然消失了。
不可變對象一定是線程安全的。
雖然在Java 語言規範和 Java 內存模型中都沒有給出不可變性的正式定義,但不可變性並不等於將對象中所有的域都聲明為 final 類型,即使對象中所有的域都是 final 類型的,這個對象也仍然是可變的,因為在 final 類型的域中可以保存對可變對象的引用。
當滿足以下條件時,對象才是不可變的:
- 對象創建以後其狀態不可能修改。
- 對象的所有域都是 final 類型。
- 對象時正確創建的(在對象的創建期間, this 引用沒有逸出)。
看個例子:在可變對象基礎上構建的不可變類
public class ThreeStooges { private final Set<String> stooges = new HashSet<>(); public ThreeStooges() { stooges.add("one"); stooges.add("two"); stooges.add("three"); } public boolean isStooge(String name) { return stooges.contains(name); } }
3.4.1 Final 域
在 Java 內存模型中,final 域還有著特殊的語義。final 域能確保初始化過程的安全性,從而可以不受限制地訪問不可變對象,並在共享這些對象時無需同步。
正如“除非需要更高的可見性,否則應將所有的域都聲明為私有域”是一個良好的編程習慣,“除非需要某個域是可變的,否則應將其聲明為 final 域”也是一個良好的編程習慣。
3.4.2 示例:使用 volatile 類型來發布不可變對象
對於volatile 關鍵字的詳細介紹,建議大家去仔細觀看 volatile關鍵字解析 ,所以在這不做過多介紹。貼一個代碼:
public class VolatileCachedFactorizer implements Servlet { private volatile OneValueCache cache = new OneValueCache(null, null); public void service(ServletRequest request, ServletResponse response) { BigInteger i = extractFromRequest(request); BigInteger[] factors = cache.getFactors(i); if (factors == null) { factors = factor(i); cache = new OneValueCache(i, factors); } encodeIntoResponse(response, factors); } }
3.5 安全發布
到目前為止,我麽重點討論的是如何確保對象不被發布,例如讓對象封閉在線程或另一個對象的內部。當然,在某些情況下我們希望多個線程間共享對象,此時必須確保安全地進行共享。
如下:在沒有足夠同步的情況下發布對象(不要這樣做)
//不安全的發布 public Holder holder; public void initialize() { holder = new Holder(42); }
由於可見性問題,其他線程看到的 Holder 對象將處於不一致的狀態,即便在該對象的構建函數中已經正確地構建了不便性條件。這種不正確的發布導致其他線程看到尚未創建完成的對象。
3.5.1 不正確的發布:正確的對象被破壞
你不能指望一個尚未被完全創建的對象擁有完整性。某個觀察該對象的線程將看到對象處於不一致的狀態,然後看到對象的狀態突然發生變化,即使線程在對象發布後還沒有修改過它。
如下:由於未被正確發布,因此這個類可能出現故障
public class Holder { private int n; public Holder(int n) { this.n = n; } public void assertSanity() { if(n != n) //這句沒看懂,就算同步時會出現 n 很可能成為失效值,但是難道 (n != n)不是原子操作?求解。 throw new AssertionError("this statement is false"); } }
3.5.2 不可變對象與初始化安全性
由於不可變對象是一種非常重要的對象,因此Java 內存模型為不可變對象的共享提供了一種特殊的初始化安全性保障。
任何線程都可以在不需要額外同步的情況下安全地訪問不可變對象,即使在發布這些對象時沒有使用同步。
3.5.3 安全發布的常用模式
要安全地發布一個對象,對象的引用以及對象的狀態必須同時對其他線程可見。一個正確構造的對象可以通過以下方式來安全地發布:
- 在靜態初始化函數中初始化一個對象引用。
- 將對象的引用保存到 volatile 類型的域或者 AtomicReferance 對象中
- 將對象的引用保存到某個正確構造對象的 final 類型域中。
- 將對象的引用保存到一個由鎖保護的域中。
線程安全庫中的容器類提供了一下的安全發布保證:
- 通過將一個鍵或者值放入 Hashtable、synchronizedMap 或者 ConcurrentMap 中,可以安全地將它發布給任何從這些同期中訪問它的線程(無論是直接訪問還是通過叠代器訪問)
- 通過將某個元素放入 Vector、CopyiOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或 synchronizedSet 中,可以將該元素安全地發布到任何從這些容器中訪問該元素的線程。
- 通過將某個元素放入 BlockingQueue 或者 ConcurrentLinkedQueue 中,可以將該元素安全地發布到任何從這些隊列中訪問該元素的線程。
通常,要發布一個靜態構造的對象,最簡單和最安全的方式是使用靜態的初始化器:
public static Holder holder = new Holder(42);
3.5.4 事實不可變對象
如果對象在發布後不會被修改,那麽 程序只需將它們視為不可變對象即可。
在沒有額外的同步情況下,任何線程都可以安全地使用被安全發布的事實不可變對象。
例如,Date 本身是可變的,但如果將它作為不可變對象來使用,那麽在多個線程之間共享 Date 對象時,就可以省去對鎖的使用。假設需要維護一個 Map 對象,其中保存了每位用戶的最近登錄時間:
public Map<String, Date> lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());
如果Date對象的值在被放入Map 後就不會改變,那麽 synchronizedMap 中的同步機制就足以使 Date 值被安全地發布,並且在訪問這些 Date 值時不需要額外的同步。
3.5.5 可變對象
對於可變對象,不僅在發布對象時需要使用同步,而且在每次對象訪問時同樣需要使用同步來確保後續修改操作的可見性。
對象的發布需要取決於它的可變性:
- 不可變對象可以通過任何機制來發布
- 事實不可變對象必須通過安全方式來發布。
- 可變對象必須通過安全方式來發布,並且必須是線程安全的或者由某個鎖保護起來。
3.4.5 安全地共享對象
當發布一個對象時,必須明確地說明對象的訪問方式。
在並發程序中使用和共享對象時,可以使用一些實用的策略包括:
線程封閉:線程封閉的對象只能由一個線程擁有,對象被封閉在該線程中,並且只能由這個線程修改。
只讀共享:在沒有額外同步的情況下,共享的只讀對象可以由多個線程並發訪問,但任何線程都不能修改它。共享的只讀對象包括不可變對象和事實不可變對象。
線程安全共享:線程安全的對象在其內部實現同步,因此對個線程可以通過對象的公有接口來進行訪問而不需要進一步的同步。
保護對象:被保護的對象只能通過持有特定的鎖來訪問。保護對象包括封裝在其他線程安全對象中的對象,以及已發布的並且由某個特定鎖保護的對象。
【Java並發.3】對象的共享