天天看點

設計模式之美 - 41 | 單例模式(上):為什麼說支援懶加載的雙重檢測不比餓漢式更優?為什麼要使用單例?如何實作一個單例?重點回顧課堂讨論

這系列相關部落格,參考 設計模式之美

設計模式之美 - 41 | 單例模式(上):為什麼說支援懶加載的雙重檢測不比餓漢式更優?

  • 為什麼要使用單例?
  • 如何實作一個單例?
    • 1. 餓漢式
    • 2. 懶漢式
    • 3. 雙重檢測
    • 4. 靜态内部類
    • 5. 枚舉
  • 重點回顧
  • 課堂讨論

從今天開始,我們正式進入到設計模式的學習。我們知道,經典的設計模式有 23 種。其中,常用的并不是很多。據我的工作經驗來看,常用的可能都不到一半。如果随便抓一個程式員,讓他說一說最熟悉的 3 種設計模式,那其中肯定會包含今天要講的單例模式。

網上有很多講解單例模式的文章,但大部分都側重講解,如何來實作一個線程安全的單例。我今天也會講到各種單例的實作方法,但是,這并不是我們專欄學習的重點,我重點還是希望帶你搞清楚下面這樣幾個問題(第一個問題會在今天講解,後面三個問題放到下一節課中講解)。

  • 為什麼要使用單例?
  • 單例存在哪些問題?
  • 單例與靜态類的差別?
  • 有何替代的解決方案?

話不多說,讓我們帶着這些問題,正式開始今天的學習吧!

為什麼要使用單例?

**單例設計模式(Singleton Design Pattern)**了解起來非常簡單。一個類隻允許建立一個對象(或者執行個體),那這個類就是一個單例類,這種設計模式就叫作單例設計模式,簡稱單例模式。

對于單例的概念,我覺得沒必要解釋太多,你一看就能明白。我們重點看一下,為什麼我們需要單例這種設計模式?它能解決哪些問題?接下來我通過兩個實戰案例來講解。

實戰案例一:處理資源通路沖突

我們先來看第一個例子。在這個例子中,我們自定義實作了一個往檔案中列印日志的Logger 類。具體的代碼實作如下所示:

public class Logger {
	private FileWriter writer;
	
	public Logger() {
		File file = new File("/Users/wangzheng/log.txt");
		writer = new FileWriter(file, true); //true表示追加寫入
	}
	
	public void log(String message) {
		writer.write(mesasge);
	}
}

// Logger類的應用示例:
public class UserController {
	private Logger logger = new Logger();
	
	public void login(String username, String password) {
		// ...省略業務邏輯代碼...
		logger.log(username + " logined!");
	}
}

public class OrderController {
	private Logger logger = new Logger();
	
	public void create(OrderVo order) {
		// ...省略業務邏輯代碼...
		logger.log("Created an order: " + order.toString());
	}
}
           

看完代碼之後,先别着急看我下面的講解,你可以先思考一下,這段代碼存在什麼問題。

在上面的代碼中,我們注意到,所有的日志都寫入到同一個檔案 /Users/wangzheng/log.txt 中。在 UserController 和 OrderController 中,我們分别建立兩個 Logger 對象。在 Web 容器的 Servlet 多線程環境下,如果兩個 Servlet 線程同時分别執行 login() 和 create() 兩個函數,并且同時寫日志到 log.txt 檔案中,那就有可能存在日志資訊互相覆寫的情況。

為什麼會出現互相覆寫呢?我們可以這麼類比着了解。在多線程環境下,如果兩個線程同時給同一個共享變量加 1,因為共享變量是競争資源,是以,共享變量最後的結果有可能并不是加了 2,而是隻加了 1。同理,這裡的 log.txt 檔案也是競争資源,兩個線程同時往裡面寫資料,就有可能存在互相覆寫的情況。

設計模式之美 - 41 | 單例模式(上):為什麼說支援懶加載的雙重檢測不比餓漢式更優?為什麼要使用單例?如何實作一個單例?重點回顧課堂讨論

那如何來解決這個問題呢?我們最先想到的就是通過加鎖的方式:給 log() 函數加互斥鎖(Java 中可以通過 synchronized 的關鍵字),同一時刻隻允許一個線程調用執行 log() 函數。具體的代碼實作如下所示:

public class Logger {
	private FileWriter writer;
	
	public Logger() {
		File file = new File("/Users/wangzheng/log.txt");
		writer = new FileWriter(file, true); //true表示追加寫入
	}
	
	public void log(String message) {
		synchronized(this) {
			writer.write(mesasge);
		}
	}
}
           

不過,你仔細想想,這真的能解決多線程寫入日志時互相覆寫的問題嗎?答案是否定的。這是因為,這種鎖是一個對象級别的鎖,一個對象在不同的線程下同時調用 log() 函數,會被強制要求順序執行。但是,不同的對象之間并不共享同一把鎖。在不同的線程下,通過不同的對象調用執行 log() 函數,鎖并不會起作用,仍然有可能存在寫入日志互相覆寫的問題。

設計模式之美 - 41 | 單例模式(上):為什麼說支援懶加載的雙重檢測不比餓漢式更優?為什麼要使用單例?如何實作一個單例?重點回顧課堂讨論

我這裡稍微補充一下,在剛剛的講解和給出的代碼中,我故意“隐瞞”了一個事實:我們給 log() 函數加不加對象級别的鎖,其實都沒有關系。因為 FileWriter 本身就是線程安全的,它的内部實作中本身就加了對象級别的鎖,是以,在在外層調用 write() 函數的時候,再加對象級别的鎖實際上是多此一舉。因為不同的 Logger 對象不共享FileWriter 對象,是以,FileWriter 對象級别的鎖也解決不了資料寫入互相覆寫的問題。

那我們該怎麼解決這個問題呢?實際上,要想解決這個問題也不難,我們隻需要把對象級别的鎖,換成類級别的鎖就可以了。讓所有的對象都共享同一把鎖。這樣就避免了不同對象之間同時調用 log() 函數,而導緻的日志覆寫問題。具體的代碼實作如下所示:

public class Logger {
	private FileWriter writer;
	
	public Logger() {
		File file = new File("/Users/wangzheng/log.txt");
		writer = new FileWriter(file, true); //true表示追加寫入
	}
	
	public void log(String message) {
		synchronized(Logger.class) { // 類級别的鎖
			writer.write(mesasge);
		}
	}
}
           

除了使用類級别鎖之外,實際上,解決資源競争問題的辦法還有很多,分布式鎖是最常聽到的一種解決方案。不過,實作一個安全可靠、無 bug、高性能的分布式鎖,并不是件容易的事情。除此之外,并發隊列(比如 Java 中的 BlockingQueue)也可以解決這個問題:多個線程同時往并發隊列裡寫日志,一個單獨的線程負責将并發隊列中的資料,寫入到日志檔案。這種方式實作起來也稍微有點複雜。

相對于這兩種解決方案,單例模式的解決思路就簡單一些了。單例模式相對于之前類級别鎖的好處是,不用建立那麼多 Logger 對象,一方面節省記憶體空間,另一方面節省系統檔案句柄(對于作業系統來說,檔案句柄也是一種資源,不能随便浪費)。

我們将 Logger 設計成一個單例類,程式中隻允許建立一個 Logger 對象,所有的線程共享使用的這一個 Logger 對象,共享一個 FileWriter 對象,而 FileWriter 本身是對象級别線程安全的,也就避免了多線程情況下寫日志會互相覆寫的問題。

按照這個設計思路,我們實作了 Logger 單例類。具體代碼如下所示:

public class Logger {
	private FileWriter writer;
	private static final Logger instance = new Logger();
	
	private Logger() {
		File file = new File("/Users/wangzheng/log.txt");
		writer = new FileWriter(file, true); //true表示追加寫入
	}
	
	public static Logger getInstance() {
		return instance;
	}
	
	public void log(String message) {
		writer.write(mesasge);
	}
}

// Logger類的應用示例:
public class UserController {
	public void login(String username, String password) {
		// ...省略業務邏輯代碼...
		Logger.getInstance().log(username + " logined!");
	}
}
public class OrderController {
	private Logger logger = new Logger();
	public void create(OrderVo order) {
		// ...省略業務邏輯代碼...
		Logger.getInstance().log("Created a order: " + order.toString());
	}
}
           

實戰案例二:表示全局唯一類

從業務概念上,如果有些資料在系統中隻應儲存一份,那就比較适合設計為單例類。

比如,配置資訊類。在系統中,我們隻有一個配置檔案,當配置檔案被加載到記憶體之後,以對象的形式存在,也理所應當隻有一份。

再比如,唯一遞增 ID 号碼生成器(第 34 講中我們講的是唯一 ID 生成器,這裡講的是唯一遞增 ID 生成器),如果程式中有兩個對象,那就會存在生成重複 ID 的情況,是以,我們應該将 ID 生成器類設計為單例。

import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
	// AtomicLong是一個Java并發庫中提供的一個原子變量類型,
	// 它将一些線程不安全需要加鎖的複合操作封裝為了線程安全的原子操作,
	// 比如下面會用到的incrementAndGet().
	private AtomicLong id = new AtomicLong(0);
	private static final IdGenerator instance = new IdGenerator();
	
	private IdGenerator() {}
	
	public static IdGenerator getInstance() {
		return instance;
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}

// IdGenerator使用舉例
long id = IdGenerator.getInstance().getId();
           

實際上,今天講到的兩個代碼執行個體(Logger、IdGenerator),設計的都并不優雅,還存在一些問題。至于有什麼問題以及如何改造,今天我暫時賣個關子,下一節課我會詳細講解。

如何實作一個單例?

盡管介紹如何實作一個單例模式的文章已經有很多了,但為了保證内容的完整性,我這裡還是簡單介紹一下幾種經典實作方式。概括起來,要實作一個單例,我們需要關注的點無外乎下面幾個:

  • 構造函數需要是 private 通路權限的,這樣才能避免外部通過 new 建立執行個體;
  • 考慮對象建立時的線程安全問題;
  • 考慮是否支援延遲加載;
  • 考慮 getInstance() 性能是否高(是否加鎖)。

如果你對這塊已經很熟悉了,你可以當作複習。注意,下面的幾種單例實作方式是針對Java 語言文法的,如果你熟悉的是其他語言,不妨對比 Java 的這幾種實作方式,自己試着總結一下,利用你熟悉的語言,該如何實作。

1. 餓漢式

餓漢式的實作方式比較簡單。在類加載的時候,instance 靜态執行個體就已經建立并初始化好了,是以,instance 執行個體的建立過程是線程安全的。不過,這樣的實作方式不支援延遲加載(在真正用到 IdGenerator 的時候,再建立執行個體),從名字中我們也可以看出這一點。具體的代碼實作如下所示:

public class IdGenerator {
	private AtomicLong id = new AtomicLong(0);
	private static final IdGenerator instance = new IdGenerator();
	
	private IdGenerator() {}
	
	public static IdGenerator getInstance() {
		return instance;
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}
           

有人覺得這種實作方式不好,因為不支援延遲加載,如果執行個體占用資源多(比如占用記憶體多)或初始化耗時長(比如需要加載各種配置檔案),提前初始化執行個體是一種浪費資源的行為。最好的方法應該在用到的時候再去初始化。不過,我個人并不認同這樣的觀點。

如果初始化耗時長,那我們最好不要等到真正要用它的時候,才去執行這個耗時長的初始化過程,這會影響到系統的性能(比如,在響應用戶端接口請求的時候,做這個初始化操作,會導緻此請求的響應時間變長,甚至逾時)。采用餓漢式實作方式,将耗時的初始化操作,提前到程式啟動的時候完成,這樣就能避免在程式運作的時候,再去初始化導緻的性能問題。

如果執行個體占用資源多,按照 fail-fast 的設計原則(有問題及早暴露),那我們也希望在程式啟動時就将這個執行個體初始化好。如果資源不夠,就會在程式啟動的時候觸發報錯(比如 Java 中的 PermGen Space OOM),我們可以立即去修複。這樣也能避免在程式運作一段時間後,突然因為初始化這個執行個體占用資源過多,導緻系統崩潰,影響系統的可用性。

2. 懶漢式

有餓漢式,對應地,就有懶漢式。懶漢式相對于餓漢式的優勢是支援延遲加載。具體的代碼實作如下所示:

public class IdGenerator {
	private AtomicLong id = new AtomicLong(0);
	private static IdGenerator instance;
	
	private IdGenerator() {}
	
	public static synchronized IdGenerator getInstance() {
		if (instance == null) {
			instance = new IdGenerator();
		}
		return instance;
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}
           

不過懶漢式的缺點也很明顯,我們給 getInstance() 這個方法加了一把大鎖(synchronzed),導緻這個函數的并發度很低。量化一下的話,并發度是 1,也就相當于串行操作了。而這個函數是在單例使用期間,一直會被調用。如果這個單例類偶爾會被用到,那這種實作方式還可以接受。但是,如果頻繁地用到,那頻繁加鎖、釋放鎖及并發度低等問題,會導緻性能瓶頸,這種實作方式就不可取了。

3. 雙重檢測

餓漢式不支援延遲加載,懶漢式有性能問題,不支援高并發。那我們再來看一種既支援延遲加載、又支援高并發的單例實作方式,也就是雙重檢測實作方式。

在這種實作方式中,隻要 instance 被建立之後,即便再調用 getInstance() 函數也不會再進入到加鎖邏輯中了。是以,這種實作方式解決了懶漢式并發度低的問題。具體的代碼實作如下所示:

public class IdGenerator {
	private AtomicLong id = new AtomicLong(0);
	private static IdGenerator instance;
	
	private IdGenerator() {}
	
	public static IdGenerator getInstance() {
		if (instance == null) {
			synchronized(IdGenerator.class) { // 此處為類級别的鎖
				if (instance == null) {
					instance = new IdGenerator();
				}
			}
		}
		return instance;
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}
           

網上有人說,這種實作方式有些問題。因為指令重排序,可能會導緻 IdGenerator 對象被 new 出來,并且指派給 instance 之後,還沒來得及初始化(執行構造函數中的代碼邏輯),就被另一個線程使用了。

要解決這個問題,我們需要給 instance 成員變量加上 volatile 關鍵字,禁止指令重排序才行。實際上,隻有很低版本的 Java 才會有這個問題。我們現在用的高版本的 Java 已經在 JDK 内部實作中解決了這個問題(解決的方法很簡單,隻要把對象 new 操作和初始化操作設計為原子操作,就自然能禁止重排序)。關于這點的詳細解釋,跟特定語言有關,我就不展開講了,感興趣的同學可以自行研究一下。

4. 靜态内部類

我們再來看一種比雙重檢測更加簡單的實作方法,那就是利用 Java 的靜态内部類。它有點類似餓漢式,但又能做到了延遲加載。具體是怎麼做到的呢?我們先來看它的代碼實作。

public class IdGenerator {
	private AtomicLong id = new AtomicLong(0);
	private IdGenerator() {}
	
	private static class SingletonHolder{
		private static final IdGenerator instance = new IdGenerator();
	}
	
	public static IdGenerator getInstance() {
		return SingletonHolder.instance;
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}
           

SingletonHolder 是一個靜态内部類,當外部類 IdGenerator 被加載的時候,并不會建立 SingletonHolder 執行個體對象。隻有當調用 getInstance() 方法時,SingletonHolder才會被加載,這個時候才會建立 instance。insance 的唯一性、建立過程的線程安全性,都由 JVM 來保證。是以,這種實作方法既保證了線程安全,又能做到延遲加載。

5. 枚舉

最後,我們介紹一種最簡單的實作方式,基于枚舉類型的單例實作。這種實作方式通過Java 枚舉類型本身的特性,保證了執行個體建立的線程安全性和執行個體的唯一性。具體的代碼如下所示:

public enum IdGenerator {
	INSTANCE;
	private AtomicLong id = new AtomicLong(0);
	
	public long getId() {
		return id.incrementAndGet();
	}
}
           

重點回顧

好了,今天的内容到此就講完了。我們來總結回顧一下,你需要掌握的重點内容。

1. 單例的定義

單例設計模式(Singleton Design Pattern)了解起來非常簡單。一個類隻允許建立一個對象(或者叫執行個體),那這個類就是一個單例類,這種設計模式就叫作單例設計模式,簡稱單例模式。

2. 單例的用處

從業務概念上,有些資料在系統中隻應該儲存一份,就比較适合設計為單例類。比如,系統的配置資訊類。除此之外,我們還可以使用單例解決資源通路沖突的問題。

3. 單例的實作

單例有下面幾種經典的實作方式。

餓漢式

餓漢式的實作方式,在類加載的期間,就已經将 instance 靜态執行個體初始化好了,是以,instance 執行個體的建立是線程安全的。不過,這樣的實作方式不支援延遲加載執行個體。

懶漢式

懶漢式相對于餓漢式的優勢是支援延遲加載。這種實作方式會導緻頻繁加鎖、釋放鎖,以及并發度低等問題,頻繁的調用會産生性能瓶頸。

雙重檢測

雙重檢測實作方式既支援延遲加載、又支援高并發的單例實作方式。隻要 instance 被建立之後,再調用 getInstance() 函數都不會進入到加鎖邏輯中。是以,這種實作方式解決了懶漢式并發度低的問題。

靜态内部類

利用 Java 的靜态内部類來實作單例。這種實作方式,既支援延遲加載,也支援高并發,實作起來也比雙重檢測簡單。

枚舉

最簡單的實作方式,基于枚舉類型的單例實作。這種實作方式通過 Java 枚舉類型本身的特性,保證了執行個體建立的線程安全性和執行個體的唯一性。

課堂讨論

  1. 在你所熟悉的程式設計語言的類庫中,有哪些類是單例類?又為什麼要設計成單例類呢?
  2. 在第一個實戰案例中,除了我們講到的類級别鎖、分布式鎖、并發隊列、單例模式等解決方案之外,實際上還有一種非常簡單的解決日志互相覆寫問題的方法,你想到了嗎?

繼續閱讀