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並發編程實戰》
java多線程2.線程安全之可見性