設計模式(4)—— 建立型 ——單例(Singleton)
導航
首先通過懶漢式的單例模式簡單程式碼實現作為開頭,發現有執行緒安全問題,並且在此懶漢模式程式碼上進行改進,衍生出同步懶漢設計模式,雙重檢查懶漢設計模式。另外還有靜態內部類方式實現單例,它是一種基於類初始化的延遲載入解決方案。
與懶漢式相對應的是餓漢式單例模式,其在類載入時就進行初始化例項,所以並不存在懶漢式單例模式存在的執行緒同步安全問題。
除以上探討的單例模式實現外,還列舉了三種實現的單例模式的方法:列舉類,容器,ThreadLocal。
單例模式介紹——摘要
定義: 保證一個類僅有一個例項,並提供一個全域性訪問點
型別:建立型
適用場景:想確保任何情況下只有一個例項
優點:
- 在記憶體中只有一個例項,減少記憶體開銷
- 避免資源的多重佔用
- 設定全域性訪問點,嚴格控制訪問
缺點:沒有介面,擴充套件困難
重點關注幾點:
- 私有構造器
- 執行緒安全
- 延遲載入
後期更新:
- 序列化,反射相關安全性
懶漢式
上程式碼:
/**
* 懶漢。顧名思義,等我們用到的時候再例項化
*/
public class LazySingleton {
private static LazySingleton instance = null; //初始化,為null
//私有構造器:外部不允許直接通過new運算獲取物件例項
private LazySingleton(){
}
//靜態方法,外面方面直接通過靜態方法來獲取例項,不需先例項化類
public static LazySingleton getInstance(){
if(singleton == null){
singleton = new LazySingleton();
}
return singleton;
}
}
上面程式碼很容易理解,外部只能通過唯一的訪問點LazySingleton.getInstance()
但是對於單執行緒來說,這樣寫沒有問題。到了多執行緒我們再分析
getInstance
程式碼塊,很容易發現問題。我們假設現在有兩個執行緒同時來獲取例項:
/**
* 測試用例
* 同時開兩個執行緒,對最簡單的懶漢單例模式進行測試
*/
public class LazyTest {
//主執行緒
public static void main(String[] args) {
//第一個執行緒
new Thread( () ->{
LazySingleton singleton = LazySingleton.getInstance();
System.out.println( "Current Singleton :" + singleton );
} ).start();
//第二個執行緒
new Thread( () -> {
LazySingleton singleton = LazySingleton.getInstance();
System.out.println( "Current Singleton :" + singleton );
} ).start();
}
}
現在分析會出現什麼情況,最簡單的當然是第一個執行緒開始執行,知道它執行結束,再輪到第二個執行緒開始執行。然而我們檢視兩個執行緒的分別呼叫getInstance
的執行圖:
執行緒1執行①程式碼之後,正要執行singleton = new LazySingleton() 的時候,由於CPU執行緒排程,使執行緒2開始執行。執行緒2一路執行下去。並且返回Singleton例項物件。而後,執行緒1繼續執行,直到結束。
很清晰的知道,LazySingleton已經被例項化兩次,違反了Singleton的原則。
有一系列的對最基本的懶漢式改進的方法。
懶漢式——同步(synchronized)
最容易改進的方法,只需新增synchronized關鍵字即可。
// 新增關鍵字同步synchronized,加鎖
public synchronized static LazySingleton getInstance(){
if(singleton == null){
singleton = new LazySingleton();
}
return singleton;
}
當新增此關鍵字的時候,我們再看到上面兩個執行緒的getInstance
執行圖 。 當①執行之後,開始試圖進入執行執行緒2的程式碼。但是此時該程式碼是加鎖的,執行緒2會被 阻塞 ,待執行緒1執行結束之後,執行緒2方可執行。如此就 避免了if判斷的執行緒錯誤 。最後只能獲得一個唯一的例項。
但是:
- 同步鎖的加鎖解鎖較為消耗資源。
- synchronized 關鍵字修飾static函式的時候,其實相當於synchronized整個類,範圍較大,不利於控制。
懶漢式——雙重檢查(Double Check)
上程式碼:
public class LazyDoubleCheckSingleton {
private volatile static LazyDoubleCheckSingleton instance = null; //初始化,為null
//私有構造器:外部不允許直接通過new運算獲取物件例項
private LazyDoubleCheckSingleton(){
}
public static LazyDoubleCheckSingleton getInstance(){
if(instance == null){
synchronized (LazyDoubleCheckSingleton.class){
if(instance == null) {
/****2, 3可以重排序。
*****使用volatile關鍵字禁止重排序
*****/
// 1. 為物件分配記憶體
// 2. 初始化物件
// 3. 設定instance指向剛分配的記憶體地址
instance = new LazyDoubleCheckSingleton();
}
}
}
return instance;
}
}
synchronized關鍵字
從上面的程式碼看到,所謂double check就是進行雙重判斷。相比於直接在static方法上用synchronized,在區域性程式碼塊用synchronized修飾,配合double check使用在效能上更勝一籌。然而如此一來卻有一個坑。
而volatile關鍵字修飾就是為了填補這個坑的。
volatile關鍵字
注意到用於修飾instance 的volatile
關鍵字。這裡集中看到這行程式碼:
instance = new LazyDoubleCheckSingleton();
。
這行程式碼並不是原子操作。所謂原子操作,可以簡單理解為此操作一步執行,不可拆分。 具體到這個例子中,
instance = new LazyDoubleCheckSingleton();
的執行步驟如下:
- 為物件分配記憶體
- 初始化物件
- 設定instance指向剛分配的記憶體地址
- 外部可以開始對instance進行訪問以及其它操作
而java編譯器在編譯時有個指令重排序的概念。在這裡的意思就是2,3執行步驟可能會交換(但並不影響4,也就是說並不影響最終的結果),這樣做的好處是根據具體情況來調整執行順序,提高執行效率。
那麼這就會導致隱藏的程式bug。現在假定執行緒1的執行順序如下
- 為物件分配記憶體
- 設定instance指向剛分配的記憶體地址
- 初始化物件
- 外部可以開始對instance進行訪問以及其它操作
現在假定當上面的步驟2執行完畢。CPU執行緒排程,輪到執行緒2開始執行
getInstance()
程式碼。執行第一步:if(instance == null){/*...*/}
,很顯然,instace已經指向了分配的記憶體地址,所以instance != null
。所以執行緒2的執行結果是直接返回instace物件。執行緒2中的應用層程式碼可以直接獲取到instace例項並且開始使用,但是值得注意的是我們的instance並沒有執行過初始化物件這一步驟,而我們知道,一個物件沒有完成物件初始化就開始使用,在某些情況下是非常嚴重的錯誤,程式bug。
而我們的程式碼private volatile static LazyDoubleCheckSingleton instance = null;
中的volatile就是為了禁止指令重排序的。
當然我們 還有一種解決方案: 就是上面的2,3指令執行步驟在別的執行緒看來是不可見的,也就是說,別的執行緒是把2,3這兩個步驟看成一個整體,外部不能介入其中執行的。
這種解決方案就是下面要介紹的基於***靜態內部類***的解決方案。
靜態內部類方式(基於類初始化的延遲載入解決方案)
直接上程式碼,程式碼很容易。重要的是理解內在JVM機制。
public class OuterSingleton {
private OuterSingleton(){
}
public static OuterSingleton getInstance(){
return InnerClass.staticInnerClassSingleton;
}
private static class InnerClass{
private static OuterSingleton staticInnerClassSingleton = new OuterSingleton();
}
}
我們要獲取的單例,為OuterSingleton類的單例。而靜態內部類作為建立這個實現單例的內在機制。
上面程式碼很容易看懂,值得注意的是兩個private:一個是構造器的private(之前的幾個單例模式也說過,外部只能通過getInstance
獲取單例物件,防止new一個物件);一個是靜態內部類的private,靜態內部類只是實現單例的內在機制,不應暴露給外部。
為什麼靜態內部類能夠代替volatile關鍵字
,解決指令重排序的問題?
JVM在類的初始化階段( Class被載入之後~~執行緒使用之前 )執行類的初始化(也就是我們前面所說的 1,2,3 或者 1,3,2階段)。類的初始化期間,類會去獲取一個鎖,此鎖用於同步多個執行緒對於一個類的初始化。
其中類被初始化會發生在以下幾種情況:什麼時候類會被初始化
餓漢式
從名字可以看出,餓漢式與懶漢式相對應。
餓漢式單例模式實現較為簡單,在類載入時就完成了初始化操作,如此避免了執行緒同步問題。當然缺點也是因為在類載入時就完成了初始化,沒有了懶漢式的延遲載入效果。
public class HungerySingleton {
public static final HungerySingleton instance;
static{
instance = new HungerySingleton();
}
private HungerySingleton(){
}
public static HungerySingleton getInstance(){
return instance;
}
}
當然,也能使用如下方法:
public class HungerySingleton {
public static final HungerySingleton instance = new HungerySingleton();
private HungerySingleton(){
}
public static HungerySingleton getInstance(){
return instance;
}
}
列舉類(Enum)
public enum EnumSingleton {
INSTANCE{
protected void methodTest(){
System.out.println("Method test.");
}
};
protected abstract void methodTest();
private Object data;
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public static EnumInstance getInstance(){
return INSTANCE;
}
}
容器式
- 通過容器儲存 <key,Object>,也就是一個key對應於一個類的單個例項。
- 優點:通過key-value容器儲存,當單例過多時,方便統一管理,節省資源。
- 缺點:執行緒不安全(下面的程式碼實現)
import org.apache.commons.lang3.StringUtils;
import java.util.HashMap;
import java.util.Map;
public class ContainerSingleton {
private ContainerSingleton(){
}
//or `new HashMap<String, Object>();`
private static Map<String,Object> singletonMap = new HashMap<>();
public static void putInstance(String key,Object instance){
if(StringUtils.isNotBlank(key) && instance != null){
if(!singletonMap.containsKey(key)){
singletonMap.put(key,instance);
}
}
}
public static Object getInstance(String key){
return singletonMap.get(key);
}
}
ThreadLocal方式
public class ThreadLocalSingleton {
private static final ThreadLocal<ThreadLocalSingleton> instance =
new ThreadLocal<ThreadLocalSingleton>() {
@Override
protected ThreadLocalSingleton initialValue() {
return new ThreadLocalSingleton();
}
};
private ThreadLocalSingleton(){
}
public static ThreadLocalSingleton getInstance(){
return instance.get();
}
}
這裡所說的單例是相對於執行緒來說的。也就是說在 同一個執行緒內,都共享一個單例的記憶體空間;而對不同執行緒來說,獲取到的是各自執行緒的單例。 如圖: