一、前言
如何使單例模式遇到多線程是安全的、正確的?
我們在學習設計模式的時候知道單例模式有懶漢式和餓漢式之分。簡單來說,餓漢式就是在使用類的時候已經將對象創建完畢,懶漢式就是在真正調用的時候進行實例化操作。
二、餓漢式+多線程
單例:
public class MyObject {
//餓漢模式
private static MyObject myObject=new MyObject();
private MyObject(){
}
public static MyObject getInstance(){
return myObject;
}
}
自定義線程:
public class MyThread extends Thread {
@Override
public void run(){
System.out.println(MyObject.getInstance().hashCode());
}
}
main方法:
public class Run {
public static void main(String[] args) {
MyThread t1=new MyThread();
MyThread t2=new MyThread();
MyThread t3=new MyThread();
t1.start();
t2.start();
t3.start();
}
}
結果:
hashCode是同一個值,說明對象是同一個。也就是說餓漢式單例模式在多線程環境下是線程安全的。
三、懶漢式+多線程
方案一:
單例:
public class MyObject {
private static MyObject myObject;
/*私有構造函數避免被實例化*/
private MyObject(){
}
public static MyObject getInstance(){
try {
if (myObject==null) {
//模擬在創建對象之前做的一些準備性工作
Thread.sleep(3000);
myObject=new MyObject();
}
} catch (Exception e) {
e.printStackTrace();
}
return myObject;
}
}
自定義線程:
public class MyThread extends Thread {
@Override
public void run(){
System.out.println(MyObject.getInstance().hashCode());
}
}
main方法:
public class Run {
public static void main(String[] args) {
MyThread t1=new MyThread();
MyThread t2=new MyThread();
MyThread t3=new MyThread();
t1.start();
t2.start();
t3.start();
}
}
結果:
3種hashCode,說明創建出了3個對象,並不是單例的。懶漢模式在多線程環境下是“非線程安全”。這是爲何?
因爲創建實例對象的那部分代碼沒有加synchronized或Lock。三個線程都進入了創建實例對象的代碼段getInstance。
方案二:synchronized同步方法
既然多個線程可以同時進入getInstance()方法,那麼只需要對getInstance()方法聲明synchronized關鍵字即可。在MyObject的getInstance()方法前加synchronized關鍵字。最終打印的三個hashcode是一樣一樣的。實現了多線程環境下,懶漢模式的正確性、安全性。但是此種方法的運行效率非常低下,因爲是同步的,一個線程釋放鎖之後,下一個線程繼續執行。
方案三:synchronized同步代碼塊
同步方法是對方法整體加鎖,效率不高,我們可以通過減少鎖的粒度,也就是使用synchronized同步代碼塊。如下面代碼所示:
public class MyObject {
private static MyObject myObject;
/*私有構造函數避免被實例化*/
private MyObject(){
}
/*synchronized*/
public static MyObject getInstance(){
try {
synchronized (MyObject.class) {
if (myObject==null) {
//模擬在創建對象之前做的一些準備性工作
Thread.sleep(3000);
myObject=new MyObject();
}
}
} catch (Exception e) {
e.printStackTrace();
}
return myObject;
}
}
這樣做能保證最終運行結果正確,但getInstance方法中的全部代碼都是同步的了,這樣做會降低運行效率,和對getInstance方法加synchronized的效率幾乎一樣。
方案四:重要代碼同步代碼塊
public class MyObject {
private static MyObject myObject;
/*私有構造函數避免被實例化*/
private MyObject(){
}
/*synchronized*/
public static MyObject getInstance(){
try {
if (myObject==null) {
//模擬在創建對象之前做的一些準備性工作
Thread.sleep(3000);
synchronized (MyObject.class) {
myObject=new MyObject();
}
}
} catch (Exception e) {
e.printStackTrace();
}
return myObject;
}
}
結果:
這種做法在多線程環境下還是無法解決得到同一個實例對象的結果。
方案五:雙重鎖定
package singleton_3;
public class MyObject {
private static MyObject myObject;
/*私有構造函數避免被實例化*/
private MyObject(){
}
//使用雙重鎖定(Double-Check Locking)解決問題,既保證了不需要同步代碼的異步執行性,
//又保證了單例的效果
public static MyObject getInstance(){
try {
if (myObject==null) {
//模擬在創建對象之前做一些準備性的工作
Thread.sleep(3000);
synchronized(MyObject.class){
if (myObject==null) {
myObject=new MyObject();
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
return myObject;
}
}
使用雙重鎖定功能,成功地解決了在多線程環境下“懶漢模式”的“非線程安全”問題。
那麼爲什麼外面已經判斷myObject實例是否存在,爲什麼在lock裏面還需要做一次myObject實例是否存在的判斷呢?
如果myObject已經存在,則直接返回,這沒有問題。當Instance爲null,並且同時有3個線程調用GetInstance()方法時,它們都可以通過第一重myObject==null的判斷,然後由於lock機制,這三個線程只有一個進入,另外2個在外排隊等候,必須第一個線程走完同步代碼塊之後,第二個線程才進入同步代碼塊,此時判斷instance==null,爲false,直接返回myObject實例。就不會再創建新的實例啦。第二個監測myObject==null一定要在同步代碼塊中。
方案六:
方案五表面上來看,在執行該代碼時,先判斷instance對象是否爲空,爲空時再進行初始化對象。即使是在多線程環境下,因爲使用了synchronized鎖進行代碼同步,該方法也僅僅創建一個實例對象。但是,從根本上來說,這樣寫還是存在一定問題的。 問題源頭:
創建對象:1.創建對象時限分配內存空間-----》2.初始化對象-----》3.設置對象指向內存空間-----》4.初次訪問對象;
2和3可能存在重排序問題,由於單線程中遵守intra-thread semantics,從而能保證即使2和3交換順序後其最終結果不變。但是當在多線程情況下,線程B將看到一個還沒有被初始化的對象,此時將會出現問題。
解決方案:
1、不允許②和③進行重排序
2、允許②和③進行重排序,但排序之後,不允許其他線程看到。
基於volatile的解決方案
對前面的雙重鎖實現的延遲初始化方案進行如下修改:
public class MyObject {
private volatile static MyObject myObject;
/* 私有構造函數避免被實例化 */
private MyObject() {
}
/* synchronized */
public static MyObject getInstance() {
try {
if (myObject == null) {
// 模擬在創建對象之前做的一些準備性工作
Thread.sleep(3000);
synchronized (MyObject.class) {
if (myObject == null) {
myObject = new MyObject(); // 用volatile修飾,不會再出現重排序
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
return myObject;
}
}
使用volatile修飾instance之後,之前的②和③之間的重排序將在多線程環境下被禁止,從而保證了線程安全執行。
注意:這個解決方案需要JDK5或更高版本(因爲從JDK5開始使用新的JSR-133內存模型規範,這個規範增強了volatile的語義)
基於類初始化的解決方案
JVM在類的初始化階段(即在Class被加載後,且被線程使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。基於這個特性,可以實現另一種線程安全的延遲初始化方案。
public class MyObject {
private static MyObject myObject;
/*私有構造函數避免被實例化*/
private MyObject(){
}
//靜態內部類方式
private static class MyObjectHandler{
private static MyObject myObject=new MyObject();
}
public static MyObject getInstance(){
return MyObjectHandler.myObject;
}
}
結果可行。
使用靜態代碼塊實現單例模式
public class MyObject {
private static MyObject instance;
/*私有構造函數避免被實例化*/
private MyObject(){
}
static{
instance=new MyObject();
}
public static MyObject getInstance(){
return instance;
}
}
結果可行。 該方案的實質是,允許②和③進行重排序,但不允許非構造線程(此處是B線程)“看到”這個重排序。
四、總結
單例模式分爲懶漢式和餓漢式,餓漢式在多線程環境下是線程安全的;懶漢式在多線程環境下 是“非線程安全”的,可以通過synchronized同步方法和“雙重檢測”機制來保證懶漢式在多線程環境下的線程安全性。靜態內部類實現單例模式和靜態代碼塊從廣義上說都是餓漢式的。