單例模式是 Java 中最簡單的設計模式之一,它是指一個類在運行期間始終只有一個實例,我們就把它稱之為單例模式。它不但被應用在實際的工作中,而且還是面試中最常考的題目之一。通過單例模式我們可以知道此人的編程風格,以及對于基礎知識的掌握是否牢固。
我們本課時的面試題是,單例的實現方式有幾種?它們有什么優缺點?
#### 典型回答
單例的實現分為餓漢模式和懶漢模式。顧名思義,餓漢模式就好比他是一個餓漢,而且有一定的危機意識,他會提前把食物囤積好,以備餓了之后直接能吃到食物。對應到程序中指的是,在類加載時就會進行單例的初始化,以后訪問時直接使用單例對象即可。
餓漢模式的實現代碼如下:
```
public class Singleton {
// 聲明私有對象
private static Singleton instance = new Singleton();
// 獲取實例(單例對象)
public static Singleton getInstance() {
return instance;
}
private Singleton() {
}
// 方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
// 調用單例對象
Singleton singleton = Singleton.getInstance();
// 調用方法
singleton.sayHi();
}
}
```
以上程序的執行結果為:
```
Hi,Java.
```
從上述結果可以看出,單例對象已經被成功獲取到并順利地執行了類中的方法。它的優點是線程安全,因為單例對象在類加載的時候就已經被初始化了,當調用單例對象時只是把早已經創建好的對象賦值給變量;它的缺點是可能會造成資源浪費,如果類加載了單例對象(對象被創建了),但是一直沒有使用,這樣就造成了資源的浪費。
懶漢模式也被稱作為飽漢模式,顧名思義他比較懶,每次只有需要吃飯的時候,才出去找飯吃,而不是像餓漢那樣早早把飯準備好。對應到程序中指的是,當每次需要使用實例時,再去創建獲取實例,而不是在類加載時就將實例創建好。
懶漢模式的實現代碼如下:
```
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
private Singleton() {
}
// 方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.sayHi();
}
}
```
以上程序的執行結果為:
```
Hi,Java.
```
從上述結果可以看出,單例對象已經被成功獲取到并順利地執行了類中的方法,它的優點是不會造成資源的浪費,因為在調用的時候才會創建被實例化對象;它的缺點在多線程環境下是非線程是安全的,比如多個線程同時執行到 if 判斷處,此時判斷結果都是未被初始化,那么這些線程就會同時創建 n 個實例,這樣就會導致意外的情況發生。
#### 考點分析
使用單例模式可以減少系統的內存開銷,提高程序的運行效率,但是使用不當的話就會造成多線程下的并發問題。餓漢模式為最直接的實現單例模式的方法,但它可能會造成對系統資源的浪費,所以只有既能保證線程安全,又可以避免系統資源被浪費的回答才能徹底地征服面試官。
和此知識點相關的面試題還有以下這些:
* 什么是雙重檢測鎖?它是線程安全的嗎?
* 單例的還有其他實現方式嗎?
#### 知識擴展
* [ ] 雙重檢測鎖
為了保證懶漢模式的線程安全我們最簡單的做法就是給獲取實例的方法上加上 synchronized(同步鎖)修飾,如下代碼所示:
```
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
```
這樣雖然能讓懶漢模式變成線程安全的,但由于整個方法都被 synchronized 所包圍,因此增加了同步開銷,降低了程序的執行效率。
于是為了改進程序的執行效率,我們將 synchronized 放入到方法中,以此來減少被同步鎖所修飾的代碼范圍,實現代碼如下:
```
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
```
細心的你可能會發現以上的代碼也存在著非線程安全的問題。例如,當兩個線程同時執行到「if (instance == null) { 」判斷時,判斷的結果都為 true,于是他們就排隊都創建了新的對象,這顯然不符合我們的預期。于是就誕生了大名鼎鼎的雙重檢測鎖(Double Checked Lock,DCL),實現代碼如下:
```
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
// 第一次判斷
if (instance == null) {
synchronized (Singleton.class) {
// 第二次判斷
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
```
上述代碼看似完美,其實隱藏著一個不容易被人發現的小問題,該問題就出在 new 對象這行代碼上,也就是 instance = new Singleton() 這行代碼。這行代碼看似是一個原子操作,然而并不是,這行代碼最終會被編譯成多條匯編指令,它大致的執行流程為以下三個步驟:
* 給對象實例分配內存空間;
* 調用對象的構造方法、初始化成員字段;
* 將 instance 對象指向分配的內存空間。
但由于 CPU 的優化會對執行指令進行重排序,也就說上面的執行流程的執行順序有可能是 1-2-3,也有可能是 1-3-2。假如執行的順序是 1-3-2,那么當 A 線程執行到步驟 3 時,切換至 B 線程了,而此時 B 線程判斷 instance 對象已經指向了對應的內存空間,并非為 null 時就會直接進行返回,而此時因為沒有執行步驟 2,因此得到的是一個未初始化完成的對象,這樣就導致了問題的誕生。執行時間節點如下表所示:
|時間點 | 線程 | 執行操作 |
| --- | --- | --- |
| t1 | A |instance = new Singleton() 的 1-3 步驟,待執行步驟2
|
| t2 | B | if (instance == null) { 判斷結果為 false|
| t3 | B | 返回半初始的 instance 對象|
為了解決此問題,我們可以使用關鍵字 volatile 來修飾 instance 對象,這樣就可以防止 CPU 指令重排,從而完美地運行懶漢模式,實現代碼如下:
```
public class Singleton {
// 聲明私有對象
private volatile static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
// 第一次判斷
if (instance == null) {
synchronized (Singleton.class) {
// 第二次判斷
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
```
* [ ] 單例其他實現方式
除了以上的 6 種方式可以實現單例模式外,還可以使用靜態內部類和枚舉類來實現單例。靜態內部類的實現代碼如下:
```
public class Singleton {
// 靜態內部類
private static class SingletonInstance {
private static final Singleton instance = new Singleton();
}
// 獲取實例(單例對象)
public static Singleton getInstance() {
return SingletonInstance.instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
```
從上述代碼可以看出,靜態內部類和餓漢方式有異曲同工之妙,它們都采用了類裝載的機制來保證,當初始化實例時只有一個線程執行,從而保證了多線程下的安全操作。JVM 會在類初始化階段(也就是類裝載階段)創建一個鎖,該鎖可以保證多個線程同步執行類初始化的工作,因此在多線程環境下,類加載機制依然是線程安全的。
但靜態內部類和餓漢方式也有著細微的差別,餓漢方式是在程序啟動時就會進行加載,因此可能造成資源的浪費;而靜態內部類只有在調用 getInstance() 方法時,才會裝載內部類從而完成實例的初始化工作,因此不會造成資源浪費的問題。由此可知,此方式也是較為推薦的單例實現方式。
單例的另一種實現方式為枚舉,它也是《Effective Java》作者極力推薦地單例實現方式,因為枚舉的實現方式不僅是線程安全的,而且只會裝載一次,無論是序列化、反序列化、反射還是克隆都不會新創建對象。它的實現代碼如下:
```
public class Singleton {
// 枚舉類型是線程安全的,并且只會裝載一次
private enum SingletonEnum {
INSTANCE;
// 聲明單例對象
private final Singleton instance;
// 實例化
SingletonEnum() {
instance = new Singleton();
}
private Singleton getInstance() {
return instance;
}
}
// 獲取實例(單例對象)
public static Singleton getInstance() {
return SingletonEnum.INSTANCE.getInstance();
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.sayHi();
}
}
```
以上程序的執行結果為:
```
Hi,Java.
```
#### 小結
本課時我們講了 8 種實現單例的方式,包括線程安全但可能會造成系統資源浪費的餓漢模式,以及懶漢模式和懶漢模式變種的 5 種實現方式。其中包含了兩種雙重檢測鎖的懶漢變種模式,還有最后兩種線程安全且可以實現延遲加載的靜態內部類的實現方式和枚舉類的實現方式,其中比較推薦使用的是后兩種單例模式的實現方式。
#### 課后問答
* 1、老師,枚舉類是怎么保證單例的,底層的內存模型是怎么支持的呢???
講師回復: 這個可以通過查看枚舉類的字節碼發現秘密所在哦,枚舉類最終會被編譯為 final 類型的并且將枚舉值標識為 static 類型的,因為 static 類型的屬性會在類被加載之后被初始化,所以枚舉類就是線程安全的了。
* 2、老師我有個疑惑哈,雙重檢測的單例模式下,第二次檢測是被synchronized修飾的代碼塊,那說明在這一段代碼塊中只能單線程運行吧,單線程的指令重排序是不會影響執行結果的吧?為什么還要用volatile修飾呢?明明synchronized就已經保證了可見性呀。麻煩老師解答一下,謝謝。
講師回復: 單線程下沒問題,有問題都是發生在多線程環境下,當兩個線程同時執行到「if (instance == null) { 」判斷時,判斷的結果都為 true,于是它們就排隊都創建了多個新的對象,所以就有問題。
- 前言
- 開篇詞
- 開篇詞:大廠技術面試“潛規則”
- 模塊一:Java 基礎
- 第01講:String 的特點是什么?它有哪些重要的方法?
- 第02講:HashMap 底層實現原理是什么?JDK8 做了哪些優化?
- 第03講:線程的狀態有哪些?它是如何工作的?
- 第04講:詳解 ThreadPoolExecutor 的參數含義及源碼執行流程?
- 第05講:synchronized 和 ReentrantLock 的實現原理是什么?它們有什么區別?
- 第06講:談談你對鎖的理解?如何手動模擬一個死鎖?
- 第07講:深克隆和淺克隆有什么區別?它的實現方式有哪些?
- 第08講:動態代理是如何實現的?JDK Proxy 和 CGLib 有什么區別?
- 第09講:如何實現本地緩存和分布式緩存?
- 第10講:如何手寫一個消息隊列和延遲消息隊列?
- 模塊二:熱門框架
- 第11講:底層源碼分析 Spring 的核心功能和執行流程?(上)
- 第12講:底層源碼分析 Spring 的核心功能和執行流程?(下)
- 第13講:MyBatis 使用了哪些設計模式?在源碼中是如何體現的?
- 第14講:SpringBoot 有哪些優點?它和 Spring 有什么區別?
- 第15講:MQ 有什么作用?你都用過哪些 MQ 中間件?
- 模塊三:數據庫相關
- 第16講:MySQL 的運行機制是什么?它有哪些引擎?
- 第17講:MySQL 的優化方案有哪些?
- 第18講:關系型數據和文檔型數據庫有什么區別?
- 第19講:Redis 的過期策略和內存淘汰機制有什么區別?
- 第20講:Redis 怎樣實現的分布式鎖?
- 第21講:Redis 中如何實現的消息隊列?實現的方式有幾種?
- 第22講:Redis 是如何實現高可用的?
- 模塊四:Java 進階
- 第23講:說一下 JVM 的內存布局和運行原理?
- 第24講:垃圾回收算法有哪些?
- 第25講:你用過哪些垃圾回收器?它們有什么區別?
- 第26講:生產環境如何排除和優化 JVM?
- 第27講:單例的實現方式有幾種?它們有什么優缺點?
- 第28講:你知道哪些設計模式?分別對應的應用場景有哪些?
- 第29講:紅黑樹和平衡二叉樹有什么區別?
- 第30講:你知道哪些算法?講一下它的內部實現過程?
- 模塊五:加分項
- 第31講:如何保證接口的冪等性?常見的實現方案有哪些?
- 第32講:TCP 為什么需要三次握手?
- 第33講:Nginx 的負載均衡模式有哪些?它的實現原理是什么?
- 第34講:Docker 有什么優點?使用時需要注意什么問題?
- 彩蛋
- 彩蛋:如何提高面試成功率?