設計模式-單例模式?10種不一樣的艷遇 讓你精通單例

1.引言

單例設計模式(Singleton Pattern)是最簡單且常見的設計模式之一,在它的核心結構中只包含一個被稱為單例的特殊類。通過單例模式可以保證系統中一個類只有一個實例而且該實例易于外界訪問,從而方便對實例個數的控制并節約系統資源。如果希望在系統中某個類的對象只能存在一個,避免多實例對象的情況下引起邏輯性錯誤(實例化數量可控)單例模式是最好的解決方案。

  • 1、單例類只能有一個實例。
  • 2、單例類必須自己創建自己的唯一實例。
  • 3、單例類必須給所有其他對象提供這一實例。

2.概述

Java中,單例模式主要分四種:懶漢式單例、餓漢式單例、登記式單例、ThreadLocal單例模式四種

  • 懶漢:非線程安全,需要用一定的風騷操作控制,裝逼失敗有可能導致看一周的海綿寶寶
  • 餓漢:天生線程安全,ClassLoad的時候就已經實例化好,該操作過于風騷會造成資源浪費
  • 單例注冊表:Spring初始化Bean的時候,默認單例用的就是該方式
  • 單例模式有餓漢模式、懶漢模式、靜態內部類、枚舉等方式實現,這些模式的構造方法是私有的,不可繼承
  • 登記式單例 使得單例對繼承開放
  • ThreadLocal 是線程副本形式,可以保證局部單例,即在各自的線程中是單例的,但是線程與線程之間不保證單例。

\color{#4285f4}{1.特點}

1. 私有構造方法,只能有一個實例。
2. 私有靜態引用指向自己實例,必須是自己在內部創建的唯一實例。
3. 單例類給其它對象提供的都是自己創建的唯一實例

\color{#4285f4}{2.案例}

1. 在計算機系統中,內存、線程、CPU等使用情況都可以再任務管理器中看到,但始終只能打開一個任務管理器,它在Windows操作系統中是具備唯一性的,因為彈多個框多次采集數據浪費性能不說,采集數據存在誤差那就有點逗比了不是么…
2. 每臺電腦只有一個打印機后臺處理程序
3. 線程池的設計一般也是采用單例模式,方便對池中的線程進行控制

\color{#4285f4}{3.注意事項}

 1. 實現方式種類較多,有的非線程安全方式的創建需要特別注意,且在使用的時候盡量根據場景選取較優的,線程安全了還需要去考慮性能問題。
 2. 不適用于變化的對象,如果同一類型的對象總是要在不同的用例場景發生變化,單例就會引起數據的錯誤,不能保存彼此的狀態。
 3. 沒有抽象層,擴展有困難。
 4. 職責過重,在一定程度上違背了單一職責原則。
 5. 使用時不能用反射模式創建單例,否則會實例化一個新的對象

3.開啟艷遇

\color{#9932CC}{第一種艷遇:}\color{#34a853}{單一檢查(懶漢)非線程安全}

public class LazyLoadBalancer {

    private static LazyLoadBalancer loadBalancer;
    private List<String> servers = null;

    private LazyLoadBalancer() {
        servers = new ArrayList<>();
    }

    public void addServer(String server) {
        servers.add(server);
    }

    public String getServer() {
        Random random = new Random();
        int i = random.nextInt(servers.size());
        return servers.get(i);
    }

    public static LazyLoadBalancer getInstance() {
        // 第一步:假設T1,T2兩個線程同時進來且滿足 loadBalancer == null
        if (loadBalancer == null) {
            // 第二步:那么 loadBalancer 即會被實例化2次
            loadBalancer = new LazyLoadBalancer();
        }
        return loadBalancer;
    }

    public static void main(String[] args) {
        LazyLoadBalancer balancer1 = LazyLoadBalancer.getInstance();
        LazyLoadBalancer balancer2 = LazyLoadBalancer.getInstance();
        System.out.println("hashCode:"+balancer1.hashCode());
        System.out.println("hashCode:"+balancer2.hashCode());
        balancer1.addServer("Server 1");
        balancer2.addServer("Server 2");
        IntStream.range(0, 5).forEach(i -> System.out.println("轉發至:" + balancer1.getServer()));
    }
}

分析:在單線程環境一切正常,balancer1balancer2兩個對象的hashCode一模一樣,由此可以判斷出堆棧中只有一份內容,不過該代碼塊中存在線程安全隱患,因為缺乏競爭條件,多線程環境資源競爭的時候就顯得不太樂觀了,請看上文代碼注釋內容

\color{#9932CC}{第二種艷遇:}\color{#34a853}{無腦上鎖(懶漢)線程安全,性能較差,第一種升級版}

public synchronized static LazyLoadBalancer getInstance() {
    if (loadBalancer == null) {
        loadBalancer = new LazyLoadBalancer();
    }
    return loadBalancer;
}

分析: 毫無疑問,知道synchronized關鍵字的都知道,同步方法在鎖沒釋放之前,其它線程都在排隊候著呢,想不安全都不行啊,但在安全的同時,性能方面就顯得短板了,我就初始化一次,你丫的每次來都上個鎖,不累的嗎(沒關系,它是為了第三種做鋪墊的)..

\color{#9932CC}{第三種艷遇:}\color{#34a853}{雙重檢查鎖(DCL),完全就是前兩種的結合體啊,有木有,只是將同步方法升級成了同步代碼塊}

//劃重點了 **volatile**
private volatile static LazyLoadBalancer loadBalancer;

public static LazyLoadBalancer getInstance() {
    if (loadBalancer == null) {
        synchronized (LazyLoadBalancer.class) {
            if (loadBalancer == null) {
                loadBalancer = new LazyLoadBalancer();
            }
        }
    }
    return loadBalancer;
}

分析:

  1. 假設new LazyLoadBalancer()加載內容過多
  2. 因重排而導致loadBalancer提前不為空
  3. 正好被其它線程觀察到對象非空直接返回使用 一種罕見的單例空指針突然來襲
  • 存在問題: 首先我們一定要清楚,DCL是不能保證線程安全的,稍微了解過JVM的就清楚,對比C/C++它始終缺少一個正式的內存模型,所以為了提升性能,它還會做一次指令重排操作,這個時候就會導致loadBalancer提前不為空,正好被其它線程觀察到對象非空直接返回使用(但實際還有部分內容沒加載完成)
  • 解決方案: 用volatile修飾loadBalancer,因為volatile修飾的成員變量可以確保多個線程都能夠順序處理,它會屏蔽JVM指令重排帶來的性能優化。

\color{#9932CC}{第四種艷遇:}\color{#34a853}{Demand Holder,靜態內部類 (懶漢)線程安全,推薦使用}

private LazyLoadBalancer() {}

private static class LoadBalancerHolder {
    //在JVM中 final 對象只會被實例化一次,無法修改
    private final static LazyLoadBalancer INSTANCE = new LazyLoadBalancer();
}

public static LazyLoadBalancer getInstance() {
    return LoadBalancerHolder.INSTANCE;
}

分析: 在Demand Holder中,我們在LazyLoadBalancer里增加一個靜態(static)內部類,在該內部類中創建單例對象,再將
該單例對象通過getInstance()方法返回給外部使用,由于靜態單例對象沒有作為LazyLoadBalancer的成員變量直接實例化,類加載時并不會實例化LoadBalancerHolder,因此既可以實現延遲加載,又可以保證線程安全,不影響系統性能(居家旅行必備良藥啊)

雙重校驗鎖版,不管性能再如何優越,還是使用了synchronized修飾符,既然使用了該修飾符,那么對性能多多少少都會造成一些影響,于是乎Demand Holder誕生,涉及內部類的加載機制,復習一下,代碼如下:

package test;

public class OuterTest {

    static {
        System.out.println("load outer class...");
    }

    // 靜態內部類
    static class StaticInnerTest {
        static {
            System.out.println("load static inner class...");
        }

        static void staticInnerMethod() {
            System.out.println("static inner method...");
        }
    }

    public static void main(String[] args) {
        OuterTest outerTest = new OuterTest(); // 此刻其內部類是否也會被加載?
        System.out.println("===========分割線===========");
        OuterTest.StaticInnerTest.staticInnerMethod(); // 調用內部類的靜態方法
    }

}

輸出如下:

load outer class...
===========分割線===========
load static inner class...
static inner method

因此,我們有如下結論:

1. 加載一個類時,其內部類不會同時被加載。
2. 一個類被加載,當且僅當其某個靜態成員(靜態域、構造器、靜態方法等)被調用時發生。

\color{#9932CC}{第五種艷遇:}\color{#34a853}{懶漢式, 防止反射|序列化|反序列化}

package singleton;

import java.io.Serializable;

public class LazySingleton4 implements Serializable {

    private static boolean initialized = false;

    private LazySingleton4() {
        synchronized (LazySingleton4.class) {
            if (initialized == false) {
                initialized = !initialized;
            } else {
                throw new RuntimeException("單例已被破壞");
            }
        }
    }

    static class SingletonHolder {
        private static final LazySingleton4 instance = new LazySingleton4();
    }

    public static LazySingleton4 getInstance() {
        return SingletonHolder.instance;
    }
    
    
    //序列化 防止序列化被破壞單例
    private Object readResolve() {
        return getInstance();
    }
}

分析: 1. 我們知道 反射可以創建對象,那我們由反射的原理即防止反射破壞了單例,因此誕生了如上文的單例

2.在分布式系統中,有些情況下你需要在單例類中實現 Serializable 接口。這樣你可以在文件系統中存儲它的狀態并且在稍后的某一時間點取出,為了避免此問題,我們需要提供 readResolve() 方法的實現。readResolve()代替了從流中讀取對象。這就確保了在序列化和反序列化的過程中沒人可以創建新的實例

為什么反序列化可以破壞呢?我們一起來看下ois.readObject()的源碼:

private Object readObject0(boolean unshared) throws IOException {
    ...省略
    case TC_OBJECT:
      return checkResolve(readOrdinaryObject(unshared));
}
-------------------------------------------------------------------
private Object readOrdinaryObject(boolean unshared){
    if (bin.readByte() != TC_OBJECT) {
            throw new InternalError();
        }

        ObjectStreamClass desc = readClassDesc(false);
        desc.checkDeserialize();

        Class<?> cl = desc.forClass();
        if (cl == String.class || cl == Class.class
                || cl == ObjectStreamClass.class) {
            throw new InvalidClassException("invalid class descriptor");
        }

        Object obj;
        try {
    //重點!!!
    //首先isInstantiable()判斷是否可以初始化
    //如果為true,則調用newInstance()方法創建對象,這時創建的對象是不走構造函數的,是一個新的對象
            obj = desc.isInstantiable() ? desc.newInstance() : null;
        } catch (Exception ex) {
            throw (IOException) new InvalidClassException(
                desc.forClass().getName(),
                "unable to create instance").initCause(ex);
        }

        passHandle = handles.assign(unshared ? unsharedMarker : obj);
        ClassNotFoundException resolveEx = desc.getResolveException();
        if (resolveEx != null) {
            handles.markException(passHandle, resolveEx);
        }

        if (desc.isExternalizable()) {
            readExternalData((Externalizable) obj, desc);
        } else {
            readSerialData(obj, desc);
        }

        handles.finish(passHandle);
    
    //重點!!!
    //hasReadResolveMethod()會去判斷,我們的InnerClassSingleton對象中是否有readResolve()方法
        if (obj != null &&
            handles.lookupException(passHandle) == null &&
            desc.hasReadResolveMethod())
        {
    //如果為true,則執行readResolve()方法,而我們在自己的readResolve()方法中 直接retrun INSTANCE,所以還是返回的同一個對象,保證了單例
            Object rep = desc.invokeReadResolve(obj);
            if (unshared && rep.getClass().isArray()) {
                rep = cloneArray(rep);
            }
            if (rep != obj) {
                // Filter the replacement object
                if (rep != null) {
                    if (rep.getClass().isArray()) {
                        filterCheck(rep.getClass(), Array.getLength(rep));
                    } else {
                        filterCheck(rep.getClass(), -1);
                    }
                }
                handles.setObject(passHandle, obj = rep);
            }
        }

        return obj;
}

\color{#9932CC}{第六種艷遇:}\color{#34a853}{枚舉特性(懶漢)線程安全,推薦使用}

enum Lazy {
    INSTANCE;
    private LazyLoadBalancer loadBalancer;

    //枚舉的特性,在JVM中只會被實例化一次
    Lazy() {
        loadBalancer = new LazyLoadBalancer();
    }

    public LazyLoadBalancer getInstance() {
        return loadBalancer;
    }
}

分析: 相比上一種,該方式同樣是用到了JAVA特性:枚舉類保證只有一個實例(即使使用反射機制也無法多次實例化一個枚舉量)

\color{#9932CC}{第七種艷遇:}\color{#34a853}{餓漢單例(天生線程安全)}

public class EagerLoadBalancer {
    private final static EagerLoadBalancer INSTANCE = new EagerLoadBalancer();

    private EagerLoadBalancer() {}

    public static EagerLoadBalancer getInstance() {
        return INSTANCE;
    }
}

分析: 利用ClassLoad機制,在加載時進行實例化,同時靜態方法只在編譯期間執行一次初始化,也就只有一個對象。使用的時候已被初始化完畢可以直接調用,但是相比懶漢模式,它在使用的時候速度最快,但這玩意就像自己挖的坑哭著也得跳,你不用也得初始化一份在內存中占個坑… 但是寫著簡單~

\color{#9932CC}{第八種艷遇:}\color{#34a853}{登記式單例}


public class RegistSingleton {
    //用ConcurrentHashMap來維護映射關系,這是線程安全的
    public static final Map<String,Object> REGIST=new ConcurrentHashMap<String, Object>();
    static {
        //把RegistSingleton自己也納入容器管理
        RegistSingleton registSingleton=new RegistSingleton();
        REGIST.put(registSingleton.getClass().getName(),registSingleton);
    }
    private RegistSingleton(){}
    public static Object getInstance(String className){
        //如果傳入的類名為空,就返回RegistSingleton實例
        if(className==null)
            className=RegistSingleton.class.getName();
            //如果沒有登記就用反射new一個
        if (!REGIST.containsKey(className)){
            //沒有登記就進入同步塊
            synchronized (RegistSingleton.class){
            //再次檢測是否登記
                if (!REGIST.containsKey(className)){
                    try {
                    //實例化對象
                        REGIST.put(className,Class.forName(className).newInstance());
                    } catch (InstantiationException e) {
                        e.printStackTrace();
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    } catch (ClassNotFoundException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        //返回單例
        return REGIST.get(className);
    }
}

來一把測試:

public class Main {
    static CyclicBarrier cyclicBarrier=new CyclicBarrier(1000);
    public static void main(String[] args) {
        for (int i = 0; i <1000 ; i++) {
            int n = i;
            new Thread(()->{
                System.out.println("線程"+ n +"準備就緒");
                try {
                    cyclicBarrier.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
                System.out.println(RegistSingleton.getInstance("singletonpattern.regist.ClassA"));
            }).start();
        }
    }
}

輸出結果:是線程安全的(ClassA是一個空類,里面什么也沒有)

來來 領略一下 Spring的源碼:

public abstract class AbstractBeanFactory implements ConfigurableBeanFactory{    
   /**  
    * 充當了Bean實例的緩存,實現方式和單例注冊表相同  
    */    
   private final Map singletonCache=new HashMap();    
   public Object getBean(String name)throws BeansException{    
       return getBean(name,null,null);    
   }    
...    
   public Object getBean(String name,Class requiredType,Object[] args)throws BeansException{    
      //對傳入的Bean name稍做處理,防止傳入的Bean name名有非法字符(或則做轉碼)    
      String beanName=transformedBeanName(name);    
      Object bean=null;    
      //手工檢測單例注冊表    
      Object sharedInstance=null;    
      //使用了代碼鎖定同步塊,原理和同步方法相似,但是這種寫法效率更高    
      synchronized(this.singletonCache){    
         sharedInstance=this.singletonCache.get(beanName);    
       }    
      if(sharedInstance!=null){    
         ...    
         //返回合適的緩存Bean實例    
         bean=getObjectForSharedInstance(name,sharedInstance);    
      }else{    
        ...    
        //取得Bean的定義    
        RootBeanDefinition mergedBeanDefinition=getMergedBeanDefinition(beanName,false);    
         ...    
        //根據Bean定義判斷,此判斷依據通常來自于組件配置文件的單例屬性開關    
        //<bean id="date" class="java.util.Date" scope="singleton"/>    
        //如果是單例,做如下處理    
        if(mergedBeanDefinition.isSingleton()){    
           synchronized(this.singletonCache){    
            //再次檢測單例注冊表    
             sharedInstance=this.singletonCache.get(beanName);    
             if(sharedInstance==null){    
                ...    
               try {    
                  //真正創建Bean實例    
                  sharedInstance=createBean(beanName,mergedBeanDefinition,args);    
                  //向單例注冊表注冊Bean實例    
                   addSingleton(beanName,sharedInstance);    
               }catch (Exception ex) {    
                  ...    
               }finally{    
                  ...    
              }    
             }    
           }    
          bean=getObjectForSharedInstance(name,sharedInstance);    
        }    
       //如果是非單例,即prototpye,每次都要新創建一個Bean實例    
       //<bean id="date" class="java.util.Date" scope="prototype"/>    
       else{    
          bean=createBean(beanName,mergedBeanDefinition,args);    
       }    
}    
...    
   return bean;    
}    
}

分析: 登記式單例實際上維護的是一組單例類的實例,將這些實例存儲到一個Map(登記簿)中,對于已經登記過的單例,則從工廠直接返回,對于沒有登記的,則先登記,而后返回

  1. 使用map實現注冊表;
  2. 使用protect修飾構造方法;

有的時候,我們不希望在一開始的時候就把一個類寫成單例模式,但是在運用的時候,我們卻可以像單例一樣使用他

最典型的例子就是spring,他的默認類型就是單例,spring是如何做到把不是單例的類變成單例呢?

這就用到了登記式單例

其實登記式單例并沒有去改變類,他所做的就是起到一個登記的作用,如果沒有登記,他就給你登記,并把生成的實例保存起來,下次你要用的時候直接給你。

IOC容器就是做的這個事,你需要就找他去拿,他就可以很方便的實現Bean的管理。

\color{#9932CC}{第九種艷遇:}\color{#34a853}{ ThreadLocal 局部單例}

public class Singleton {
    
    private Singleton(){}
    
    private static final ThreadLocal<Singleton> threadLocal = 
            new ThreadLocal<Singleton>(){
                @Override
                protected Singleton initialValue(){
                    return new Singleton();
                }
            };
    
    public static Singleton getInstance(){
        return threadLocal.get();
    }
    
}

分析: 這種寫法利用了ThreadLocal的特性,可以保證局部單例,即在各自的線程中是單例的,但是線程與線程之間不保證單例。

initialValue()一般是用來在使用時進行重寫的,如果在沒有set的時候就調用get,會調用initialValue方法初始化內容。

ThreadLocal會為每一個線程提供一個獨立的變量副本,從而隔離了多個線程對數據的訪問沖突。對于多線程資源共享的問題,同步機制采用了“以時間換空間”的方式,而ThreadLocal采用了“以空間換時間”的方式。前者僅提供一份變量,讓不同的線程排隊訪問,而后者為每一個線程都提供了一份變量,即線程隔離,因此可以同時訪問而互不影響。

\color{#9932CC}{第十種艷遇:}\color{#34a853}{ 使用CAS鎖實現(線程安全)}

/**
 * 更加優美的Singleton, 線程安全的
 */
public class Singleton {
 /** 利用AtomicReference */
 private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
 /**
  * 私有化
  */
 private Singleton(){
 }
 /**
  * 用CAS確保線程安全
  */
 public static final Singleton getInstance(){
  for (;;) {
   Singleton current = INSTANCE.get();
            if (current != null) {
                return current;
            }
            current = new Singleton();
            if (INSTANCE.compareAndSet(null, current)) {
                return current;
            }
        }
 }
 
 public static void main(String[] args) {
  Singleton singleton1 = Singleton.getInstance();
  Singleton singleton2 = Singleton.getInstance();
     System.out.println(singleton1 == singleton2);
 }
}

分析:
CAS 是線程安全的,使用了無鎖編程. 這種方式當在大量線程去獲取實例的時候,會造成CPU的激情燃燒~

4.總結

本文給出了多個版本的單例模式,供我們在項目中使用。實際上,我們在實際項目中一般從艷遇四、五、六中,根據實際情況三選一即可。
最后,希望大家有所收獲。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容