本文已收錄到個人 github 地址: https://github.com/allentofight/easy-cs ,歡迎你們關注並給個 star,這對我很是重要,感謝支持!以後碼海的每篇文章都會收錄至此地址以方便你們查閱!java
單例模式能夠說是設計模式中最簡單和最基礎的一種設計模式了,哪怕是一個初級開發,在被問到使用過哪些設計模式的時候,估計多數會說單例模式。可是你認爲這麼基本的」單例模式「真的就那麼簡單嗎?或許你會反問:「一個簡單的單例模式該是咋樣的?」哈哈,話很少說,讓咱們一塊兒拭目以待,堅持看完,相信你必定會有收穫!git
餓漢式是最多見的也是最不須要考慮太多的單例模式,由於他不存在線程安全問題,餓漢式也就是在類被加載的時候就建立實例對象。餓漢式的寫法以下:github
public class SingletonHungry {
private static SingletonHungry instance = new SingletonHungry();
private SingletonHungry() {
}
private static SingletonHungry getInstance() {
return instance;
}
}
class A {
public static void main(String[] args) {
IntStream.rangeClosed(1, 5)
.forEach(i -> {
new Thread(
() -> {
SingletonHungry instance = SingletonHungry.getInstance();
System.out.println("instance = " + instance);
}
).start();
});
}
}
優勢:線程安全,不須要關心併發問題,寫法也是最簡單的。web
缺點:在類被加載的時候對象就會被建立,也就是說無論你是否是用到該對象,此對象都會被建立,浪費內存空間面試
如下是最基本的餓漢式的寫法,在單線程狀況下,這種方式是很是完美的,可是咱們實際程序執行基本都不多是單線程的,因此這種寫法一定會存在線程安全問題設計模式
public class SingletonLazy {
private SingletonLazy() {
}
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (null == instance) {
return new SingletonLazy();
}
return instance;
}
}
演示多線程執行安全
class B {
public static void main(String[] args) {
IntStream.rangeClosed(1, 5)
.forEach(i -> {
new Thread(
() -> {
SingletonLazy instance = SingletonLazy.getInstance();
System.out.println("instance = " + instance);
}
).start();
});
}
}
結果很顯然,獲取的實例對象不是單例的。也就是說這種寫法不是線程安全的,也就不能在多線程狀況下使用多線程
DCL 即 Double Check Lock 就是在建立實例的時候進行雙重檢查,首先檢查實例對象是否爲空,若是不爲空將當前類上鎖,而後再判斷一次該實例是否爲空,若是仍然爲空就建立該是實例;代碼以下:併發
public class SingleTonDcl {
private SingleTonDcl() {
}
private static SingleTonDcl instance = null;
public static SingleTonDcl getInstance() {
if (null == instance) {
synchronized (SingleTonDcl.class) {
if (null == instance) {
instance = new SingleTonDcl();
}
}
}
return instance;
}
}
測試代碼以下:編輯器
class C {
public static void main(String[] args) {
IntStream.rangeClosed(1, 5)
.forEach(i -> {
new Thread(
() -> {
SingleTonDcl instance = SingleTonDcl.getInstance();
System.out.println("instance = " + instance);
}
).start();
});
}
}
相信大多數初學者在接觸到這種寫法的時候已經感受是「高大上」了,首先是判斷實例對象是否爲空,若是爲空那麼就將該對象的 Class 做爲鎖,這樣保證同一時刻只能有一個線程進行訪問,而後再次判斷實例對象是否爲空,最後纔會真正的去初始化建立該實例對象。一切看起來彷佛已經沒有破綻,可是當你學過JVM後你可能就會一眼看出貓膩了。沒錯,問題就在 instance = new SingleTonDcl(); 由於這不是一個原子的操做,這句話的執行是在 JVM 層面分如下三步:
1.給 SingleTonDcl 分配內存空間 2.初始化 SingleTonDcl 實例 3.將 instance 對象指向分配的內存空間( instance 爲 null 了)
正常狀況下上面三步是順序執行的,可是實際上JVM可能會「自做多情」得將咱們的代碼進行優化,可能執行的順序是一、三、2,以下代碼所示
public static SingleTonDcl getInstance() {
if (null == instance) {
synchronized (SingleTonDcl.class) {
if (null == instance) {
1. 給 SingleTonDcl 分配內存空間
3.將 instance 對象指向分配的內存空間( instance 不爲 null 了)
2. 初始化 SingleTonDcl 實例
}
}
}
return instance;
}
假設如今有兩個線程 t1, t2
該怎麼解決呢,既然問題出在指令有可能重排序上,不讓它重排序不就好了,volatile 不就是幹這事的嗎,咱們能夠在 instance 變量前面加上一個 volatile 修飾符
畫外音:volatile 的做用
1.保證的對象內存可見性
2.防止指令重排序
優化後的代碼以下
public class SingleTonDcl {
private SingleTonDcl() {
}
//在對象前面添加 volatile 關鍵字便可
volatile private static SingleTonDcl instance = null;
public static SingleTonDcl getInstance() {
if (null == instance) {
synchronized (SingleTonDcl.class) {
if (null == instance) {
instance = new SingleTonDcl();
}
}
}
return instance;
}
}
到這裏彷佛問題已經解決了,雙重鎖機制 + volatile 實際上確實基本上解決了線程安全問題,保證了「真正」的單例。但真的是這樣的嗎?繼續往下看
先看代碼
public class SingleTonStaticInnerClass {
private SingleTonStaticInnerClass() {
}
private static class HandlerInstance {
private static SingleTonStaticInnerClass instance = new SingleTonStaticInnerClass();
}
public static SingleTonStaticInnerClass getInstance() {
return HandlerInstance.instance;
}
}
class D {
public static void main(String[] args) {
IntStream.rangeClosed(1, 5)
.forEach(i->{
new Thread(()->{
SingleTonStaticInnerClass instance = SingleTonStaticInnerClass.getInstance();
System.out.println("instance = " + instance);
}).start();
});
}
}
靜態內部類的特色:
這種寫法使用 JVM 類加載機制保證了線程安全問題;因爲 SingleTonStaticInnerClass 是私有的,除了 getInstance() 以外沒有辦法訪問它,所以它是懶漢式的;同時讀取實例的時候不會進行同步,沒有性能缺陷;也不依賴 JDK 版本;
可是,它依舊不是完美的。
上面實現單例都不是完美的,主要有兩個緣由
首先要提到 java 中讓人又愛又恨的反射機制, 閒言少敘,咱們直接邊上代碼邊說明,這裏就以 DCL 舉例(爲何選擇 DCL 由於不少人以爲 DCL 寫法是最高大上的....這裏就開始去」打他們的臉「)
將上面的 DCl 的測試代碼修改以下:
class C {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class<SingleTonDcl> singleTonDclClass = SingleTonDcl.class;
//獲取類的構造器
Constructor<SingleTonDcl> constructor = singleTonDclClass.getDeclaredConstructor();
//把構造器私有權限放開
constructor.setAccessible(true);
//反射建立實例 注意反射建立要放在前面,纔會攻擊成功,由於若是反射攻擊在後面,先使用正常的方式建立實例的話,在構造器中判斷是能夠防止反射攻擊、拋出異常的,
//由於先使用正常的方式已經建立了實例,會進入if
SingleTonDcl instance = constructor.newInstance();
//正常的獲取實例方式 正常的方式放在反射建立實例後面,這樣當反射建立成功後,單例對象中的引用其實仍是空的,反射攻擊才能成功
SingleTonDcl instance1 = SingleTonDcl.getInstance();
System.out.println("instance1 = " + instance1);
System.out.println("instance = " + instance);
}
}
竟然是兩個對象!心裏是否是異常平靜?果真和你想的不同?其餘的方式基本相似,均可以經過反射破壞單例。
咱們以「餓漢式單例」爲例來演示一下序列化和反序列化攻擊代碼,首先給餓漢式單例對應的類添加實現 Serializable 接口的代碼,
public class SingletonHungry implements Serializable {
private static SingletonHungry instance = new SingletonHungry();
private SingletonHungry() {
}
private static SingletonHungry getInstance() {
return instance;
}
}
而後看看如何使用序列化和反序列化進行攻擊
SingletonHungry instance = SingletonHungry.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file")));
// 序列化【寫】操做
oos.writeObject(instance);
File file = new File("singleton_file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))
// 反序列化【讀】操做
SingletonHungry newInstance = (SingletonHungry) ois.readObject();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
來看下結果
果真出現了兩個不一樣的對象!這種反序列化攻擊其實解決方式也簡單,重寫反序列化時要調用的 readObject 方法便可
private Object readResolve(){
return instance;
}
這樣在反序列化時候永遠只讀取 instance 這一個實例,保證了單例的實現。
public enum SingleTonEnum {
/**
* 實例對象
*/
INSTANCE;
public void doSomething() {
System.out.println("doSomething");
}
}
調用方法
public class Main {
public static void main(String[] args) {
SingleTonEnum.INSTANCE.doSomething();
}
}
枚舉模式實現的單例纔是真正的單例模式,是完美的實現方式
有人可能會提出疑問:枚舉是否是也能經過反射來破壞其單例實現呢?
試試唄,修改枚舉的測試類
class E{
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class<SingleTonEnum> singleTonEnumClass = SingleTonEnum.class;
Constructor<SingleTonEnum> declaredConstructor = singleTonEnumClass.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
SingleTonEnum singleTonEnum = declaredConstructor.newInstance();
SingleTonEnum instance = SingleTonEnum.INSTANCE;
System.out.println("instance = " + instance);
System.out.println("singleTonEnum = " + singleTonEnum);
}
}
沒有無參構造?咱們使用 javap 工具來查下字節碼看看有啥玄機
好傢伙,發現一個有參構造器 String Int ,那就試試唄
//獲取構造器的時候修改爲這樣子
Constructor<SingleTonEnum> declaredConstructor = singleTonEnumClass.getDeclaredConstructor(String.class,int.class);
好傢伙,拋出了異常,異常信息寫着: 「Cannot reflectively create enum objects」
源碼之下無祕密,咱們來看看 newInstance() 到底作了什麼?爲啥用反射建立枚舉會拋出這麼個異常?
真相大白!若是是枚舉,不容許經過反射來建立,這纔是使用 enum 建立單例才能夠說是真正安全的緣由!
以上就是一些關於單例模式的知識點彙總,你還真不要小看這個小小的單例,面試的時候多數候選人寫不對這麼一個簡單的單例,寫對的多數也僅止於 DCL,但再問是否有啥不安全,如何用 enum 寫出安全的單例時,幾乎沒有人能答出來!有人說能寫出 DCL 就好了,何須這麼鑽牛角尖?但我想說的是正是這種鑽牛角尖的精神能讓你逐步積累技術深度,成爲專家,對技術有一探究竟的執著,何愁成不了專家?
最後歡迎你們關注個人公號,加我好友:「geekoftaste」,一塊兒交流,共同進步!