第 3 章 物件的共享
@@ 關鍵字 synchronized 可以用於實現原子性或確定 “ 臨界區(Critical Section ) ” ,
同時同步還有另一個重要的方面:記憶體可見性(Memory Visibility)。
@@ 可以通過顯式地同步或者類庫中內建的同步來保證物件被安全地釋出。
》》可見性
@@ 可見性是一種複雜的屬性,因為可見性中的錯誤總是會違揹我們的直覺。
@@ 為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。
@@ 在沒有同步的情況下,編譯器、處理器以及執行時等都可能對操作的執行順序進行
一些意向不到的調整。在缺乏足夠同步的多執行緒程式中,要想對記憶體操作的執行順序進行
判斷,幾乎無法得出正確的結論。
@@ 只要有資料在多個執行緒之間共享,就使用正確的同步。
### 失效資料
@@ 雖然在 Web 應用程式中失效的命中計數器可能不會導致太糟糕的情況,但在其他情況
中,失效可能會導致一些嚴重的安全問題或者活躍性問題。
### 非原子的 64 位操作
@@ 當執行緒在沒有同步的情況下讀取變數時,可能會得到一個失效值,但至少這個值是由
之前的某個執行緒設定的值,而不是一個隨機值。這種安全性保證也被稱為最低安全性(out-of-
thin-air safety)。
@@ 最低安全性適用於絕大多數變數,但是存在一個例外:非 volatile 型別的 64位數值變數
(double 和 long )
------------ Java 記憶體模型要求,變數的讀取操作和寫入操作都必須是原子操作,但對於非 volatile
型別的 long 和 double 變數,JVM 允許將 64 位的讀操作或寫操作分解為兩個 32 位的
操作。
------------- 當讀取一個非 volatile 型別的 long變數時,如果對該變數的讀操作和寫操作在不同的
執行緒中執行,那麼很可能會讀取到某個值的高 32 位和另一個值的低 32 位。
------------- 即使不考慮失效資料問題,在多執行緒程式中使用共享且可變的 long 和 double 等型別
的變數也是不安全的,除非用關鍵字 volatile 來宣告它們,或者用鎖保護起來。
### 加鎖與可見性
@@ 內建鎖可以用於確保某個執行緒以一種可預測的方式來檢視另一個執行緒的執行結果。
@@ 加鎖的含義不僅僅侷限於互斥行為,還包括記憶體可見性。為了確保所有執行緒都能看到共享變數
的最新值,所有執行讀操作或者寫操作的執行緒都必須在同一個鎖上同步。
### Volatile 變數
@@ Java 語言提供了一種稍弱的同步機制,即 volatile 變數,用來確保將變數的更新操作通知
到其他執行緒。
------------ 當把變數宣告為 volatile 型別後,編譯器與執行時都會注意到這個變數是共享的,因此
不會將該變數上的操作與其他記憶體操作一起重排序。
------------ volatile 變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取 volatile
型別的變數時總會返回最新寫入的值。
@@ 在訪問 volatile 變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此 volatile 變數
是一種比 synchronized 關鍵字更輕量級的同步機制。
@@ 從記憶體可見性的角度來看,寫入 volatile 變數相當於退出同步程式碼塊,而讀取 volatile 變數
相當於進入同步程式碼塊。然而,我們並不建議過度依賴 volatile 變數提供的可見性。如果在程式碼中
依賴 volatile 變數來控制狀態的可見性,通常比使用鎖的程式碼更脆弱,也更難以理解。
@@ 僅當 volatile 變數能簡化程式碼的實現以及對同步策略的驗證時,才應該使用它們。如果在驗證
時需要對可見性進行復雜的判斷,那麼就不要使用 volatile 變數。
@@ volatile 變數的正確使用方式包括:確保它們自身狀態的可見性,確保它們所引用物件的狀態的
可見性,以及標識一些重要的程式生命週期事件的發生(例如,初始化或關閉)。
@@ 雖然 volatile 變數很方便,但也存在一些侷限性。volatile 變數通常用做某個操作完成 、 發生中斷
或者狀態的標誌。
@@ 加鎖機制既可以確保可見性又可以確保原子性,而 volatile 變數只能確保可見性。
@@ 當且僅當滿足以下所有條件時,才應該使用 volatile 變數:
----- 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。
----- 該變數不會與其他狀態變數一起納入不變性條件。
----- 在訪問變數時不需要加鎖。
》》釋出與逸出
@@ “ 釋出(Publish) ”一個物件的意思是指,使物件能夠在當前作用域之外的程式碼中使用。
@@ 當某個不應該釋出的物件被髮布時,這種情況就被稱為逸出(Escape)。
@@ 釋出物件的最簡單的方法是將物件的引用儲存到一個公有的靜態變數中,以便任何類和
執行緒都能看見該物件。(如下程式碼示例)
---------------------------------------------------------------------------------------------------------------
public static Set<Secret> knowSecrets ;
public void initialize ( ) {
knowSecrets = new HashSet<Secret>( ) ;
}
--------------------------------------------------------------------------------------------------------------
@@ 當釋出一個物件時,在該物件的非私有域中引用的所有物件同樣會被髮布。一般來說,如
果一個已經發布的物件能夠通過非私有的變數引用和方法呼叫到達其他的物件,那麼這些物件也都
會被髮布。
@@ 當某個物件逸出後,你必須假設有某個類或執行緒可能會誤用該物件。這正是需要使用封裝
的最主要原因:封裝能夠使得對程式的正確性進行分析變得可能,並使得無意中破壞設計約束條件
變得更難。
@@ 當從建構函式釋出物件時,只是釋出了一個尚未構造完成的物件。即使釋出物件的語句位於
建構函式的最後一行也是如此。如果 this 引用在構造過程中逸出,那麼這種物件就被認為是不正確
構造。
@@ 不要在構造過程中使 this 引用逸出。
@@ 在構造過程中使 this 引用逸出的一個常見錯誤是,在建構函式中啟動一個執行緒。
--------- 在建構函式中建立執行緒並沒有錯誤,但是最好不要立即啟動它,而是通過一個 start 或
initialize 方法來啟動。
@@ 在建構函式中呼叫一個可改寫的例項方法時(既不是私有方法,也不是終結方法),同樣會
導致 this 引用在建構函式中逸出。
@@ 如果想在建構函式中註冊一個事件監聽器或啟動執行緒,那麼可以使用一個私有的構造
函式和一個公共的工廠方法(Factory Method),從而避免不正確的構造過程。
》》執行緒封閉
@@ 當訪問共享的可變資料時,通常需要使用同步。一種避免使用同步的方式就是不共享
資料。如果僅在單執行緒內訪問資料,就不需要同步。這種技術稱為執行緒封閉(Thread Confinement),
它是實現執行緒安全性的最簡單方式之一。
@@ 當某個物件封閉在一個執行緒中時,這種用法將自動實現執行緒安全性,即使被封閉的物件本身
不是執行緒安全的。
@@ 在 Swing 中大量使用了執行緒封閉技術。Swing 的視覺化元件和資料模型物件都不是執行緒安全
的,Swing 通過將它們封閉到 Swing 的事件分發執行緒中來實現執行緒安全性。
@@ 為了簡化對 Swing 的使用, Swing 還提供了 invokeLater 機制,用於將一個 Runnable
例項排程到事件執行緒中執行。Swing 應用程式的許多併發錯誤都是由於錯誤地在另一個執行緒中使用
了這些被封閉的物件。
@@ 執行緒封閉技術的另一種常見應用是 JDBC 的 Connection 物件。
--------- 在典型的伺服器應用程式中,執行緒從連線池中獲得一個 Connection 物件,並且用該物件來
處理請求,使用完後再將物件返還給連線池。
由於大多數請求(例如 Servlet 請求或 EJB 呼叫等)都是由單個執行緒採用同步的方式來處理,
並且在 Connection 物件返回之前,連線池不會再將它分配給其他執行緒,因此,這種連線管理模式
在處理請求時隱含地將 Connection 物件封閉線上程中。
@@ 執行緒封閉是在程式設計中的一個考慮因素,必須在程式中實現。Java 語言及其核心庫提供了
一些機制來幫助維持執行緒封閉,例如 區域性變數和 ThreadLocal 類,但即便如此,程式設計師仍然需要
負責確保封閉線上程中的物件不會從執行緒中逸出。
### Ad-hoc 執行緒封閉
@@ Ad-hoc 執行緒封閉是指,維護執行緒封閉性的職責完全由程式實現來承擔。Ad-hoc 執行緒封閉是
非常脆弱的。
事實上,對執行緒封閉物件(例如,GUI 應用程式中的視覺化元件或資料模型等)的引用通常
儲存在公有變數中。
@@ 當決定使用執行緒封閉技術時,通常是因為要將某個特定的子系統實現為一個單執行緒子系統。
在某些情況下,單執行緒子系統提供的簡便性要勝過 Ad-hoc 執行緒封閉技術的脆弱性。
@@ 由於 Ad-hoc 執行緒封閉技術的脆弱性,因此在程式中儘量少用它,在可能的情況下,應該
使用更強的執行緒封閉技術(例如,棧封閉 或 ThreadLocal 類)。
### 棧封閉
@@ 棧封閉是執行緒封閉的一種特例,在棧封閉中,只能通過區域性變數才能訪問物件。
@@ 區域性變數的固有屬性之一就是封閉在執行執行緒中,它們位於執行執行緒的棧中,其他執行緒無法
訪問這個棧。
@@ 棧封閉(也被稱為執行緒內部使用或執行緒區域性使用,不要與核心類庫中的 ThreadLocal 混淆)
比 Ad-hoc 執行緒封閉更易於維護,也更加健壯。
@@ 在維持物件引用的棧封閉性時,程式設計師需要多做一些工作以確保被引用的物件不會逸出。
@@ 如果線上程內部上下文中使用非執行緒安全的物件,那麼該物件仍然是執行緒安全的。然而,
要小心的是,只有編寫程式碼的開發人員才知道哪些物件需要被封閉到執行執行緒中,以及被封閉
的物件是否是執行緒安全的。如果沒有明確地說明這些需求,那麼後續的維護人員很容易錯誤地
使物件逸出。
### ThreadLocal 類
@@ 維持執行緒封閉性的一種更規範方法是使用 ThreadLocal ,這個類能使執行緒中的某個值與儲存
值的物件關聯起來。ThreadLocal 提供了 get 與 set 等訪問介面或方法,這些方法為每個使用該變數
的執行緒都存有一個獨立的副本,因此 get 總是返回由當前執行執行緒在呼叫 set 時設定的最新值。
@@ ThreadLocal 物件通常用於防止對可變的單例項變數(Singleton)或全域性變數進行共享。
@@ 通過將 JDBC 的連線儲存到 ThreadLocal 物件中,每個執行緒都會擁有屬於自己的連線。
示例:使用 ThreadLocal 來維持執行緒封閉性
-----------------------------------------------------------------------------------------------------------------------------
public static ThreadLocal<Connection> connectionHolder
= new ThreadLocal<Connection>( ){
public Connection initialValue( ){
return DriverManager.getConnection( DB_URL ) ;
}
};
public static Connection getConnection ( ){
return connectionHolder.get( ) ;
}
----------------------------------------------------------------------------------------------------------------------------
補充:(1)、當某個頻繁執行的操作需要一個臨時物件,例如一個緩衝區,而同時又希望
避免在每次執行時都重新分配該臨時物件,就可以使用上面的這項技術。
(2)、當某個執行緒初次呼叫 ThreadLocal.get 方法時,就會呼叫 initialValue( ) 來
獲取初始值。
@@ 假設你需要將一個單執行緒應用程式移植到多執行緒環境中,通過將共享的全域性變數轉換為
ThreadLocal 物件(如果全域性變數的語義允許),可以維持執行緒安全性。然而,如果將應用程式
範圍內的換粗轉換為執行緒區域性的快取,就不會有太大的作用。
@@ 在實現應用程式框架時大量使用了 ThreadLocal 。例如,在 EJB 呼叫期間,J2EE 需要
容器需要將一個事務上下文(Transaction Context)與某個執行中的執行緒關聯起來。通過將事務
上下文儲存在靜態的 ThreadLocal 物件中,可以很容易地實現這個功能:當框架程式碼需要判斷當
前執行的是哪一個事務時,只需要從這個 ThreadLocal 物件中讀取事務上下文。這種機制很方便,
因為它避免了每個方法都要傳遞執行上下文資訊,然而這也將使用該機制的程式碼與框架耦合在一起。
@@ ThreadLocal 變數類似於全域性變數,它能降低程式碼的可重用性,並在類之間引入隱含的耦合性,
因此在使用時要格外小心。
》》不變性
@@ 滿足同步需求的另一種方法是使用不可變物件(Immutable Object) 。
@@ 執行緒安全性是不可變物件的固有屬性之一,它們的不變性條件是由建構函式建立的,只要它們
的狀態不改變,那麼這些不變性條件就能得以維持。
@@ 不可變物件一定是執行緒安全的。
@@ 可以安全地共享和釋出不可變物件,而無須建立保護性的副本。
@@ 不可變性並不等於將物件中所有的域都宣告為 final 型別,即使物件中所有的域都是 final
型別的,這個物件也仍然是可變的,因為在 final 型別的域中可以儲存對可變物件的引用。
@@ 當滿足以下條件時,物件才是不可變的:
------------ 物件建立以後其狀態就不能修改。
------------ 物件的所有域都是 final 型別。
------------ 物件是正確建立的(在物件的建立期間, this 引用沒有逸出)。
@@ “ 不可變物件 ” 和 “ 不可變物件的引用 ” 之間存在差異。
### Final 域
@@ final 型別的域是不能修改的(但如果 final 域所引用的物件是可變的,那麼這些被引用的物件
是可以修改的)。
@@ 在 Java 記憶體模型中, final 域還有著特殊的語義:final 域能確保初始化過程的安全性,從而
可以不受限制地訪問不可變物件,並在共享這些物件時無須同步。
@@ 僅包含一個或兩個可變狀態的 “ 基本不可變 ” 物件仍然比包含多個可變狀態的物件簡單。
@@ 正如 “ 除非需要更高的可見性,否則應將所有的域都宣告為私有域 ” 是一個良好的程式設計習慣,
“ 除非需要某個域是可變的, 否則應該將其宣告為 final 域 ” 也是一個良好的程式設計習慣。
### 示例:使用 Volatile 型別來發布不可變物件
@@ 每當需要對一組相關資料以原子方式執行某個操作時,就可以考慮建立一個不可變的類
來包含這些資料。(如下程式碼)
對數值及其因數分解結果進行快取的不可變容器類
------------------------------------------------------------------------------------------------------------------------------------
@Immutable
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 nulll;
}else{
return Arrays.copyOf( lastFactors , lastFactors.length ) ;
}
}
}
---------------------------------------------------------------------------------------------------------------------------------------
@@ 對於在訪問和更新多個相關變數時出現的競爭條件問題,可以通過將這些變數全部儲存在
一個不可變物件中來消除。
如果是一個可變的物件,那麼就必須使用鎖來確保原子性。
@@ 如果是一個不可變物件,那麼當執行緒獲得了對該物件的引用後,就必須擔心另一個執行緒會
修改物件的狀態。
如果要更新這些變數,那麼可以建立一個新的容器物件,但其他使用原有物件的執行緒仍然
會看到物件處於一致的狀態。
@@ 通過使用包含多個狀態變數的容器物件來維持不變性條件,並使用一個 volatile 型別的引用
來確保可見性。
》》安全釋出
### 不正確的釋出:正確的物件被破壞
@@ 如果沒有足夠的同步,那麼當在多個執行緒間共享資料時將發生一些非常奇怪的事情。
### 不可變物件與初始化安全性
@@ 由於不可變物件是一種非常重要的物件,因此 Java 記憶體模型為不可變物件的共享提供了
特殊的初始化安全性保證。
-------- 為了確保物件狀態能呈現出一致的檢視,就必須使用同步。
-------- 即使在釋出不可變物件的引用時沒有使用同步,也仍然可以安全地訪問該物件。
-------- 為了維持上面的初始化安全性的保證,必須滿足不可變性的所有需求:
狀態不可修改,所有的域都是 final 型別, 以及正確的構造過程。
@@ 任何執行緒都可以在不需要額外同步情況下安全地訪問不可變物件,即使在釋出這些物件時
沒有使用同步。
然而,如果 final 型別的域所指向的是可變物件,那麼在訪問這些域所指向的物件的狀態
時然需要同步。
### 安全釋出的常用模式
@@ 可變物件必須通過安全的方式來發布,這通常意味著在釋出和使用該物件的執行緒時都必須
使用同步。
@@ 要安全地釋出一個物件,物件的引用以及物件的狀態必須同時對其他執行緒可見。一個正確
構造的物件可以通過以下方式來安全地釋出:
------------ 在靜態初始化函式中初始化一個物件引用
------------ 將物件的引用儲存在 volatile 型別的域 或者 AtomicReferance 物件中
----------- 將物件的引用儲存在某個正確構造物件的 final 型別域中
----------- 將物件的引用儲存到由鎖保護的域中
@@ 線上程安全容器內部的同步意味著,在將物件放到某個容器。
@@ 儘管 Javadoc 在這個主題上沒有給出很清晰的說明,但執行緒安全庫中的容器類提供了
以下的安全釋出保證:
----------- 通過將一個鍵或者值放入 Hashtable 、 synchronizedMap 或者 ConcurrentMap 中,
可以安全地將它釋出給任何從這些容器中訪問它的執行緒(無論是直接訪問還是通過迭代器
訪問)
----------- 通過將某個元素放入 Vector 、 CopyOnWriterArrayList 、CopyOnWriterArraySet 、
synchronizedList 或者 synchronizedSet 中,可以將元素安全地釋出到任何從這些容器
中訪問該元素的執行緒。
---------- 通過將某個元素放入 BlockingQueue 或者 ConcurrentLinkedQueue 中,可以將該元素
安全地釋出到任何從這些佇列中訪問該元素的執行緒。
@@ 通常,要釋出一個靜態構造的物件,最簡單和最安全的方式是使用靜態的初始化器:
public static Holder holder = new Holder( 42 ) ;
靜態初始化器由 JVM 在類的初始化階段執行。由於在 JVM 內部存在著同步機制,因此通
過這種方式初始化的任何物件都可以被安全地釋出。
### 事實不可變物件
@@ 所有的安全釋出機制都能確保,當物件的引用對所有訪問該物件的執行緒可見時,物件釋出時
的狀態對於所有執行緒也將是可見的,並且如果物件狀態不會再改變,那麼就足以確保任何訪問都是
安全的。
@@ 如果物件從技術上來看是可變的,但其狀態在釋出後不會再改變,那麼把這種物件稱為
“ 事實不可變物件 ” 。 在這些物件釋出後,程式只需將它們視為不可變物件即可。通過使用事實
不可變物件,不僅可以簡化開發過程,而且還能由於減少了同步而提高效能。
@@ 在沒有額外的同步的情況下,任何執行緒都可以安全地使用被髮布的事實不可變物件。
### 可變物件
@@ 對於可變物件,不僅在釋出物件時需要使用同步,而且在每次物件訪問時同樣需要使用
同步來確保後續修改操作的可見性。
要安全地共享可變物件,這些物件就必須被安全地釋出,並在必須執行緒安全的或者由某個
鎖保護起來。
@@ 物件的釋出需求取決於它的可變性:
------------- 不可變物件可以通過任意機制來發布
------------ 事實不可變物件必須通過安全方式來發布
------------ 可變物件必須通過安全方式來發布,並且必須是執行緒安全的或者由某個鎖保護起來
### 安全地共享物件
@@ 當釋出一個物件時,必須明確地說明物件的訪問方式。
@@ 在併發程式中使用和共享物件時,可以使用一些實用的策略,包括:
-------- 執行緒封閉。執行緒封閉的物件只能由一個執行緒擁有,物件被封閉在該執行緒中,並且只能由
這個執行緒修改。
-------- 只讀共享。在沒有額外同步的情況下,共享的只讀物件可以由多個執行緒併發訪問,但任何
執行緒都不能修改它。共享的只讀物件包括不可變物件和事實不可變物件。
------- 執行緒安全共享。執行緒安全的物件在其內部實現同步,因此多個執行緒可以通過物件的公有
介面來進行訪問而不需要進一步的同步。
--------- 保護物件。被保護的物件只能通過持有特定的鎖來訪問。保護物件包括封裝在其他執行緒安全
中的物件,以及已經發布的並且由某個特定的鎖保護的物件。