設計模式之美 - 43 | 單例模式(下):如何設計實現一個集羣環境下的分佈式單例模式?

這系列相關博客,參考 設計模式之美

設計模式之美 - 43 | 單例模式(下):如何設計實現一個集羣環境下的分佈式單例模式?

上兩節課中,我們針對單例模式,講解了單例的應用場景、幾種常見的代碼實現和存在的問題,並粗略給出了替換單例模式的方法,比如工廠模式、IOC 容器。今天,我們再進一步擴展延伸一下,一塊討論一下下面這幾個問題:

  • 如何理解單例模式中的唯一性?
  • 如何實現線程唯一的單例?
  • 如何實現集羣環境下的單例?
  • 如何實現一個多例模式?

今天的內容稍微有點“燒腦”,希望你在看的過程中多思考一下。話不多說,讓我們正式開始今天的學習吧!

如何理解單例模式中的唯一性?

首先,我們重新看一下單例的定義:“一個類只允許創建唯一一個對象(或者實例),那這個類就是一個單例類,這種設計模式就叫作單例設計模式,簡稱單例模式。”

定義中提到,“一個類只允許創建唯一一個對象”。那對象的唯一性的作用範圍是什麼呢?是指線程內只允許創建一個對象,還是指進程內只允許創建一個對象?答案是後者,也就是說,單例模式創建的對象是進程唯一的。這裏有點不好理解,我來詳細地解釋一下。

我們編寫的代碼,通過編譯、鏈接,組織在一起,就構成了一個操作系統可以執行的文件,也就是我們平時所說的“可執行文件”(比如 Windows 下的 exe 文件)。可執行文件實際上就是代碼被翻譯成操作系統可理解的一組指令,你完全可以簡單地理解爲就是代碼本身。

當我們使用命令行或者雙擊運行這個可執行文件的時候,操作系統會啓動一個進程,將這個執行文件從磁盤加載到自己的進程地址空間(可以理解操作系統爲進程分配的內存存儲區,用來存儲代碼和數據)。接着,進程就一條一條地執行可執行文件中包含的代碼。比如,當進程讀到代碼中的 User user = new User(); 這條語句的時候,它就在自己的地址空間中創建一個 user 臨時變量和一個 User 對象。

進程之間是不共享地址空間的,如果我們在一個進程中創建另外一個進程(比如,代碼中有一個 fork() 語句,進程執行到這條語句的時候會創建一個新的進程),操作系統會給新進程分配新的地址空間,並且將老進程地址空間的所有內容,重新拷貝一份到新進程的地址空間中,這些內容包括代碼、數據(比如 user 臨時變量、User 對象)。

所以,單例類在老進程中存在且只能存在一個對象,在新進程中也會存在且只能存在一個對象。而且,這兩個對象並不是同一個對象,這也就說,單例類中對象的唯一性的作用範圍是進程內的,在進程間是不唯一的。

如何實現線程唯一的單例?

剛剛我們講了單例類對象是進程唯一的,一個進程只能有一個單例對象。那如何實現一個線程唯一的單例呢?

我們先來看一下,什麼是線程唯一的單例,以及“線程唯一”和“進程唯一”的區別。

“進程唯一”指的是進程內唯一,進程間不唯一。類比一下,“線程唯一”指的是線程內唯一,線程間可以不唯一。實際上,“進程唯一”還代表了線程內、線程間都唯一,這也是“進程唯一”和“線程唯一”的區別之處。這段話聽起來有點像繞口令,我舉個例子來解釋一下。

假設 IdGenerator 是一個線程唯一的單例類。在線程 A 內,我們可以創建一個單例對象a。因爲線程內唯一,在線程 A 內就不能再創建新的 IdGenerator 對象了,而線程間可以不唯一,所以,在另外一個線程 B 內,我們還可以重新創建一個新的單例對象 b。

儘管概念理解起來比較複雜,但線程唯一單例的代碼實現很簡單,如下所示。在代碼中,我們通過一個 HashMap 來存儲對象,其中 key 是線程 ID,value 是對象。這樣我們就可以做到,不同的線程對應不同的對象,同一個線程只能對應一個對象。實際上,Java 語言本身提供了 ThreadLocal 工具類,可以更加輕鬆地實現線程唯一單例。不過,ThreadLocal 底層實現原理也是基於下面代碼中所示的 HashMap。

public class IdGenerator {
	private AtomicLong id = new AtomicLong(0);
	private static final ConcurrentHashMap<Long, IdGenerator> instances
			= new ConcurrentHashMap<>();
			
	private IdGenerator() {}
	
	public static IdGenerator getInstance() {
		Long currentThreadId = Thread.currentThread().getId();
		instances.putIfAbsent(currentThreadId, new IdGenerator());
		return instances.get(currentThreadId);
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}

如何實現集羣環境下的單例?

剛剛我們講了“進程唯一”的單例和“線程唯一”的單例,現在,我們再來看下,“集羣唯一”的單例。

首先,我們還是先來解釋一下,什麼是“集羣唯一”的單例。

我們還是將它跟“進程唯一”“線程唯一”做個對比。“進程唯一”指的是進程內唯一、進程間不唯一。“線程唯一”指的是線程內唯一、線程間不唯一。集羣相當於多個進程構成的一個集合,“集羣唯一”就相當於是進程內唯一、進程間也唯一。也就是說,不同的進程間共享同一個對象,不能創建同一個類的多個對象。

我們知道,經典的單例模式是進程內唯一的,那如何實現一個進程間也唯一的單例呢?如果嚴格按照不同的進程間共享同一個對象來實現,那集羣唯一的單例實現起來就有點難度了。

具體來說,我們需要把這個單例對象序列化並存儲到外部共享存儲區(比如文件)。進程在使用這個單例對象的時候,需要先從外部共享存儲區中將它讀取到內存,並反序列化成對象,然後再使用,使用完成之後還需要再存儲回外部共享存儲區。

爲了保證任何時刻,在進程間都只有一份對象存在,一個進程在獲取到對象之後,需要對對象加鎖,避免其他進程再將其獲取。在進程使用完這個對象之後,還需要顯式地將對象從內存中刪除,並且釋放對對象的加鎖。

按照這個思路,我用僞代碼實現了一下這個過程,具體如下所示:

public class IdGenerator {
	private AtomicLong id = new AtomicLong(0);
	private static IdGenerator instance;
	private static SharedObjectStorage storage = FileSharedObjectStorage(
	private static DistributedLock lock = new DistributedLock();
	
	private IdGenerator() {}
	
	public synchronized static IdGenerator getInstance()
		if (instance == null) {
			lock.lock();
			instance = storage.load(IdGenerator.class);
		}
		return instance;
	}
	
	public synchroinzed void freeInstance() {
		storage.save(this, IdGeneator.class);
		instance = null; //釋放對象
		lock.unlock();
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}

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

如何實現一個多例模式?

跟單例模式概念相對應的還有一個多例模式。那如何實現一個多例模式呢?

“單例”指的是,一個類只能創建一個對象。對應地,“多例”指的就是,一個類可以創建多個對象,但是個數是有限制的,比如只能創建 3 個對象。如果用代碼來簡單示例一下的話,就是下面這個樣子:

public class BackendServer {
	private long serverNo;
	private String serverAddress;
	
	private static final int SERVER_COUNT = 3;
	private static final Map<Long, BackendServer> serverInstances = new HashMa
	
	static {
		serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
		serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
		serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
	}
	
	private BackendServer(long serverNo, String serverAddress) {
		this.serverNo = serverNo;
		this.serverAddress = serverAddress;
	}
	
	public BackendServer getInstance(long serverNo) {
		return serverInstances.get(serverNo);
	}
	
	public BackendServer getRandomInstance() {
		Random r = new Random();
		int no = r.nextInt(SERVER_COUNT)+1;
		return serverInstances.get(no);
	}
}

實際上,對於多例模式,還有一種理解方式:同一類型的只能創建一個對象,不同類型的可以創建多個對象。這裏的“類型”如何理解呢?

我們還是通過一個例子來解釋一下,具體代碼如下所示。在代碼中,logger name 就是剛剛說的“類型”,同一個 logger name 獲取到的對象實例是相同的,不同的 loggername 獲取到的對象實例是不同的。

public class Logger {
	private static final ConcurrentHashMap<String, Logger> instances
			= new ConcurrentHashMap<>();
			
	private Logger() {}
	
	public static Logger getInstance(String loggerName) {
		instances.putIfAbsent(loggerName, new Logger());
		return instances.get(loggerName);
	}
	
	public void log() {
		//...
	}
}

//l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");

這種多例模式的理解方式有點類似工廠模式。它跟工廠模式的不同之處是,多例模式創建的對象都是同一個類的對象,而工廠模式創建的是不同子類的對象,關於這一點,下一節課中就會講到。實際上,它還有點類似享元模式,兩者的區別等到我們講到享元模式的時候再來分析。除此之外,實際上,枚舉類型也相當於多例模式,一個類型只能對應一個對象,一個類可以創建多個對象。

重點回顧

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

今天的內容比較偏理論,在實際的項目開發中,沒有太多的應用。講解的目的,主要還是拓展你的思路,鍛鍊你的邏輯思維能力,加深你對單例的認識。

1. 如何理解單例模式的唯一性?

單例類中對象的唯一性的作用範圍是“進程唯一”的。“進程唯一”指的是進程內唯一,進程間不唯一;“線程唯一”指的是線程內唯一,線程間可以不唯一。實際上,“進程唯一”就意味着線程內、線程間都唯一,這也是“進程唯一”和“線程唯一”的區別之處。“集羣唯一”指的是進程內唯一、進程間也唯一。

2. 如何實現線程唯一的單例?

我們通過一個 HashMap 來存儲對象,其中 key 是線程 ID,value 是對象。這樣我們就可以做到,不同的線程對應不同的對象,同一個線程只能對應一個對象。實際上,Java語言本身提供了 ThreadLocal 併發工具類,可以更加輕鬆地實現線程唯一單例。

3. 如何實現集羣環境下的單例?

我們需要把這個單例對象序列化並存儲到外部共享存儲區(比如文件)。進程在使用這個單例對象的時候,需要先從外部共享存儲區中將它讀取到內存,並反序列化成對象,然後再使用,使用完成之後還需要再存儲回外部共享存儲區。爲了保證任何時刻在進程間都只有一份對象存在,一個進程在獲取到對象之後,需要對對象加鎖,避免其他進程再將其獲取。在進程使用完這個對象之後,需要顯式地將對象從內存中刪除,並且釋放對對象的加鎖。

4. 如何實現一個多例模式?

“單例”指的是一個類只能創建一個對象。對應地,“多例”指的就是一個類可以創建多個對象,但是個數是有限制的,比如只能創建 3 個對象。多例的實現也比較簡單,通過一個 Map 來存儲對象類型和對象之間的對應關係,來控制對象的個數。

課堂討論

在文章中,我們講到單例唯一性的作用範圍是進程,實際上,對於 Java 語言來說,單例類對象的唯一性的作用範圍並非進程,而是類加載器(Class Loader),你能自己研究並解釋一下爲什麼嗎?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章