聊一聊最難的設計模式
很多人上來肯定一臉懵逼,因為在你的印象中,單例模式實現起來還是很簡單的。不要著急,慢慢往下看,你就知道為什麼我說它最難了。
1. 基本概念
- 單例模式是一種常用的建立型設計模式。單例模式保證類僅有一個例項,並提供一個全域性訪問點。
2. 適用場景
-
想確保任何情況下都絕對只有一個例項。
-
典型的場景有:windows 的工作管理員、windows 的回收站、執行緒池的設計等。
3. 單例模式的優缺點
優點
- 記憶體中只有一個例項,減少了記憶體開銷。
- 可以避免對資源的多重佔用。
- 設定全域性訪問點,嚴格控制訪問。
缺點
- 沒有介面,擴充套件困難。
4. 常見的實現模式
- 懶漢式
- 餓漢式
5. 先搞一個懶漢式的玩一玩
public class LazySingleton { // 1. 私有物件 private static LazySingleton lazySingleton = null; // 2. 構造方法私有化 private LazySingleton() {} // 3. 設定全域性訪問點 public static LazySingleton getInstance() { if (lazySingleton == null) { lazySingleton = new LazySingleton(); } return lazySingleton; } }
接下來,我們單執行緒測試
public class MainTest {
public static void main(String[] args) {
LazySingleton instance = LazySingleton.getInstance();
LazySingleton instance2 = LazySingleton.getInstance();
System.out.println(instance == instance2);
}
}
- 測試程式碼及結果如上,一切看著毫無違和感。
那自然而然,我們考慮一下多執行緒如何呢。
- 我們來建立一個執行緒類
public class MyThread implements Runnable {
@Override
public void run() {
LazySingleton instance = LazySingleton.getInstance();
System.out.println(Thread.currentThread().getName() + " " + instance);
}
}
- 然後修改我們的測試程式碼
public class MainTest {
public static void main(String[] args) {
Thread t1 = new Thread(new MyThread());
Thread t2 = new Thread(new MyThread());
t1.start();
t2.start();
System.out.println("program end.");
}
}
-
我們通過 IDEA 自帶的斷點測試來測試多執行緒下的問題,我們在
LazySingleton
如下位置打上斷點。(設定斷點的 Suspend 為 Thread) -
我們通過 debug 方式啟動測試程式碼,然後通過 IDEA 的工具視窗切換執行緒進行檢視。(具體的 IDEA 除錯多執行緒程式碼的方法可以通過各種途徑學習,當然,也可以找我,我教你。雖然我也是略知皮毛。)
-
此時會看到有 Thread-0 和 Thread-1 兩個執行緒,此時兩個執行緒都判斷了
lazySingleton
為空,此時兩個執行緒都會建立物件。 -
將程式碼執行完,此時可以看到控制檯列印的訊息。
- 很明顯地,兩個執行緒拿到的是不同的物件。也就說明了,我們如上的懶漢式程式碼不是執行緒安全的,在多執行緒下可能會創建出多個物件。
那接下來,我們就應該想辦法處理這種情況了。
- 通過在全域性訪問點新增
synchronized
關鍵字處理
// 3. 設定全域性訪問點
public synchronized static LazySingleton getInstance() {
if (lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
如上問題是處理了,但是出現了新的問題,該方法訪問時會加鎖,導致訪問效率降低,但是隻要是判斷和建立物件的時候加鎖即可,大概率情況下,該物件已經創建出來,併發訪問也是沒有什麼問題的。為了實現這個目的,我們又提出了“Double Check 雙重檢查方案”
- 廢話不多說,上程式碼。
public class LazyDoubleCheckSingleton {
private static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;
private LazyDoubleCheckSingleton() {}
public static LazyDoubleCheckSingleton getInstance() {
if (lazyDoubleCheckSingleton == null) {
synchronized (LazyDoubleCheckSingleton.class) {
if (lazyDoubleCheckSingleton == null) {
lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
}
}
}
return lazyDoubleCheckSingleton;
}
}
- 此程式碼便實現了在大概率情況下,
lazyDoubleCheckSingleton
已經不為空,也就不需要獲取到鎖,可以實現多執行緒併發訪問。
但是如上程式碼還是有一些問題的,因為問題很難復現,也就不做演示。問題是由大名鼎鼎的“指令重排序”引起的。
-
來大概說明一下原理,可能不是很準確,但是主要以理解這個問題為目的。
-
其實建立物件(
new LazyDoubleCheckSingleton()
)這個操作在底層我們可以看作三個步驟:- memory = allocate(); // 1:分配物件的記憶體空間
- ctorInstance(memory); // 2:初始化物件
- lazyDoubleCheckSingleton = memory; // 3:設定 lazyDoubleCheckSingleton 指向剛分配的記憶體地址
-
針對這個問題,Java 語言規範中是有要求的,就是必須遵守 intra-thread semantics (執行緒內語義),保證重排序不會改變單執行緒內的程式執行結果。
-
但是在上述例子中,2、3步驟可能會出現重排序,也就是可能出現,先指向記憶體地址,再初始化物件,此時,lazyDoubleCheckSingleton 不為空,但是物件還未初始化完成。問題也就出現了。並且此時重排序操作並不會違反 intra-thread semantics,因為在單執行緒的執行下,此類重排序是不會影響最終結果的。
上一個圖來說明一下指令重排序引起的問題吧
- 此時便會發生:執行緒0中物件未初始化完成,執行緒1就訪問了物件。
那問題來了,也就該處理了。
- 針對以上問題,我們處理思路其實有兩種:
- 不允許步驟 2、3 進行重排序。
- 允許步驟 2、3 進行重排序,但是這個重排序過程不能讓其他執行緒看到。
不允許步驟 2、3 進行重排序
- 只需要物件新增
volatile
關鍵字即可。
private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;
- 具體其中的原理,會在其他內容中進行分析,不是此次的重點。
允許步驟 2、3 進行重排序,但是這個重排序過程不能讓其他執行緒看到。
基於靜態內部類的解決方案
public class StaticInnerClassSingleton {
// InnerClass 物件鎖
private static class InnerClass {
private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
}
private StaticInnerClassSingleton() {}
public static StaticInnerClassSingleton getInstance() {
return InnerClass.staticInnerClassSingleton;
}
}
到此為止,咱們的懶漢式先告一段落啊。。。喪心病狂呀,有木有。。。
6. 那咱們就再來玩玩餓漢式
public class HungrySingleton {
private final static HungrySingleton hungrySingleton;
static {
hungrySingleton = new HungrySingleton();
}
private HungrySingleton() {}
public static HungrySingleton getInstance() {
return hungrySingleton;
}
}
- 這個東西在多執行緒下就好點了,因為餓漢式是在類初始化的時候便把物件建立好了,所以也不需要判斷物件是不是空,當然,在多執行緒下也就沒那麼多需要我們考慮的了。
7. 然後,然後,咱們再來看看序列化和反序列化的情況下,單例模式有沒有什麼問題呢。
- 因序列化問題與懶漢式還是餓漢式實現無關,以下便以餓漢式程式碼為例展示。
餓漢式
- 首先,我們的單例類實現序列化
public class HungrySingleton implements Serializable {
// ...
}
- 然後我們來寫一個測試程式碼
public class MainTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
HungrySingleton instance = HungrySingleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.txt"));
oos.writeObject(instance);
File file = new File("test.txt");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
HungrySingleton newInstance = (HungrySingleton) ois.readObject();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
}
}
- 我們來看一下執行結果
- 哈哈哈哈,瞬間窒息了,有木有。。。
針對上面問題,咱們來看一看原始碼,找一找原因啊。
- 如下是跟蹤原始碼的過程,我只做簡單截圖,有興趣可自行研究(哈哈哈,或者你可以找我呀,我們一起研究)。
看到這兒,我感覺你應該也就知道了,desc.isInstantiable()
方法返回了true
,所以通過反射new
了一個新的物件,導致讀出的物件與寫入的物件不是同一個物件。
那你一定想問我,那怎麼處理呢,彆著急啊,接著往下看。
- 這個變數的初始化,可以直接通過查詢看到。
這不就清楚了嘛,有readResolve()
方法的時候,直接通過呼叫該方法返回了單例物件,那我們處理起來也就簡單了,為我們的單例類新增一個方法即可。
private Object readResolve() {
return hungrySingleton;
}
- 然後重新直接測試程式碼,會出現如下結果。
8. 序列化和序列化的問題說完了,咱們再來看看反射的問題吧,畢竟反射我們用的還是很多的,通過反射去建立一個物件也是常用的操作。
該問題針對兩種方式是不一樣的,我們先來看看餓漢式的表現。
- 我們來寫個測試程式碼
public class MainTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class objectClass = HungrySingleton.class;
Constructor constructor = objectClass.getDeclaredConstructor();
constructor.setAccessible(true);
HungrySingleton instance = HungrySingleton.getInstance();
HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
}
}
- 看看執行結果
有一種五雷轟頂的感覺了沒,彆著急,彆著急,咱們慢慢搞啊,雖然花點時間,但是能搞到很多東西的。
既然問題出來了,那怎麼處理呢?其實處理也簡單,因為反射是講私有構造方法許可權進行了開放,那我們在私有構造中新增判斷即可。
private HungrySingleton() {
if (hungrySingleton != null) {
throw new RuntimeException("單例構造器禁止反射呼叫!");
}
}
- 再來執行我們的測試程式碼,可以看到會丟擲以下異常。
接下來我們分析分析懶漢式
- 與餓漢式新增同樣的操作,也是避免不了反射的。
- 假如先使用getInstance()方法獲取物件,然後使用反射建立物件,是可以丟擲異常的。
- 但是當先使用反射建立物件,再通過getInstance()方法獲取物件時,便可以獲取到兩個不同的物件,還是避免不了對單例模式的破壞。
最終的結論,懶漢式是無法防止反射攻擊的。
9. 然後估計你就快暈了,你肯定想問,難道以後做一個單例都要考慮這麼多問題嘛,也太墨跡了點吧。那咱們接下來就看看用列舉來實現單例的方法吧。
- 該方法為Effective Java書中推薦的用法。
- 該方法完美解決了序列化及反射對單例模式的破壞。
上程式碼
public enum EnumInstance {
INSTANCE;
private Object data;
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public static EnumInstance getInstance() {
return INSTANCE;
}
}
上面既然說了,完美解決了序列化及反射對單例模式的破壞,那咱們接下來就看看是如何解決的。
解決序列化對單例模式的破壞
- 我們還是來看
ObjectInputStream.readObject()
方法
- 可以看出是使用名稱通過反射去獲取到
Enum
,並沒有建立新的物件,所以獲取到的是同一個物件。
解決反射對單例模式的破壞
- 來寫一個測試程式碼
public class MainTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class objectClass = EnumInstance.class;
Constructor constructor = objectClass.getDeclaredConstructor();
constructor.setAccessible(true);
EnumInstance instance = EnumInstance.getInstance();
EnumInstance newInstance = (EnumInstance) constructor.newInstance();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
}
}
- 結果
- 來看一下
java.lang.Enum
類,我們可以看到只有一個構造方法,且需要兩個引數。
- 那我們就來傳入兩個引數試一下。
- 最終的結果
- 我們來看一下原因啊,請看
constructor.newInstance()
方法
-
發現其對 Enum 型別進行了處理,不允許通過反射建立 Enum 物件。
-
至此我們也就明白了,為什麼 Enum 單例可以完美防止序列化及反射對單例模式的破壞了。
OK 了,我們再來搞兩個相關的東西
10. 我們來聊聊容器單例
- 為了方便,使用 HashMap 來實現一個容器單例
直接走程式碼
public class ContainerSingleton {
private static Map<String, Object> singletonMap = new HashMap<>();
private ContainerSingleton() {}
public static void putInstance(String key, Object instance) {
if (key != null && !"".equals(key) && instance != null) {
if (!singletonMap.containsKey(key)) {
singletonMap.put(key, instance);
}
}
}
public static Object getInstance(String key) {
return singletonMap.get(key);
}
}
針對上述程式碼的說明
-
因其key 相同,所以最終獲取到的是同一個物件。
-
但是上述程式碼是執行緒不安全的。在多執行緒情況下,如果兩個執行緒同時判斷 if 條件成立,此時 t1 執行緒 put,t1 執行緒 get;然後 t2 執行緒 put ,t2 執行緒 get 時,t1 執行緒與 t2 執行緒獲取到的物件是不同的。
-
如果此時容器單例不使用 HashMap,而使用 HashTable 是可以實現執行緒安全的,但是從效能考慮,假如 get 請求多的情況下,HashTable 效率會非常低下。
11. 最後一個,我們來看看 ThreadLocal 執行緒單例怎麼實現
定義一個執行緒單例類
public class ThreadLocalInstance {
private static final ThreadLocal<ThreadLocalInstance> threadLocalInstanceThreadLocal =
new ThreadLocal<ThreadLocalInstance>() {
@Override
protected ThreadLocalInstance initialValue() {
return new ThreadLocalInstance();
}
};
private ThreadLocalInstance() {}
public static ThreadLocalInstance getInstance() {
return threadLocalInstanceThreadLocal.get();
}
}
實現一個執行緒類做測試
public class MyThread implements Runnable {
@Override
public void run() {
ThreadLocalInstance instance = ThreadLocalInstance.getInstance();
System.out.println(Thread.currentThread().getName() + " " + instance);
}
}
寫一個測試程式碼來測試一下
public class MainTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
Thread t1 = new Thread(new MyThread());
Thread t2 = new Thread(new MyThread());
t1.start();
t2.start();
System.out.println("program end.");
}
}
結果
我們今天的討論到現在就結束了。今天主要討論了入如下內容。
- 基本的單例模式的實現:懶漢式和餓漢式。
- 針對多執行緒下的單例模式執行緒安全的討論。
- 序列化和反序列化對單例模式的破壞。
- 反射對單例模式的破壞。
- Enum 列舉單例。
- 單例容器。
- ThreadLocal 執行緒單例。