單例模式
一.單例模式(上):爲什麼說支持懶加載的雙重檢測不比餓漢式更優?
1. 爲什麼要使用單例?
- 單例設計模式
一個類只允許創建一個對象(或者實例),那這個類就是一個單例類,這種設計模式就叫作單例設計模式,簡稱單例模式
- 例子:向文件寫日誌----通過加鎖方式,給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() 函數,會被強制要求順序執行。但是,不同的對象之間並不共享同一把鎖
- 改寫2:只需要把對象級別的鎖,換成類級別的鎖就可以了。讓所有的對象都共享同一把鎖。這樣就避免了不同對象之間同時調用 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);
}
}
}
-
相對於這兩種解決方案,單例模式的解決思路就簡單一些了。單例模式相對於之前類級別鎖的好處是,不用創建那麼多 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 {
public void create(OrderVo order) {
// ...省略業務邏輯代碼...
Logger.getInstance().log("Created a order: " + order.toString());
}
}
2. 如何實現一個單例?
- 關注的點
- 構造函數需要是 private 訪問權限的,這樣才能避免外部通過 new 創建實例;
- 考慮對象創建時的線程安全問題;
- 考慮是否支持延遲加載;
- 考慮 getInstance() 性能是否高(是否加鎖)。
3. 餓漢式
-
餓漢式的實現方式比較簡單。在類加載的時候,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();
}
}
- 採用餓漢式實現方式,將耗時的初始化操作,提前到程序啓動的時候完成,這樣就能避免在程序運行的時候,再去初始化導致的性能問題。
4. 懶漢式
- 懶漢式相對於餓漢式的優勢是支持延遲加載
- 代碼實現:
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,也就相當於串行操作了。而這個函數是在單例使用期間,一直會被調用。如果這個單例類偶爾會被用到,那這種實現方式還可以接受。但是,如果頻繁地用到,那頻繁加鎖、釋放鎖及併發度低等問題,會導致性能瓶頸,這種實現方式就不可取了
5. 雙重檢測
- 餓漢式不支持延遲加載,懶漢式有性能問題,不支持高併發。那我們再來看一種既支持延遲加載、又支持高併發的單例實現方式,也就是雙重檢測實現方式。
- 在這種實現方式中,只要 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();
}
}
6. 靜態內部類
- 一種比雙重檢測更加簡單的實現方法,那就是利用 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。instance 的唯一性、創建過程的線程安全性,都由 JVM 來保證。所以,這種實現方法既保證了線程安全,又能做到延遲加載。
7. 枚舉
- 一種最簡單的實現方式,基於枚舉類型的單例實現。這種實現方式通過 Java 枚舉類型本身的特性,保證了實例創建的線程安全性和實例的唯一性
- 代碼實現
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
}
二.單例模式(中):爲什麼不推薦使用單例模式?又有何替代方案?
1. 單例存在哪些問題?
1. 單例對 OOP 特性的支持不友好
- 生成ID例子代碼:
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
//...
}
}
- 問題:IdGenerator 的使用方式違背了基於接口而非實現的設計原則,也就違背了廣義上理解的 OOP 的抽象特性。如果未來某一天,我們希望針對不同的業務採用不同的 ID 生成算法。比如,訂單 ID 和用戶 ID 採用不同的 ID 生成器來生成。爲了應對這個需求變化,我們需要修改所有用到 IdGenerator 類的地方,這樣代碼的改動就會比較大。
- 改動後:
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
// 需要將上面一行代碼,替換爲下面一行代碼
long id = OrderIdGenerator.getIntance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
// 需要將上面一行代碼,替換爲下面一行代碼
long id = UserIdGenerator.getIntance().getId();
}
}
2. 單例會隱藏類之間的依賴關係
- 單例類不需要顯示創建、不需要依賴參數傳遞,在函數中直接調用就可以了。如果代碼比較複雜,這種調用關係就會非常隱蔽
3. 單例對代碼的擴展性不友好
- 慢 SQL 與其他 SQL 隔離開來執行。爲了實現這樣的目的,我們可以在系統中創建兩個數據庫連接池,慢 SQL 獨享一個數據庫連接池,其他 SQL 獨享另外一個數據庫連接池,這樣就能避免慢 SQL 影響到其他 SQL 的執行
4. 單例對代碼的可測試性不友好
5. 單例不支持有參數的構造函數
- 比如我們創建一個連接池的單例對象,我們沒法通過參數來指定連接池的大小。
- 可使用其他方法
6. 單例不支持有參數的構造函數
2. 有何替代解決方案?
- 我們還可以用靜態方法來實現。不過,靜態方法這種實現思路,並不能解決我們之前提到的問題。如果要完全解決這些問題,我們可能要從根上,尋找其他方式來實現全局唯一類了。比如,通過工廠模式、IOC 容器(比如 Spring IOC 容器)來保證,由程序員自己來保證(自己在編寫代碼的時候自己保證不要創建兩個類對象)。
三.單例模式(下):如何設計實現一個集羣環境下的分佈式單例模式?
1. 如何理解單例模式中的唯一性?
- 單例定義
一個類只允許創建唯一一個對象(或者實例),那這個類就是一個單例類,這種設計模式就叫作單例設計模式,簡稱單例模式。
- 唯一性理解
單例模式創建的對象是進程唯一的,單例類在老進程中存在且只能存在一個對象,在新進程中也會存在且只能存在一個對象。而且,這兩個對象並不是同一個對象,這也就說,單例類中對象的唯一性的作用範圍是進程內的,在進程間是不唯一的
2. 如何實現線程唯一的單例?
- 線程唯一 && 進程唯一
“進程唯一”指的是進程內唯一,進程間不唯一。類比一下,“線程唯一”指的是線程內唯一,線程間可以不唯一。實際上,“進程唯一”還代表了線程內、線程間都唯一,這也是“進程唯一”和“線程唯一”的區別之處。這段話聽起來有點像繞口令,我舉個例子來解釋一下。
- 不同的線程對應不同的對象,同一個線程只能對應一個對象。實際上,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();
}
}
3. 如何實現集羣環境下的單例?
- 集羣唯一
“進程唯一”指的是進程內唯一、進程間不唯一。“線程唯一”指的是線程內唯一、線程間不唯一。集羣相當於多個進程構成的一個集合,“集羣唯一”就相當於是進程內唯一、進程間也唯一。也就是說,不同的進程間共享同一個對象,不能創建同一個類的多個對象。
- 實現:需要把這個單例對象序列化並存儲到外部共享存儲區(比如文件)。進程在使用這個單例對象的時候,需要先從外部共享存儲區中將它讀取到內存,並反序列化成對象,然後再使用,使用完成之後還需要再存儲回外部共享存儲區。
- 爲了保證任何時刻,在進程間都只有一份對象存在,一個進程在獲取到對象之後,需要對對象加鎖,避免其他進程再將其獲取。在進程使用完這個對象之後,還需要顯式地將對象從內存中刪除,並且釋放對對象的加鎖。
- 實現代碼
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();
4. 如何實現一個多例模式?
- 多例模式:“單例”指的是,一個類只能創建一個對象。對應地,“多例”指的就是,一個類可以創建多個對象,但是個數是有限制的,比如只能創建 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 HashMap<>();
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);
}
}