我曾經寫過一篇文章叫《上帝是如何把宙斯擠下神壇的》,那么上帝在成為唯一的神以后是怎么處理來自凡人的祈禱和愿望呢?忙瘋了的上帝又是如何做到面對這么多凡人的時候不出錯的?
需求故事
- 1.作為一個基督徒ChristianA,可以向god1祈禱“ChristianA want rich”,這樣god1的祈禱清單里面就會有“ChristianA want rich”
- 2.作為一個基督徒ChristianB,可以向god2祈禱“ChristianB want strong”,這樣god2的祈禱清單里就會有“ChristianA want rich”,“ChristianB want strong”,而且god1和god2的實例相同
- 3.作為一個1000個Christian,可以同時向上帝祈禱,要求這1000個祈禱的上帝實例相同。
Story1
作為一個基督徒ChristianA,可以向god1祈禱“ChristianA want rich”,這樣god1的祈禱清單里面就會有“ChristianA want rich”
Story1 Test Case
@Test
public void testTheCrazyGod(){
//story 1
GOD god1 = GOD.getInstance();
god1.recievePray("ChristianA","ChristianA want rich");
assertEquals("ChristianA want rich", god1.getPray("ChristianA"));
}
Story1 Implementation
public class GOD {
private Map<String, String> prayMap = new HashMap();
private GOD() {
}
public static GOD getInstance() {
return new GOD();
}
public void recievePray(String prayer, String prayMessage) {
prayMap.put(prayer, prayMessage);
}
public String getPray(String prayer) {
return prayMap.get(prayer);
}
}
Story2
作為一個基督徒ChristianB,可以向god2祈禱“ChristianB want strong”,這樣god2的祈禱清單里就會有“ChristianA want rich”,“ChristianB want strong”,而且god1和god2的實例相同
Story2 Test Case
在設計Story2的test case時,重要的是如何測試兩個god的實例相同,通過Object.toString(), 可以獲得實例的ID,所以驗證方法就是判斷兩個對象的實例ID是否相同
@Test
public void testTheCrazyGod(){
//story 1
GOD god1 = GOD.getInstance();
String god1Instanceid = god1.toString();
god1.recievePray("ChristianA","ChristianA want rich");
assertEquals("ChristianA want rich", god1.getPray("ChristianA"));
//story 2
GOD god2 = GOD.getInstance();
String god2Instanceid = god2.toString();
god2.recievePray("ChristianB","ChristianB want Strong");
assertEquals("ChristianA want rich", god1.getPray("ChristianA"));
assertEquals("ChristianB want Strong", god2.getPray("ChristianB"));
assertEquals(god1Instanceid,god2Instanceid);
}
Story2 Implementation
public class GOD {
private static GOD singleGod;
private Map<String, String> prayMap = new HashMap();
private GOD() {
}
public static GOD getInstance() {
if (singleGod == null) {
singleGod = new GOD();
}
return singleGod;
}
public void recievePray(String prayer, String prayMessage) {
prayMap.put(prayer, prayMessage);
}
public String getPray(String prayer) {
return prayMap.get(prayer);
}
}
這就是一個最簡單的Singleton模式的實現了,但是這個實現有個最大的問題,那就是不是線程安全的,如果兩個線程同時進入了getInstance方法,那么就可能會創建多個上帝的實例
Story3
作為一個1000個Christian,可以同時向上帝祈禱,要求這1000個祈禱的上帝實例相同。
Story3 Test Case
public void testSafetyGodStory3() {
Set<String> stringSet = new HashSet<String>();
//在測試中創建一個內部類用來跑多線程
class Christian implements Runnable {
@Override
public void run() {
GOD god = GOD.getInstance();
stringSet.add(god.toString());//記錄每個線程獲得的實例ID
}
}
//創建一個當使用線程才創建的線程池
ExecutorService executorService = Executors.newCachedThreadPool();
for (int c = 0; c < 1000; c++) {
//在線程池里創建1000個線程Christian并執行
executorService.execute(new Christian());
}
//如果之前提交的線程任務完成就關閉線程池
executorService.shutdown();
Iterator<String> iterator = stringSet.iterator();
assertTrue(iterator.hasNext());
String firstInstanceId = iterator.next();
while (iterator.hasNext()) {
String instanceId = iterator.next();
assertEquals(firstInstanceId, instanceId);
}
}
Story3 Implementation
Singleton的線程安全問題解決方法一共有5種
既然這個story講到并發和線程安全問題,那么就在這部分干脆深入講講JVM在涉及線程調度時候的內存模型,
用一個singleton模式來徹底理解java的多線程并發和線程安全
JVM內存模型和線程
先說說現代計算機線程機制設計的初衷吧,本質原因其實是運算功能和讀寫功能速度差異導致的,所以聰明的人類使用運籌學的原理來壓榨計算機的運算功能,就是讓CPU在等待讀寫操作的時候不要閑著。
人類里面頂尖聰明的人想出的線程機制其實不僅僅是提高了計算機的功效,我們普通人更應該從這些頂尖聰明的人的想法里進行學習:在日常工作生活中,當我們遭遇了因為無法抗拒的因素導致的等待時,比如等待飛機,我們是不是可以新開啟一個線程在等待的同時進行其他的運算呢?從我個人的經驗來說,這種啟動新線程的思維方法是一個技能,而且這個技能是用的越多就越熟練效率越高的技能。
Java內存模型和線程關系圖
![]()
每個新創建的線程都會分布一個獨立的工作內存,而這些工作內存是和主內存打交道的,所以在多線程并發的情況下最重要和最核心的問題就是緩存一致性問題,也就是在Story3里面定義的線程安全問題。所以我們先要了解一下Java Memory Model的8個基本操作是什么:
- lock:只能用在主內存變量上,他把一個主內存變量標記成一條線程獨占的狀態
- unlock:只能用在主內存變量上,一個線程釋放主內存變量后其他線程才能夠鎖定
- read:把一個主內存變量從主內存讀到線程的工作內存,以便load使用
- load:把read操作的變量值放入工作內存的變量副本中
- use:線程從工作內存的變量副本獲得變量值進行運算
- assign:線程把預算結果賦值給工作內存的變量
- store:把工作內存的變量值傳輸給主內存,以便write使用
- write:把從store操作中的變量值寫入主內存
從上面的8個操作我們可以看到read和load,store和write必須是一起出現的,JMM不允許這4個指令單獨出現。但是(這個但是很重要),雖然要求兩個操作是一起的,卻可以在兩個操作之間插入其他操作,舉例來說就是可以是這樣的執行順序:read A,read B,load A,load B
有關java內存模型和線程的基礎操作介紹完了,下面我們用5個不同的Singleton線程安全實現來具體分析一下吧:
線程安全方法1:Eager方法
public class GOD {
private static final GOD singleGod = new GOD();
private Map<String, String> prayMap = new HashMap();
private GOD() {
try {
Thread.sleep(5);//模擬創建對象時間較長,可以啟動另外一個線程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static GOD getInstance() {
return singleGod;
}
}
這個實現方式其實和上面提到的java內存模型就沒什么關系了,因為被定義成static final的singleGod會在jvm啟動調用ClassLoader來加載GOD類的時候就會完成實例的準備,所以稱之為Eager模式,這樣做的缺點是開銷比較大,在沒有調用到getInstance的時候JVM就已經創建好了singleGod的實例了。
這里剛好涉及到了類加載的問題,那么讓我們再深入一點,看看一個類的生命周期究竟是什么樣的,這樣也可以幫助我們更好的理解final,static這些修飾符是怎么工作的。
Java Class生命周期圖
![]()
這里先要明確一個概念,我們在平常說到類加載其實是包含了上圖中的“加載”,“鏈接”,“初始化”3個步驟的。而上圖是官方定義一個類各個階段的命名。
- loading,在loading階段JVM需要完成3件事:
+ 根據類名讀類的二進制字節流,
+ 把字節流轉化成存儲結構,
+ 在內存中生成一個代表這個類的java.lang.Class的對象。
在我們的Test Case中,當第一次調用到GOD.getInstance()時,JVM就會啟動loading動作去讀GOD.class的二進制流
- linking-Verification,在verification階段JVM需要進行4個驗證:
+ 文件格式驗證,確定字節流是符合Class規范的
+ 元數據驗證,確定描述信息是符合java規范的
+ 字節碼驗證,確定程序語意是合法的
+ 符號引用驗證,確保Resolution能夠正常執行
在我們的Test Case中,JVM讀完了GOD.class的二進制字節流就會啟動Verification動作
- linking-Preparation,為類變量分配內存并設置類變量初始值的過程,所謂的類變量就是被Static,通常情況下初始值都是變量的默認零值,但是如果類變量被final修飾過,那么就會執行賦值命令
在我們的Test Case中,singleGod就是一個類變量,并在preparation階段初始化成new GOD()
private static final GOD singleGod = new GOD();
而沒有final修飾的類變量的區別就是賦值命令不會在Preparation階段執行,而是在Initialization階段執行,這時候singleGod還是null
- linking-Resolution,JVM把常量池內的符號引用替換為直接引用的過程
+ 符號引用(SymbolicReference),用符號來描述引用目標,引用的目標不一定加載到JVM中
+ 直接引用(DirectReference),引用的目標對象已經加載到了JVM中
在我們的Test Case中, Resolution階段就是發現GOD需要Map,然后會把Map的全名給GOD的ClassLoader去加載Map,然后把Map從符號引用變為直接引用
private Map<String, String> prayMap = new HashMap();
- Initialization,這個階段主要就是執行類構造器<clinit>()的過程,這里有幾個有意思的特點需要注意:
+ **\<clinit\>()是類構造器,不同于實例構造器,這點尤為重要**
+ 由于\<clinit\>()就是把所有static修飾的變量和語句塊進行順序執行,所以語句塊的順序很重要(就好比是JavaScript這種解釋性語言的執行方式)
+ 如果有父類,那么父類的\<clinit\>()會先執行父類的\<clinit\>(),所以父類的靜態變量賦值優先于子類的靜態變量賦值,**這也是靜態變量不能被子類改寫的根本原因**
+ 如果沒有static修飾的變量和代碼,那么編譯器可以不生成\<clinit\>()方法
- Using,當初始化完成之后,java虛擬機就可以執行Class的業務邏輯指令,通過堆中java.lang.Class對象的入口地址,調用方法區的方法邏輯,最后將方法的運算結果通過方法返回地址存放到方法區或堆中。
- Unloading,在類使用完之后,如果滿足下面3個條件全部滿足的情況,類就會被卸載:
+ 該類所有的實例都已經被回收,也就是java堆中不存在該類的任何實例
+ 加載該類的ClassLoader已經被回收
+ 該類對應的java.lang.Class對象沒有任何地方被引用,無法在任何地方通過反射訪問該類的方法。
線程安全方法2:Lazy方法
public class GOD {
private static GOD singleGod;
private GOD() {
try {
Thread.sleep(5);//模擬創建對象時間較長,可以啟動另外一個線程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static synchronized GOD getInstance() {
if (singleGod == null) {
singleGod = new GOD();
}
return singleGod;
}
}
所謂Lazy就是等到真正調用到getInstance的時候再去創建實例,這是和Eager方法相對的。
采用 synchronized 修飾符實現的同步機制叫做互斥鎖機制,它所獲得的鎖叫做互斥鎖。每個對象都有一個 monitor (鎖標記),當線程擁有這個鎖標記時才能訪問這個資源,沒有鎖標記便進入鎖池。任何一個對象系統都會為其創建一個互斥鎖,這個鎖是為了分配給線程的,防止打斷原子操作。每個對象的鎖只能分配給一個線程,因此叫做互斥鎖。
這里既然談到了那么我們就研究的再深入一點,看看JVM是怎么實現synchronized的吧。
Synchronized的實現
1.先說說synchronized的歷史
synchronized在JDK5之前一直被稱為重量級鎖,是一個較為雞肋的設計,而在JDK6對synchronized內在機制進行了大量顯著的優化,加入了CAS,輕量級鎖和偏向鎖的功能,性能上提升很多,所以如果僅僅是為了實現互斥,那么可以優先考慮synchronized。
2.CAS Compare and Swap,
這事一個用于在硬件層面上提供原子性操作。在 Intel 處理器中,CAS通過匯編指令cmpxchg實現。比較是否和給定的數值一致,如果一致則修改,不一致則不修改。那么這個硬件特性就很適合用在鎖上面了。用一個更具體的例子來說,通常將 CAS 用于同步的方式是從地址 V 讀取值 A,執行多步計算來獲得新值 B,然后使用 CAS 將 V 的值從 A 改為 B。如果 V 處的值尚未同時更改,則 CAS 操作成功。
3.java中synchronized可以用在2個地方:
- 用在方法上,鎖的是當前實例對象,實現方法是編譯時,方法的常量池中多了ACC_SYNCHRONIZED標示符,當線程調用方法時,會檢查ACC_SYNCHRONIZED是否設置,如果設置了,那么線程會獲取對應monitor,獲取成功了才能執行方法體。
- 如果方法是普通方法,那么monitor是對象實例上的鎖,
如果方法是靜態方法,那么monitor是類上的鎖
![]()
- 用在代碼塊上,鎖的是括號里的對象,實現方式是在代碼編譯的時候給代碼塊前后增加monitorenter和monitorexit
![]()
- 線程執行monitorenter的詳細過程:[1]如果monitor的進入數為0,則該線程進入monitor,然后將進入數設置為1,該線程即為monitor的所有者.[2]如果線程已經占有該monitor,只是重新進入,則進入monitor的進入數加1.[3]如果其他線程已經占用了monitor,則該線程進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權
- 執行monitorexit的線程必須是對應monitor的所有者,指令執行時,monitor的進入數減1,如果減1后進入數為0,那線程退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權。
4.這個坑是越挖越深了,那我們來看看monitor吧。
當多線程同時訪問一段同步代碼時,新請求的線程會首先加入到Entry Set集合中,通過競爭(compete)方式,同一時間只有一個線程可以競爭成功并獲取監視器,進入The Owner。獲取監視器的線程調用wait()后就會釋放監視器,并進入Wait Set集合中等待滿足條件時被喚醒。下面這個圖可以幫助更容易的理解這個過程
![]()
5.Java SE1.6里鎖的四種狀態
Java SE1.6為了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,所以鎖有四種狀態:無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態。它會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖后不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。
鎖存在Java對象頭里。如果對象是數組類型,則虛擬機用3個Word(字寬)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛擬機中,一字寬等于四字節,即32bit。Java對象頭里的Mark Word里默認存儲對象的HashCode,分代年齡和鎖標記位。在運行期間Mark Word里存儲的數據會隨著鎖標志位的變化而變化。Mark Word可能變化為存儲以下4種數據:
![]()
- 偏向鎖,Hotspot的作者經過以往的研究發現大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖,而只需簡單的測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖,如果測試成功,表示線程已經獲得了鎖,如果測試失敗,則需要再測試下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖),如果沒有設置,則使用CAS競爭鎖,如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程,所以CAS指令大大提高了鎖的效率。偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。
![]()
- 輕量級鎖
- 輕量級鎖加鎖:線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
輕量級鎖解鎖:輕量級解鎖時,會使用原子的CAS操作來將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。
![]()
因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處于這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之后會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭
鎖的優缺點對比:
鎖 優點 缺點 場景 偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距。 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗。 適用于只有一個線程訪問同步塊場景。 輕量級鎖 競爭的線程不會阻塞,提高了程序的響應速度。 如果始終得不到鎖競爭的線程使用自旋會消耗CPU。 追求響應時間。同步塊執行速度非常快。 重量級鎖 線程競爭不使用自旋,不會消耗CPU。 線程阻塞,響應時間緩慢。 追求吞吐量。同步塊執行速度較長。 ![]()
有關java中synchronized的實現原理就先講到這里了,我們知道在java中另外一個用來實現線程安全的關鍵字就是volatile了,下面這個線程安全的實現就是使用了volatile來達到的
線程安全方法3:DCL(double check lock)
public class GOD {
private volatile static GOD singleGod ;
private GOD() {
try {
Thread.sleep(5);//模擬創建對象時間較長,可以啟動另外一個線程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static GOD getInstance() {
if(singleGod==null){
synchronized (GOD.class){
if(singleGod==null){
singleGod=new GOD();
}
}
}
return singleGod;
}
}
這個實現里面先用volatile來聲明了singleGod,這樣就是對其他線程可見的了。然后在getInstance方法里面使用DCL(Double Check Lock)機制來進行雙重鎖檢查:
- 一次是在同步塊外,同步塊外的檢查是為了節省時間,如果實例已經存在就不需要進入同步塊了。
- 一次是在同步塊內,為什么在同步塊內還要再檢驗一次?因為可能會有多個線程一起進入同步塊外的檢查,如果在同步塊內不進行二次檢驗的話就會生成多個實例了。
以上就是一個使用DCL來實現線程安全singleton的標準方法了,使用了volatile,還使用了synchronized,還有DCL。我們已經知道了synchronized的實現原理是在代碼塊編譯的時候前后加鎖判斷,那么這時候問題來了,既然已經又了代碼塊的鎖,為什么還要使用volatile呢,所以下面我們就深入看看volatile的實現和一個更有意思的東西:重排序
Volatile的實現和重排序
1.Volatile基礎作用
Java 語言規范中指出:為了獲得最佳速度,允許線程保存共享成員變量的私有拷貝,而且只當線程進入或者離開同步代碼塊時才將私有拷貝與共享內存中的原始值進行比較。
而Volatile 修飾的成員變量在每次被線程訪問時,都強迫從共享內存中重讀該成員變量的值。而且,當成員變量發生變化時,強迫線程將變化值回寫到共享內存。這樣在任何時刻,兩個不同的線程總是看到某個成員變量的同一個值。
這樣當多個線程同時與某個對象交互時,就必須注意到要讓線程及時的得到共享成員變量的變化。而 volatile 關鍵字就是提示 JVM:對于這個成員變量,不能保存它的私有拷貝,而應直接與共享成員變量交互。volatile 是一種稍弱的同步機制,在訪問 volatile 變量時不會執行加鎖操作,也就不會執行線程阻塞,因此 volatilei 變量是一種比 synchronized 關鍵字更輕量級的同步機制。2.Volatile實現原理
那么Volatile是如何來保證可見性的呢?在x86處理器下通過工具獲取JIT編譯器生成的匯編指令來看看對Volatile進行寫操作CPU會做什么事情。
java代碼:instance = new Singleton();//instance是volatile變量 匯編代碼: 0x01a3de1d: movb $0x0,0x1104800(%esi); 0x01a3de24: lock addl $0x0,(%esp);
有volatile變量修飾的共享變量進行寫操作的時候會多第二行匯編代碼,通過查IA-32架構軟件開發者手冊可知,lock前綴的指令在多線程下會引發了兩件事情,讓我們再回顧一下java內存模型來看看這兩件事情的含義是什么
- Lock指令會將當前線程工作內存的數據會寫回(Store-Write)到主內存,lock指令執行期間會鎖定緩存,阻止其他線程同時修改被鎖定的區域數據
- 這個寫回主內存的操作會引起在其他線程工作內存緩存了該內存地址的數據無效,CPU有嗅探技術,其他線程通過嗅探發現緩存區域的主內存被修改了,那么就會無效緩存區域,并在下次讀取的時候強制從主內存讀取(load read)
![]()
3.有了Volatile為什么還要Synchronized?
這時候有個問題來了:那么在我們的線程安全方法3的實現里面,對singleGod加volatile應該就可以實現在寫singleGod的時候讓其他的線程緩存無效了,為什么還要加上synchronized呢?(在網上看了很多文章都是講為什么synchronized了以后還要加volatile的,沒有一個提出上面這個問題,所以這個問題絕對是本文原創)
這個問題的答案是:volatile聲明的singleGod僅僅保證了在執行singleGod = new GOD();
時是使用了lock指令鎖定了singleGod,但是不會保證只有一個線程進入if (singleGod == null)
, 所以會有多個線程先后使用lock指令來給singleGod賦值。這里也能看出Volatile和Synchronized的重要區別是Volatile是針對變量的,Synchronized是真對方法和代碼塊的。
4.有了Synchronized為什么還要Volatile?
看到網上有人對這個問題的解釋是Volatile可以讓變量對所有線程可見,其實想想Synchronized的原理都發現是不對的,這個塊都上鎖了,那么這塊代碼自然是對線程可見的啊。所以加Volatile是有別的原因的,這個原因就是防止JVM對指令的重排序,那么什么是指令重排序呢?
5.什么是指令重排序?
讓我們再看看這行代碼
singleGod=new GOD();
這行代碼其實做了3件事情:
- 給singleGod分配內存
- 調用GOD的構造函數來初始化成員變量
- 把singleGod指向分配的內存空間(執行完這步singleGod就是非null了)
JVM在編譯的時候會對上面三個指令進行重排序優化,而優化后的順序是不能保證的。因為在單線程條件下1-3-2這種順序也是沒有任何問題的。
但是我們想象一下多線程下的情況:
- 線程A執行順序是1-3-2,這時候執行完了1-3,singleGod已經是非null了,這時候還沒有執行2就被線程B搶占了
- 線程B搶進來一看,singleGod已經不是null,那么就不需要執行
singleGod=new GOD();
了,但這樣B得到的是一個沒有調用構造函數初始化的對象,僅僅有分配好的空內存空間而加入了volatile就會保證如果線程A變量的寫操作沒有完成,線程B的工作內存緩存是被設置成無效的,線程B如果要讀變量,必須從主內存讀取,也就是不論執行順序是1-2-3,還是1-3-2,線程B都沒法插隊。所以volatile定義的變量是遵循了Happen-Before規則的。那我們再多走一步,聊聊什么是happen-before吧
6.什么是Happen-Before呢
Java語言中有一個“先行發生”(Happen-Before)的規則,它是Java內存模型中定義的兩項操作之間的偏序關系,如果操作A先行發生于操作B,其意思就是說,在發生操作B之前,操作A產生的影響都能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等,它與時間上的先后發生基本沒有太大關系。這個原則特別重要,它是判斷數據是否存在競爭、線程是否安全的主要依據。
舉例來說,存在3個線程:線程A中執行如下操作:i=1 線程B中執行如下操作:j=i 線程C中執行如下操作:i=2
假設線程A中的操作”i=1“ Happen-Before線程B中的操作“j=i”,那么就可以保證在線程B的操作執行后,變量j的值一定為1,即線程B觀察到了線程A中操作“i=1”所產生的影響;現在,我們依然保持線程A和線程B之間的Happen-Before關系,同時線程C出現在了線程A和線程B的操作之間,但是C與B并沒有Happen-Before關系,那么j的值就不確定了,線程C對變量i的影響可能會被線程B觀察到,也可能不會,這時線程B就存在讀取到不是最新數據的風險,不具備線程安全性。
Java內存模型中的有八條可保證Happen-Before的規則,他們無需任何同步器協助就已經存在,如果編譯器判斷指令不存在Happen-Before規則,那么就會隨機的進行重排序,volatile就是其中一條對一個volatile變量的寫操作happen—before后面對該變量的讀操作
,所以在我們DCL實現里面,沒有用volatile修飾的singleGod的3個指令就是被重排序了。至于其余的7條,如果有興趣可以自己找來看看,這里就不再增加閱讀負擔了。
在方法3的實現里面我們深入講了一下volatile的作用和實現原理,但是用DCL這種方式做線程安全的Lazy模式也還是有些復雜了,有沒有更簡單的方式呢?
線程安全方法4:static nested class
public class GOD {
private static class GODHolder{
private static final GOD singleGod = new GOD();
}
private GOD() {
try {
Thread.sleep(5);//模擬創建對象時間較長,可以啟動另外一個線程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static synchronized GOD getInstance() {
return GODHolder.singleGod;
}
}
這個實現是不是很簡單,也是《Effective Java》上面推薦的,看起來和Eager方法一樣是利用了final static的方式在類加載階段就完成了實例的創建,但是它卻是Lazy模式的。
因為只有當調用到GOD.getInstance時才會去調用GODHolder,這時候才會啟動ClassLoader去加載GODHolder,那么在這個加載的過程中就會創建好singleGod,所以這個創建和寫入內存的過程是根本不擔心多線程的。那么為什么JVM可以做到這樣呢?原因是java在編譯GOD時,會把GOD編譯成GOD.class
和GOD$GODHolder.class
兩個二進制文件,那么在Christian調用到GOD.getInstance實際的執行順序如下:
- 找到
GOD.class
進行GOD的加載,連接和初始化- 進入GOD.getInstance方法
- 因為需要GODHolder,所以找到
GOD$GODHolder.class
進行加載,連接和初始化。在連接時會創建一個GOD,賦值給singleGod- GODHolder返回已經初始化的singleGod
線程安全方法5:enum
public enum GOD {
INSTANCE;
}
這個實現是不是更簡單,而且也是可以通過多線程測試的,因為enum默認就是線程安全的。那enum又是怎么實現的呢?
Java enum的實現原理
其實看enum的實現方法很簡單,就是使用javap來看一下class文件的字節碼就好了,從下面的字節碼文件里我們可以看到其實enum是通過繼承java.lang.Enum來實現的一個final類,而且定義的INSTANCE是final static的,而且會在static{}區塊對INSTANCE進行初始化。因此enum實現的Singleton模式是Eager的,會在JVM加載enum的時候就初始化好,那么自然是線程安全的了。所以enum并沒有在JVM底層數據結構上有任何改變,而是通過對關鍵字的封裝可以讓程序員更方便的定義一些final類來使用,并自動添加了values和valueOf方法
javap -c dp.singleton.GOD4Enum
Compiled from "GOD4Enum.java"
public final class dp.singleton.GOD4Enum extends java.lang.Enum<dp.singleton.GOD4Enum> {
public static final dp.singleton.GOD4Enum INSTANCE;
public static dp.singleton.GOD4Enum[] values();
Code:
0: getstatic #1 // Field $VALUES:[Ldp/singleton/GOD4Enum;
3: invokevirtual #2 // Method "[Ldp/singleton/GOD4Enum;".clone:()Ljava/lang/Object;
6: checkcast #3 // class "[Ldp/singleton/GOD4Enum;"
9: areturn
public static dp.singleton.GOD4Enum valueOf(java.lang.String);
Code:
0: ldc #4 // class dp/singleton/GOD4Enum
2: aload_0
3: invokestatic #5 // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
6: checkcast #4 // class dp/singleton/GOD4Enum
9: areturn
static {};
Code:
0: new #4 // class dp/singleton/GOD4Enum
3: dup
4: ldc #7 // String INSTANCE
6: iconst_0
7: invokespecial #8 // Method "<init>":(Ljava/lang/String;I)V
10: putstatic #9 // Field INSTANCE:Ldp/singleton/GOD4Enum;
13: iconst_1
14: anewarray #4 // class dp/singleton/GOD4Enum
17: dup
18: iconst_0
19: getstatic #9 // Field INSTANCE:Ldp/singleton/GOD4Enum;
22: aastore
23: putstatic #1 // Field $VALUES:[Ldp/singleton/GOD4Enum;
26: return
}
和堅思辨
本來singleton模式是一個非常簡單的模式,但是在考慮了多線程安全以后我們確能夠提供5種不同的實現方式:
- Eager方法:利用static final關鍵字
- Lazy方法:利用synchronized關鍵字
- DCL方法:利用volatile關鍵字
- Nested Class方法:利用static nested class來實現
- enum方法:利用enum方法
以上5種方法不存在絕對的優劣之分,需要根據場景來進行合適的選擇。
但是在介紹5個方法的時候我覺得更有價值的是也順帶了解了一下每種實現方法背后的本質是什么,而且通過這種打破砂鍋問道底的方法能夠讓我們感受到Java這些機制創造者們的智慧和嚴謹:
- Java內存模型是什么樣子的,多線程是如何在這個模型里面工作的
- 通過深入了解static final關鍵字知道了Java的類加載過程是什么,從一個類被加載,使用,到最后卸載每一步都干了什么
- 通過深入了解synchronized關鍵字知道了synchronized是怎么工作的,還有java1.6以后的3種鎖是怎么工作的
- 通過深入了解volatile關鍵知道了volatile是怎么工作的,什么是指令重排序,什么是Happen-Before原則
- 通過查看Nested Class文件明白了為什么這個方法可以做到lazy的線程安全
- 通過查看enum的class字節碼,我們知道了enum的底層實現是什么,又為什么可以做到線程安全