Java併發程式設計學習記錄#3
共享物件
我們已經見識到同步方法和同步程式碼塊能夠保證操作執行的原子性,但同時這也是一個常見的誤區:同步僅僅關於原子性。其實,同步還有另一個重要而微妙的方面–記憶體可見性。我們不僅僅希望阻止一個執行緒修改另一個執行緒正在使用的物件,我們還希望當一個執行緒修改了某個物件後,其改變後的狀態能夠被其它執行緒觀察到。
可以使用具體的同步或是已經封裝好的類庫,來保證物件改變後,能將狀態安全的釋出出去。
可見性
可見性是個微妙的話題,特別是在未同步的併發情況下,一個例子來說明。
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;
}
}
上述例子的結果有3個,分別解釋下:
- 輸出42,這是最直觀,最容易理解的方式,不解釋;
- 陷入死迴圈,因為ready的值在主執行緒改變之後,未必會發布給其它執行緒;
- 輸出0,這個顯得非常怪異。因為新ready的值可能釋出在number的值修改前,這種現象稱為reording。在一個執行緒中,若是reording內部不能被檢測到的話,那麼操作將不一定按照程式給定的順序執行,即使這個reording能被其它執行緒檢測到。例子中,在主執行緒中,先給number寫值,再給ready寫值這個順序無法在沒有同步的情況下得以保證,也就是說,讀執行緒可能先檢測到ready的值的改變。這可能是一個比較差的設計,但的確能讓JVM充分的的利用現代處理器的硬體。
沒有使用同步時,Java記憶體模型將允許編譯器打亂操作順序並在暫存器裡快取值,也允許CPUs打亂指令順序並在處理器定製快取裡快取值,即,多執行緒環境下,對於未使用同步的程式碼,不能推匯出其真正的執行順序。
上述這麼個簡單小例子就非常容易出錯了,複雜點的更容易出現問題,好在有同步機制,記住,在多執行緒資料共享時,一定要加入合適的同步程式碼。
1.過期資料
上述例子的結果可以簡化描述為:過期資料。它可能出現,也可能不出現,也可能只部分出現:即一個變數是新資料,另一個變數仍是過期資料。過期資料可以造成很多嚴重後果:異常,損壞資料結構,計算錯誤還有死迴圈等。
2.非原子性的64位操作
Java記憶體模型要求讀取和儲存操作必須是原子性的,但是對於非volatile的long型和double型,JVM允許將這些64位的讀寫操作當做兩個單獨的32位操作。如果讀寫操作出現在不同的執行緒中的時候,這就有可能會出現讀取到一個nonvolatile的長整型或者得到高位的32位元或者低位的32位元。這樣即使你不去關心過期資料的問題,在多執行緒環境中使用可變的long和double變數也是不安全的。除非他們被宣告為volatile的或者被鎖守護。
3.鎖和可見性
內部鎖機制可以保證一個執行緒使用一種可以預期的方式看到另外一個執行緒的結果。
在多執行緒中,當訪問一個共享可變變數時,應該給所有的執行緒使用同一個鎖加以同步,這樣可以保證當一個執行緒修改了這個變數時,其它的執行緒將會觀察到最新資料。
鎖,不僅僅關乎互斥操作,它還關乎記憶體可見性,為了保證所有的執行緒能夠看到共享變數的最新狀態,它們應該使用同一個鎖進行同步。
4.Volatile變數
Java語言提供一種可選的,弱化的同步機制-volatile變數,來保證對某個變數的修改以可以預見的形式被其他執行緒獲得。當一個變數被宣告為volatile型別之後,編譯器和執行時環境就會住注意到該變數是被共享的,這樣這個變數之上的操作就不會與其他記憶體操作打亂時序。Volatile變數不會在暫存器中快取也不會在cpu私有的快取中進行快取,因此讀取一個volatile型別的變數將肯定會返回被執行緒修改後的最新值。
依賴於volatile變數來獲取任意狀態可見比使用鎖機制更加更脆弱,也更加難以理解。 所有Volatile變數要慎用。
當代碼邏輯的實現非常簡單,或者驗證你的同步策略的時候,才會用到volatile變數。Volatile變數通常會用來作為競爭、中斷、狀態標記。
鎖既可以保證原子性,也可以保證記憶體可見性;volatile變數只能保證記憶體可見性。
如果程式碼的正確性驗證需要考慮到可見性的時候,不要使用volatile變數。對於volatile變數的正確使用方式包括:
• 對變數的修改並不依賴於變數的當前值,或者你能夠保證只有一個執行緒可以修改該變數值。
• 該變數不會與其他狀態一起參與不變性的維護。
• 在變數被訪問的時候,沒有其他對鎖機制的需求。
物件釋出和逃逸
物件釋出:使得這個物件可以在當前範圍之外的地方使用。常見方式:
- 引用放到一個可以公共訪問的地方,比如public;
- 引用放到一個可以間接被訪問的地方,比如新增到一個集合中;
- 引用作為非私有有方法的返回值提供給外部;
- 引用作為方法的引數,傳遞給其它類;
- 對外提供一個內部類的例項,這個例項將會隱式持有本物件引用。
物件逃逸:一個物件在並未聲明發布時卻被髮布了,就會造成這個物件的逃逸。
物件逃逸的可能場景:
- 聲明瞭一個不想釋出的物件,卻把它放在了對外發布的集合中,外界便可通過遍歷該集合,獲得這個物件;
- 內部類的例項對外發布時,會隱式的攜帶這個物件的引用;
- 執行的執行緒方法中,可能持有所在物件的引用,進一步會造成物件逃逸;
- 建構函式中建立並啟動一個執行緒。
物件逃逸的危害:逃逸的物件總是存在被誤用的風險,無論對其它類或是執行緒中。
物件的安全釋出
一個正確建立的物件,可以通過下列條件正確的釋出:
- 通過靜態初始化器初始化物件的引用;
- 將它的引用儲存在volatile域中或者atomicReference中;
- 將它的引用儲存到正確建立的物件的final域中
- 將它的引用儲存到由鎖正確保護的域中
在併發程式中,使用和共享物件的幾種最佳實踐:
- 執行緒限制:被一個執行緒獨佔,也只能在這個執行緒中修改;
- 共享只讀:任何執行緒都不能修改它;
- 共享執行緒安全:物件自身設定成執行緒安全,其它執行緒可隨意訪問;
- 被守護的物件只能通過獲取特定的鎖來進行訪問和修改。
//待下篇