多執行緒下指令重排與DCL單列模式
1、JMM記憶體模型三大特性包括原子性,可見性,有序性。詳細請看https://juejin.im/post/5cb5d419e51d456e500f7d02。
2、指令重排是相對有序性來說的,指在程式執行過程中, 為了效能考慮, 編譯器和CPU可能會對指令重新排序。單執行緒模式下只有一個執行引擎,不存在競爭,所有的操作都是有有序的,不影響最後的執行結果。
3、指令重排只能保證序列(單執行緒)語句執行的一致性。
單例模式
假設我的單列物件是Faith(一個人只有一個信仰),檢視多執行緒下示例的建立次數,即建構函式的呼叫次數。
餓漢模式
示例程式碼
class Faith { private static Faith myFaith = new Faith(); private Faith(){ System.out.println("Faith.Faith --- 私有構造呼叫了"); } public static Faith getMyFaith() { return myFaith; } } public class TestSingleton { public static void main(String[] args) { for (int i = 0; i <= 10; i++) { new Thread(() -> { Faith.getMyFaith(); },String.valueOf(i)).start(); } } } 複製程式碼
控制檯:
Faith.Faith --- 私有構造呼叫了 複製程式碼
- 多條執行緒同時執行時,只建立了一個例項。
- 餓漢模式下,在類載入的時候建立一次例項,不會存在多個執行緒建立多個例項的情況。但在類載入時就自動建立,佔用記憶體。
- 因此重點講懶漢模式,即第一次呼叫獲取實列方法時,才被動建立物件。
懶漢模式
單執行緒懶漢模式
示例程式碼
class Faith { private static Faith myFaith = null; private Faith(){ System.out.println(Thread.currentThread().getName()+" --- Faith.Faith --- 私有構造呼叫了"); } public static Faith getMyFaith() { if (myFaith == null){ myFaith =new Faith(); } return myFaith; } } 複製程式碼
上面的程式碼是單執行緒下的懶漢模式,但是在併發情況下,當myFaith為空,需new物件時,多個執行緒可能同時進入這個方法。
public class TestSingleton { public static void main(String[] args) { for (int i = 0; i <= 10; i++) { new Thread(() -> { Faith.getMyFaith(); },String.valueOf(i)).start(); } } } 複製程式碼
控制檯:
5 --- Faith.Faith --- 私有構造呼叫了 1 --- Faith.Faith --- 私有構造呼叫了 8 --- Faith.Faith --- 私有構造呼叫了 4 --- Faith.Faith --- 私有構造呼叫了 2 --- Faith.Faith --- 私有構造呼叫了 3 --- Faith.Faith --- 私有構造呼叫了 9 --- Faith.Faith --- 私有構造呼叫了 7 --- Faith.Faith --- 私有構造呼叫了 10 --- Faith.Faith --- 私有構造呼叫了 0 --- Faith.Faith --- 私有構造呼叫了 6 --- Faith.Faith --- 私有構造呼叫了 複製程式碼
可以看到,結果非常糟糕,得到多個不同物件。
多執行緒懶漢模式-synchronized
最直接的方法就是在靜態方法上加synchronized
互斥鎖.
public static synchronized Faith getMyFaith() { if (myFaith == null){ myFaith =new Faith(); } return myFaith; } 複製程式碼
synchronized屬於重量鎖,在高併發情況下,上百條個執行緒都等在靜態方法外,阻塞很大,不推薦。
多執行緒懶漢模式-DCL
DCL(double check lock)雙端檢索機制,在new方法上加同步鎖,但要在加鎖前後進行非空判斷。
class Faith { private static Faith myFaith = null; private Faith(){ System.out.println(Thread.currentThread().getName()+" --- Faith.Faith --- 私有構造呼叫了"); } public static Faith getMyFaith() { // 第一次判斷,若myFaith例項為空 if (myFaith == null){ // 加同步鎖 synchronized (Faith.class) { // 第二次判斷,若myFaith例項確實為空,進入構造方法 if (myFaith == null) { myFaith = new Faith(); } } } return myFaith; } } 複製程式碼
public class TestSingleton { public static void main(String[] args) { for (int i = 0; i <= 10; i++) { new Thread(() -> { Faith.getMyFaith(); },String.valueOf(i)).start(); } } } 複製程式碼
控制檯:
0 --- Faith.Faith --- 私有構造呼叫了 複製程式碼
- 可以看到,10條執行緒下,只獲取到一個實列物件,看似是一個相對高效的方法。但在本文一開始,就提到了指令重排。
- 當myFaith為空,進入初始化,當還沒初始化完成時,會有執行緒安全問題。
指令重排分析
myFaith = new Faith();,該方法其實有3步:
1、分配記憶體空間何記憶體地址
memeory = allocate; 複製程式碼
2、初始化物件
myFaith(memory); 複製程式碼
3、將例項指向分配的記憶體地址
myFaith = memory; 複製程式碼
第二步和第三步沒有資料依賴關係,單執行緒下指令重排不影響執行結果,因此編譯器和cpu允許重排優化的行為。
即可能出現第三步先於第二部執行, myFaith = memory; 此時因為已經給即將 建立的myFaith分配了記憶體空間,所以myFaith!=null,但物件的初始化還沒有完成,造成執行緒安全問題。
多執行緒懶漢模式-DCL+volatile
JMM保證有序性的重要方法就是引入J.U.C併發包下的volatile
關鍵字,volatile 關鍵字通過新增記憶體屏障
的方式來禁止指令重排,即重排序時不能把後面的指令放到記憶體屏障之前。
即原來的DCL單例模式,在例項物件上再加volatile修飾即可。
private static volatile Faith myFaith = null; 複製程式碼