java多執行緒2.執行緒安全之可見性
要編寫正確的併發程式,關鍵在於:在訪問共享的可變狀態時需要進行正確的管理
可見性: 同步的另一個重要目的——記憶體可見性。
我們不僅希望防止某個執行緒正在使用物件狀態而另一個執行緒同時在修改狀態,而且希望當一個執行緒修改了物件狀態後,其他執行緒能夠看到發生的狀態變化(互斥訪問/通訊效果)
- 問題
/** * 主執行緒和讀執行緒都訪問共享變數ready和number,主執行緒啟動讀執行緒,然後將number設定為42, * 並將ready設為true.讀執行緒一直迴圈直到發現ready的值變為true,然後輸出number的值。 * 但是程式碼中沒有使用同步機制,因此無法保證主執行緒寫入的ready值和number對於讀執行緒是可見的, * 看起來會輸出42,但很可能輸出0或者根本無法終止。 * * 因為在沒有同步的情況下,編譯器、處理器以及執行時等都可能對操作的執行順序進行一些意想不到的調整。 * 在缺乏足夠同步的多執行緒程式中,要想對記憶體操作的執行順序進行判斷,幾乎無法得出正確的結論。*/ public class Demo { 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; } }
- 非原子的64位操作
以上展示了在缺乏同步的程式中可能產生錯誤結果的一種情況:失效資料。
當讀執行緒檢視ready時,可能會得到一個已經失效的值,除非在每次訪問時都使用同步。更糟的情況是,一個執行緒獲得變數的最新值,而另一個執行緒獲得變數的失效值。
當執行緒在沒有同步的情況下讀取變數時,可能會得到一個失效值,但至少這個值是由之前某個執行緒設定的值。這種安全性保證也被稱為最低安全性。
最低安全性適用於絕大多數變數,但是存在一個例外:非volatile型別的64位數值變數(double和long)
java記憶體模型要求,變數的讀取操作和寫入操作都必須是原子操作,但對於非volatile型別的long和double變數,jvm允許將64位的讀操作或寫操作分解為兩個32位的操作,就是說即使不考慮失效資料的問題,在多執行緒程式中使用共享可變的long和double等型別變數也是不安全的,除非用關鍵字volatile來宣告它們,或者用鎖保護起來。
- 加鎖與可見性
內建鎖可以用於確保某個執行緒以一種可預測的方式來檢視另一個執行緒的執行結果。
當執行緒A執行某個同步程式碼塊時,執行緒B隨後進入由同一個鎖保護的同步程式碼塊,在這種情況下可以保證,在鎖被釋放之前,A看到的變數值在B獲得鎖後同樣可以由B看到
這樣可以進一步理解為什麼在訪問某個共享且可變的變數時要求所有執行緒在同一個鎖上同步,就是為了確保某個執行緒寫入該變數的值對於其他執行緒來說都是可見的。
- volatile變數
當把變數宣告為volatile型別後,編譯器與執行時都會注意到這個變數時共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。
volatile變數不會被快取在暫存器或者對其他處理器不可見的地方,因此讀取volatile型別的變數時總會返回最新寫入的值。
儘管volatile變數也可以用於表示其他的狀態資訊,但在使用時要非常小心。
例如,volatile的語義不足以確保遞增操作++count的原子操作,除非你能確保只有一個執行緒對變數執行寫操作。
正確的使用volatile變數需要滿足以下條件:
- 對變數的寫入操作不依賴變數的當前值(讀-改-寫),或者你能確保只有單個執行緒更新變數的值。
- 該變數不會與其他狀態變數一起納入不變性條件中。
- 訪問變數時不需要加鎖。
- 釋出與逸出
釋出一個物件是指:使物件能夠在當前作用域之外的程式碼中使用。
如將一個指向該物件的引用儲存到其他程式碼可以訪問的地方,或者在某一個非私有的方法中返回該引用,又或者將引用傳遞到其他類的方法中。
- 釋出
釋出物件最簡單的方法是將物件的引用儲存到一個公有的靜態變數中,當釋出某個物件時,可能會間接的釋出其他物件。如示例中將一個Secret物件新增到集合中,那麼同樣會釋出這個物件,因為任何程式碼都可以遍歷這個集合。
public static Set<Secret> knownSecrets; public void init(){ knownSecrets = new HashSet<Secret>(); }
- 逸出
當釋出一個物件時,在該物件的非私有域中引用的所有物件同樣會被髮布。即如果一個已經發布的物件能夠通過非私有的變數引用和方法呼叫到達其他的物件,那麼這些物件也都會被髮布。
示例中釋出states會出現問題,因為任何呼叫者都能修改這個陣列內的內容。陣列states已經逸出了它所在的作用域,因為這個本應是私有的變數已經被髮布了。
private String[] states = new String[]{"ak","al"}; public String[] getStates(){ return states; }
- this逸出
示例中,當Demo釋出EventListener時,也隱含地釋出了Demo例項本身,因為在這個內部類的例項中包含了對Demo例項的隱含引用this。當且僅當物件的建構函式返回時,物件才處於可預測的和一致的狀態,因此,當從物件的建構函式中釋出物件時,只是釋出了一個尚未構造完成的物件。如果this引用在構造過程中逸出,那麼這種物件就被認為是不正確構造。如果實際中這種情況不可避免,可以嘗試在建構函式中初始化volatile變數(未驗證),因為volatile變數可以避免指令重排序。
public class Demo{ public Demo(EventSource source){ source.registerListener(){ new EventListener(){ public void onEvent(Event e){ doSomething(e); } }; } } }
- 避免物件在構造過程中逸出
如果想在建構函式註冊一個事件監聽器或啟動執行緒,那麼可以使用一個私有的建構函式和一個公共的工廠方法,從而避免不正確的構造過程。其實在建構函式中建立執行緒並沒有錯誤,但最好不要立即啟動它,而是通過一個start或者initialize方法來啟動。
public class Demo{ private final EventListener listener; private Demo(){ listener = new EventListener(){ public void onEvent(Event e){ dosomething(e); } }; } public static Demo getInstance(EventSource source){ Demo demo = new Demo(); source.registerListener(demo.listener); return demo; } }
- 執行緒封閉:
顯然如果僅在單執行緒內訪問資料,就不需要同步,稱之為執行緒封閉,它是實現執行緒安全性的最簡單方式之一
例如:
swing的視覺化元件和資料模型物件都不算執行緒安全的,swing通過將他們封閉到swing的事件分發執行緒中來實現執行緒安全性。
JDBC規範並不要求Connection物件必須是執行緒安全的。在典型的伺服器應用程式中,執行緒從連線池中獲得一個Connection物件,並且用該物件來處理請求,使用完後返回連線池。
大多數請求如Servlet或EJB呼叫等都是由當個執行緒採用同步的方式來處理,並且在Connection物件返回之前,連線池不會再將它分配給其他執行緒。
在java語言中並沒有強制規定某個變數必須由鎖來保護,也無法強制物件封閉在某個執行緒中。執行緒封閉是在程式設計中的一個考慮因素,必須在程式中實現。
java語言及其核心庫提供了一些機制來維護執行緒封閉性,如區域性變數和ThreadLocal類。
- Ad-hoc執行緒封閉:指維護執行緒封閉性的職責完全由程式實現來承擔。
例如在volatile變數上存在一種特殊的執行緒封閉。只要你能確保只有單個執行緒對共享的volatile變數執行寫入操作,那麼就可以完全地在這些共享的volatile變數上執行‘讀-改-寫’。
- 棧封閉:區域性變數固有的屬性之一就是封閉線上程之中。
但是在維護物件引用的棧封閉時,需要多做一些操作以確保被引用的物件不會逸出。
- ThreadLocal:使執行緒中的某個值與儲存值的物件關聯起來。
ThreadLocal 提供了get與set訪問介面或方法,這些方法為每個使用該變數的執行緒都存有一份獨立的副本,因此get總是返回由當前執行執行緒在呼叫set時設定的最新值。
ThreadLocal物件通常用於防止對可變的單例項變數或全域性變數進行共享。
例如,在單執行緒應用程式中可能會維持一個全域性的資料庫連線,並在程式啟動時初始化這個連線物件,從而避免在呼叫每個方法時都用傳遞一個Connection物件。
由於JDBC的連線物件不一定是執行緒安全的。因此,當多執行緒應用程式在沒有協同的情況下使用全域性變數時,就不是執行緒安全的。而通過將JDBC的連線儲存到ThreadLocal物件中,每個執行緒都會擁有屬於自己的連線。
當某個執行緒初次呼叫ThreadLocal.get()方法時,就會呼叫init()來獲取初始值。
你可以將ThreadLocal<T>視為包含了Map<Thread,T>物件,其中儲存了特定於該執行緒的值,但ThreadLocal並非如此。這些特定於執行緒的值儲存在Thread物件中,當執行緒終止後,這些值會作為垃圾回收。
當某個頻繁執行的操作需要一個臨時物件,而同時又希望避免在每次執行時都重新分配該臨時物件,就可以使用這種方法
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){ @Override public Connection initialValue(){ try { return DriverManager.getConnection("DB_URL"); } catch (SQLException e) { e.printStackTrace(); return null; } } }; public static Connection getConnection(){ return connectionHolder.get(); }
- final域
不可變物件一定是執行緒安全的
在“不可變的物件”與“不可變的物件引用”之間存在著差異。儲存在不可變物件中的物件狀態仍然可以更新,即通過將一個儲存新狀態的例項來“替換”原有的不可變物件。fianl型別的域是不能修改的,但如果final域所引用的物件是可變的,那麼這些被引用的物件是可以修改的
在java記憶體模型中,final域有著特殊的語義。final域能確保初始化過程的安全性,因此可以不受限制的訪問不可變物件,並在共享這些物件時無須同步
對於訪問和更新多個相關變量出現的競態條件時,可以通過將這些變數全部儲存在一個不可變的物件中來消除。如果是一個可變的變數就必須使用鎖來確保原子性。而如果是一個不可變物件,那麼當執行緒獲得了該物件的引用後,就不必擔心另一個執行緒會修改物件的狀態,如果要更新這些變數,那麼可以建立一個新的容器物件,其他使用原有物件的執行緒仍然會看到物件處於一致的狀態。再將這個物件設定成volatile,那麼當一個執行緒更新了物件狀態時,其他執行緒就會立即看到新快取的資料。
這樣利用final以及volatie,在沒有顯示地使用鎖的情況下仍然保證物件是執行緒安全的。
- 示例
// 執行因式分解,快取結果,並通過判斷快取中的數值是否等於請求數值來決定是否直接讀取快取的因式分解結果。 public class Demo{ private volatile Cache cache = new Cache(null,null); public BigInteger[] service(BigInteger param){ BigInteger[] factors = cache.getFactors(param); if(factors == null){ factors = factor(param); cache = new Cache(param,factors); } } } class Cache{ private final BigInteger lastNumber; private final BigInteger[] lastFactors; public Cache(BigInteger i,BigInteger[] factors){ lastNumber = i; lastFactors = factors; } public BigInteger[] getFactors(BigInteger i){ if(lastNumber == null || !lastNumber.equals(i)){ return null; }else{ return Arrays.copyOf(lastFactors, lastFactors.length); } } }
- 安全釋出
即使某個物件的引用對其他執行緒是可見的,也並不意味著物件狀態對於使用該物件的執行緒來說一定是可見的。
可變物件必須通過安全的方式來發布,意味著在釋出和使用該物件時都必須使用同步來確保物件狀態呈現一致性。因為物件的初始化無法得到保證,因為除了釋出物件的執行緒外,其他執行緒可以看到尚未完全建立的物件以及物件包含域的失效值。
而釋出不可變物件的引用時,不使用同步仍然可以安全的訪問該物件。為了維持這種初始化安全性的保證,必須滿足不可變的條件:狀態不可修改,所有域都是final型別,以及正確的構造過程。然而如果final型別的域所指向的是可變物件,那麼在訪問這些域所指向的物件的狀態時仍然需要同步。
安全釋出常用模式:
- 在靜態初始化函式中初始化一個物件引用。
- 將物件的引用儲存到volatile型別的域或者AtomicReferance物件中。
- 將物件的引用儲存某個正確構造的物件的final型別域中。
- 將物件的引用儲存到一個由鎖保護的域中。
線上程安全容器內部的同步意味著,在將物件放到某個容器,將滿足條件4。執行緒T1將物件A放入一個執行緒安全的容器,隨後執行緒T2讀取這個物件,那麼不再需要額外的同步。
通常要釋出一個靜態構造的物件,最簡單和最安全的方式是使用靜態的初始化器:條件1
public static Holeder holder = new Holder(4);
靜態初始化器由JVM在類的初始化階段執行。由於JVN內部存在著同步機制(類初始化後,引用物件已經初始化完成),因此通過這種方式初始化的任何物件都可以被安全釋出。
如果物件在釋出後不會被修改,那麼對於其他在沒有額外同步的情況下安全地訪問這些物件的執行緒來說,安全釋出是足夠的。而對於可變物件,需要在釋出時以及在每次物件訪問時都使用同步來確保後續修改操作的可見性。
#筆記內容來自《java併發程式設計實戰》