這系列相關博客,參考 設計模式之美
設計模式之美 - 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),你能自己研究並解釋一下爲什麼嗎?