雙重檢查鎖(DCL)問題

問題來源
以」懶漢式「單例模式爲例(思想就是延遲高開銷對象的初始化操做),代碼以下。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會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取變量。

時序圖爲:

 

相關文章
相關標籤/搜索