問題來源
以」懶漢式「單例模式爲例(思想就是延遲高開銷對象的初始化操做),代碼以下。java
這是一個普通的POJO:安全
package myTest; import java.time.LocalDateTime; /** * <p>Title: DCLTestBean</p> * <p>Description: </p> * @author xiayuxuanmin * @date 2019年7月8日 */ public class DCLTestBean { private String username; private Integer password; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public Integer getPassword() { return password; } public void setPassword(Integer password) { this.password = password; } public DCLTestBean() { System.out.println(Thread.currentThread().getName()+"~~~~~"+LocalDateTime.now()+" |DCLTestBean初始化了。"); } }
」懶漢式「單例:多線程
package myTest; /** * <p>Title: TestDCLDemo3</p> * <p>Description: </p> * @author xiayuxuanmin * @date 2019年7月8日 */ public class DCLDemo3 { private static DCLTestBean instance; public static DCLTestBean getInstance(){ if (instance==null){ //第一行 instance = new DCLTestBean(); //第二行 } return instance; } }
這個單例模式明顯是線程不安全的,當多個線程調用時,他們可能都試圖同時建立對象,或者可能最終得到對未徹底初始化對象的引用。能夠簡單測試一下:併發
package myTest; import java.util.concurrent.CountDownLatch; /** * <p>Title: TestDCLDemo3</p> * <p>Description: </p> * @author xiayuxuanmin * @date 2019年7月8日 */ public class TestDCLDemo3 { private static int count = 10; private static CountDownLatch countDownLatch = new CountDownLatch(count); private static DCLTestBean instance; public static DCLTestBean getInstance() { if(instance == null) { instance = new DCLTestBean(); } return instance; } public static void main(String[] args) { for(int i = 0;i < count;i++) { new Thread(()-> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } getInstance(); },"thread-"+i).start(); countDownLatch.countDown(); } } }
執行結果:ide
thread-6~~~~~2019-07-08T09:45:34.604 |DCLTestBean初始化了。 thread-7~~~~~2019-07-08T09:45:34.605 |DCLTestBean初始化了。 thread-9~~~~~2019-07-08T09:45:34.604 |DCLTestBean初始化了。 thread-1~~~~~2019-07-08T09:45:34.606 |DCLTestBean初始化了。 thread-4~~~~~2019-07-08T09:45:34.605 |DCLTestBean初始化了。 thread-8~~~~~2019-07-08T09:45:34.606 |DCLTestBean初始化了。 thread-0~~~~~2019-07-08T09:45:34.605 |DCLTestBean初始化了。 thread-5~~~~~2019-07-08T09:45:34.605 |DCLTestBean初始化了。 thread-3~~~~~2019-07-08T09:45:34.605 |DCLTestBean初始化了。 thread-2~~~~~2019-07-08T09:45:34.605 |DCLTestBean初始化了。
DCLTestBean初始化實例屢次,這明顯就違背了單例。性能
解決方法也很簡單,直接加上synchronized便可:測試
package myTest; import java.util.concurrent.CountDownLatch; /** * <p>Title: TestDCLDemo3</p> * <p>Description: </p> * @author xiayuxuanmin * @date 2019年7月8日 */ public class TestDCLDemo3 { private static int count = 10; private static CountDownLatch countDownLatch = new CountDownLatch(count); private static DCLTestBean instance; public static synchronized DCLTestBean getInstance() { if(instance == null) { instance = new DCLTestBean(); } return instance; } public static void main(String[] args) { for(int i = 0;i < count;i++) { new Thread(()-> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } getInstance(); },"thread-"+i).start(); countDownLatch.countDown(); } } }
執行結果:優化
thread-1~~~~~2019-07-08T09:50:21.112 |DCLTestBean初始化了。
可是在JDK1.6以前,synchronized屬於重量級鎖,若是getInstance()方法被多個線程頻繁的調用,將會致使程序執行性能的降低。即只有在第一次調用getInstance()時將建立對象,而且在此期間只有少數嘗試訪問它的線程須要同步; 以後全部調用只得到對成員變量的引用。因爲同步方法在某些極端狀況下能夠將性能下降100倍或更高,每次調用此方法時獲取和釋放鎖的開銷彷佛都是沒必要要的:一旦初始化完成,獲取並釋放鎖彷佛不必,因而下列方式優化這種狀況:this
檢查變量是否已初始化(未得到鎖定)。若是已初始化,請當即返回。
得到鎖定。
仔細檢查變量是否已經初始化:若是另外一個線程首先得到了鎖,它可能已經完成了初始化。若是是,則返回初始化變量。
不然,初始化並返回變量。
即雙重檢查鎖,也就是DCL。spa
雙重檢查鎖(DCL)
package myTest; /** * <p>Title: TestDCLDemo3</p> * <p>Description: </p> * @author xiayuxuanmin * @date 2019年7月8日 */ public class DCLDemo3 { private static DCLTestBean instance; public static DCLTestBean getInstance(){ if (instance==null){ //第一次檢查 synchronized (DCLDemo3.class) {//同步鎖 if(instance==null) { //第二次檢查 instance = new DCLTestBean(); } } } return instance; } }
如上面代碼所示,DCL本質上也就是減小了鎖粒度,若是第一次檢查instance不爲null,那麼就不須要執行下面的加鎖和初始化操做。所以,能夠大幅下降synchronized帶來的性能開銷。上面代碼表面上看起來,彷佛一箭雙鵰。多個線程試圖在同一時間建立對象時,會經過加鎖來保證只有一個線程能建立對象。在對象建立好以後,執行getInstance()方法將不須要獲取鎖,直接返回已建立好的對象。雙重檢查鎖定看起來彷佛很完美,但這是一個錯誤的優化!當線程進行第一次檢查的時候,代碼讀取到instance不爲null時,instance引用的對象有可能尚未完成初始化。
DCL問題分析
在上面代碼中:
instance = new DCLTestBean();
這段代碼能夠分爲以下三行僞代碼:
memory = allocate(); //1.分配對象內存空間 ctorInstance(memory); //2.在內存空間初始化對象 instance = memory; //3.設置instance指向剛分配的內存地址
上面3行僞代碼中的2和3之間,可能會被重排序(在一些JIT編譯器上,這種重排序是真實發生的)。
memory = allocate(); //1.分配對象內存空間 instance = memory; //3.設置instance指向剛分配的內存地址 ctorInstance(memory); //2.在內存空間初始化對象
以前介紹過(https://blog.csdn.net/Dongguabai/article/details/82290776),指令重排序能夠保證串行語義一致,可是沒有義務保證多線程間的語義也一致。也就是說上面3行僞代碼的2和3之間雖然被重排序了,可是是不影響串行語義的。可是在多線程併發執行的狀況就可能出現:
也就是說一個線程可能讀到還沒有初始化的DCLTestBean,而這個instance的確是!=null的。
能夠用過一段代碼來「模擬」下這種狀況:
package myTest; import java.util.concurrent.TimeUnit; /** * <p>Title: DCLDemo</p> * <p>Description: </p> * @author xiayuxuanmin * @date 2019年7月8日 */ public class DCLDemo { private static volatile DCLTestBean instance; public static DCLTestBean getInstance() throws InterruptedException { System.out.println(Thread.currentThread().getName()+"-----等待執行同步方法"); if (instance == null) { synchronized (DCLDemo.class) { System.out.println(Thread.currentThread().getName()+"進入同步方法"); if (instance == null) { instance = new DCLTestBean(); System.out.println(Thread.currentThread().getName()+"new 了"); //當前線程睡眠3秒 //該數值要大於main方法中的sleep,保證thread3在啓動的時候,已經執行過new方法的線程正在睡眠狀態 TimeUnit.SECONDS.sleep(3); instance.setPassword(123); instance.setUsername("zhangsan"); } } } return instance; } public static void main(String[] args) throws InterruptedException { Runnable runnable = new Runnable() { @Override public void run() { try { DCLTestBean instance = DCLDemo.getInstance(); System.out.println(Thread.currentThread().getName()+"^^^^^"+instance.getUsername().toString()); } catch (InterruptedException e) { e.printStackTrace(); } } }; Thread thread1 = new Thread(runnable); Thread thread2 = new Thread(runnable); Thread thread3 = new Thread(runnable); thread1.start(); thread2.start(); //該代碼的做用是讓thread3延遲啓動2秒(保證thread3在進入getInstance() 方法以後,在判斷instance == null的時候,thread1 或者thread2已經執行過new()方法,而且執行new()方法的線程正處於睡眠狀態) Thread.sleep(2000); thread3.start(); } }
運行結果爲:
Thread-1-----等待執行同步方法 Thread-0-----等待執行同步方法 Thread-1進入同步方法 Thread-1~~~~~2019-07-08T10:00:40.881 |DCLTestBean初始化了。 Thread-1new 了 Thread-2-----等待執行同步方法 Exception in thread "Thread-2" java.lang.NullPointerException //出現空指針異常 at myTest.DCLDemo$1.run(DCLDemo.java:39) at java.lang.Thread.run(Thread.java:748) Thread-1^^^^^zhangsan Thread-0進入同步方法 Thread-0^^^^^zhangsan
在知曉了問題發生的根源以後,咱們能夠想出兩個辦法來實現線程安全的延遲初始化。
1)不容許2和3重排序(在JDK 1.5後能夠基於volatile來解決);
2)容許2和3重排序,但不容許其餘線程「看到」這個重排序(可使用靜態內部類解決);
基於volatile的解決方案
很簡單,只須要添加volatile關鍵字便可:
package myTest; /** * <p>Title: TestDCLDemo3</p> * <p>Description: </p> * @author xiayuxuanmin * @date 2019年7月8日 */ public class DCLDemo3 { private static volatile DCLTestBean instance;//這裏加上volatile public static DCLTestBean getInstance(){ if (instance==null){ //第一次檢查 synchronized (DCLDemo3.class) {//同步鎖 if(instance==null) { //第二次檢查 instance = new DCLTestBean(); } } } return instance; } }
這個解決方案須要JDK 5或更高版本(由於從JDK 5開始使用新的JSR-133內存模型規範,這個規範加強了volatile的語義)。
當聲明對象的引用爲volatile後,下圖中的3行僞代碼中的2和3之間的重排序,在多線程環境中將會被禁止。當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取變量。
時序圖爲: