Java多執行緒程式設計筆記10:單例模式

mortal同學發表於2018-12-13

立即載入:“餓漢模式”

立即載入就是指使用類的時候已經將物件建立完畢,常見的實現方法就是直接new例項化。也就是在呼叫方法前,例項就被建立了。示例程式碼如下所示:

class MyObject {
    private static MyObject myObject=new MyObject();
    private MyObject(){}
    public static MyObject getInstance(){
        //如果還有其他程式碼,存線上程安全問題
        return myObject;
    }
}
class MyThread extends Thread{
    @Override
    public void run() {
        System.out.println(MyObject.getInstance().hashCode());
    }
}
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();
    }
}
複製程式碼

執行結果如下:

58615885
58615885
58615885
複製程式碼

可以發現,實現了單例模式,因為多個執行緒得到的例項的hashCode是一樣的。

延遲載入:“懶漢模式”

延遲載入就是在呼叫getInstance()方法時例項才被建立,常見的方法就是在getInstance()方法中進行new例項化。實現程式碼如下:

class MyObject {
    private static MyObject myObject;
    private MyObject(){}
    public static MyObject getInstance(){
        if(myObject==null){ //A執行緒執行
            myObject=new MyObject();//B執行緒執行
        }
        return myObject;
    }
}
class MyThread extends Thread{
    @Override
    public void run() {
        System.out.println(MyObject.getInstance().hashCode());
    }
}

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();
    }
}
複製程式碼

但是由於在getInstance()中,存在多條語句,因此可能存線上程安全問題。執行結果也顯示了這一點:

2041531420
1348345633
1348345633
複製程式碼

甚至,當getInstance()中,有更多的語句,會出現不同的三個物件,在if(myObject==null)語句塊中加入Thread.sleep(3000),執行結果如下所示:

218620763
58615885
712355351
複製程式碼

DCL

如果使用synchronized關鍵字,對整個getInstance()上鎖或者對整個if語句塊加鎖,會存在效率問題。

最終採用了DCL(Double-Check Locking)雙檢查鎖機制,也是大多數多執行緒結合單例模式使用的解決方案。第一層主要是為了避免不必要的同步,第二層判斷則是為了在null情況下才建立例項。

public class MyObject {
    private static MyObject myObject;

    private MyObject() {
    }

    public static MyObject getInstance() {
        if (myObject == null) {//第一次檢查
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (MyObject.class) {//加鎖
                if (myObject == null) {//第二次檢查
                    myObject = new MyObject();//建立物件
                }
            }
        }
        return myObject;
    }
}
複製程式碼

測試結果,得到的是相同的hashcode。

上述雙重檢查鎖定的方法從表面上看來達到了如下的效果:

  • 多個執行緒試圖在同一時間建立物件時,通過加鎖保證只有一個執行緒能建立物件
  • 在物件建立好之後,執行 getInstance() 方法將不需要獲取鎖,直接返回已經建立好的物件 建立物件可以分解為 分配記憶體;初始化;設定myObject指向的記憶體地址。但是由於重排序,可能導致先分配地址再進行初始化。因此,線上程A設定好了記憶體空間,還未初始化時,執行緒B判斷不為空,將訪問該物件,得到了一個還未初始化的物件。

解決辦法有兩種:

  • 不允許初始化和設定變數指向的記憶體地址兩步重排序(使用volatile)
  • 允許這兩步重排序,但是不允許其他執行緒看到這個重排序

基於volatile的解決方案

public class MyObject {
    private volatile static MyObject myObject;

    private MyObject() {
    }

    public static MyObject getInstance() {
        if (myObject == null) {//第一次檢查
            synchronized (MyObject.class) {//加鎖
                if (myObject == null) {//第二次檢查
                    myObject = new MyObject();//建立物件
                }
            }
        }
        return myObject;
    }
}
複製程式碼

由於instance是volatile修飾的,初始化和設定記憶體地址在多執行緒環境中將被禁止重排序。

基於類初始化的解決方案(靜態內建類)

public class MyObject{
    private static class MyObjectHandler{
        private static MyObject myObject=new MyObject();
    }

    private MyObject() {
    }

    public static MyObject getInstance() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return MyObjectHandler.myObject;
    }
} 
複製程式碼

採用靜態內建類的方法,是執行緒安全的。JVM在類的初始化階段,會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖,可以同步多個執行緒對同一個類的初始化。雖然可能重排序,但是其他執行緒是無法看到這個過程的。

類的初始化過程:

  1. 通過在 Class 物件上同步(獲取 Class 物件的初始化鎖),控制類或介面的初始化。這個獲取鎖的執行緒會一直等待,直到當前執行緒能獲得這個初始化鎖。
  2. 執行緒A執行類的初始化,執行緒B在初始化鎖對應的 condition 上等待。
  3. 執行緒A設定state=initialized,然後喚醒在 condition 中等待的所有執行緒。
  4. 其他執行緒獲取到初始化鎖,讀取到state,釋放該鎖,然後類初始化處理過程完成。

使用static程式碼塊

靜態程式碼塊的程式碼再使用類的時候就已經執行了,所以可以應用靜態程式碼塊的這個特性來實現單例設計模式。

public class MyObject {
    private static MyObject myObject=null;

    static{myObject=new MyObject();}

    private MyObject() {
    }

    public static MyObject getInstance() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return myObject;
    }
}
複製程式碼

使用enum列舉資料型別

使用列舉類時,和靜態程式碼塊的特性相似,構造方法會被自動呼叫。列舉在經過javac的編譯之後,會被轉換成形如public final class T extends Enum的定義。也就是說,我們定義的一個列舉,在第一次被真正用到的時候,會被虛擬機器載入並初始化,而這個初始化過程是執行緒安全的。而我們知道,解決單例的併發問題,主要解決的就是初始化過程中的執行緒安全問題。

所以,由於列舉的以上特性,列舉實現的單例是天生執行緒安全的。同時,列舉可解決反序列化會破壞單例的問題。

enum MyObject{
    INSTANCE;
}
複製程式碼

SimpleDataFormat

SimpleDataFormat使用了單例模式,具有執行緒安全問題。SimpleDateFormat中的日期格式不是同步的。推薦(建議)為每個執行緒建立獨立的格式例項。如果多個執行緒同時訪問一個格式,則它必須保持外部同步。

解決方案1:需要的時候建立新例項

public class DateUtil {
    
    public static  String formatDate(Date date)throws ParseException{
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.format(date);
    }
    
    public static Date parse(String strDate) throws ParseException{
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.parse(strDate);
    }
}

複製程式碼

在需要用到SimpleDateFormat 的地方新建一個例項,不管什麼時候,將有執行緒安全問題的物件由共享變為區域性私有都能避免多執行緒問題,不過也加重了建立物件的負擔。在一般情況下,這樣其實對效能影響比不是很明顯的。

解決方案2:同步SimpleDateFormat物件

public class DateSyncUtil {

    private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
      
    public static String formatDate(Date date)throws ParseException{
        synchronized(sdf){
            return sdf.format(date);
        }  
    }
    
    public static Date parse(String strDate) throws ParseException{
        synchronized(sdf){
            return sdf.parse(strDate);
        }
    } 
}
複製程式碼

當執行緒較多時,當一個執行緒呼叫該方法時,其他想要呼叫此方法的執行緒就要block,多執行緒併發量大的時候會對效能有一定的影響。

解決方案3:使用ThreadLocal

public class ConcurrentDateUtil {

    private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() {
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };

    public static Date parse(String dateStr) throws ParseException {
        return threadLocal.get().parse(dateStr);
    }

    public static String format(Date date) {
        return threadLocal.get().format(date);
    }
}
複製程式碼

使用ThreadLocal, 也是將共享變數變為獨享,執行緒獨享肯定能比方法獨享在併發環境中能減少不少建立物件的開銷。如果對效能要求比較高的情況下,一般推薦使用這種方法。

相關文章