我向面試官講解了單例模式,他對(duì)我豎起了大拇指
單例模式相信大家都有所聽聞,甚至也寫過不少了,在面試中也是考得最多的其中一個(gè)設(shè)計(jì)模式,面試官常常會(huì)要求寫出兩種類型的單例模式并且解釋其原理,廢話不多說,我們開始學(xué)習(xí)如何很好地回答這一道面試題吧。
什么是單例模式
面試官問什么是單例模式時(shí),千萬不要答非所問,給出單例模式有兩種類型之類的回答,要圍繞單例模式的定義去展開。
單例模式是指在內(nèi)存中只會(huì)創(chuàng)建且僅創(chuàng)建一次對(duì)象的設(shè)計(jì)模式。在程序中多次使用同一個(gè)對(duì)象且作用相同時(shí),為了防止頻繁地創(chuàng)建對(duì)象使得內(nèi)存飆升,單例模式可以讓程序僅在內(nèi)存中創(chuàng)建一個(gè)對(duì)象,讓所有需要調(diào)用的地方都共享這一單例對(duì)象。
單例模式的類型
單例模式有兩種類型:
- 懶漢式:在真正需要使用對(duì)象時(shí)才去創(chuàng)建該單例類對(duì)象
- 餓漢式:在類加載時(shí)已經(jīng)創(chuàng)建好該單例對(duì)象,等待被程序使用
懶漢式創(chuàng)建單例對(duì)象
懶漢式創(chuàng)建對(duì)象的方法是在程序使用對(duì)象前,先判斷該對(duì)象是否已經(jīng)實(shí)例化(判空),若已實(shí)例化直接返回該類對(duì)象。否則則先執(zhí)行實(shí)例化操作。
根據(jù)上面的流程圖,就可以寫出下面的這段代碼
- public class Singleton {
- private static Singleton singleton;
- private Singleton(){}
- public static Singleton getInstance() {
- if (singleton == null) {
- singleton = new Singleton();
- }
- return singleton;
- }
- }
沒錯(cuò),這里我們已經(jīng)寫出了一個(gè)很不錯(cuò)的單例模式,不過它不是完美的,但是這并不影響我們使用這個(gè)“單例對(duì)象”。
以上就是懶漢式創(chuàng)建單例對(duì)象的方法,我會(huì)在后面解釋這段代碼在哪里可以優(yōu)化,存在什么問題。
餓漢式創(chuàng)建單例對(duì)象
餓漢式在類加載時(shí)已經(jīng)創(chuàng)建好該對(duì)象,在程序調(diào)用時(shí)直接返回該單例對(duì)象即可,即我們在編碼時(shí)就已經(jīng)指明了要馬上創(chuàng)建這個(gè)對(duì)象,不需要等到被調(diào)用時(shí)再去創(chuàng)建。
關(guān)于類加載,涉及到JVM的內(nèi)容,我們目前可以簡單認(rèn)為在程序啟動(dòng)時(shí),這個(gè)單例對(duì)象就已經(jīng)創(chuàng)建好了。
- public class Singleton{
- private static final Singleton singleton = new Singleton();
- private Singleton(){}
- public static Singleton getInstance() {
- return singleton;
- }
- }
注意上面的代碼在第3行已經(jīng)實(shí)例化好了一個(gè)Singleton對(duì)象在內(nèi)存中,不會(huì)有多個(gè)Singleton對(duì)象實(shí)例存在
類在加載時(shí)會(huì)在堆內(nèi)存中創(chuàng)建一個(gè)Singleton對(duì)象,當(dāng)類被卸載時(shí),Singleton對(duì)象也隨之消亡了。
懶漢式如何保證只創(chuàng)建一個(gè)對(duì)象
我們再來回顧懶漢式的核心方法
- public static Singleton getInstance() {
- if (singleton == null) {
- singleton = new Singleton();
- }
- return singleton;
- }
這個(gè)方法其實(shí)是存在問題的,試想一下,如果兩個(gè)線程同時(shí)判斷 singleton 為空,那么它們都會(huì)去實(shí)例化一個(gè)Singleton 對(duì)象,這就變成多例了。所以,我們要解決的是線程安全問題。
最容易想到的解決方法就是在方法上加鎖,或者是對(duì)類對(duì)象加鎖,程序就會(huì)變成下面這個(gè)樣子
- public static synchronized Singleton getInstance() {
- if (singleton == null) {
- singleton = new Singleton();
- }
- return singleton;
- }
- // 或者
- public static Singleton getInstance() {
- synchronized(Singleton.class) {
- if (singleton == null) {
- singleton = new Singleton();
- }
- }
- return singleton;
- }
這樣就規(guī)避了兩個(gè)線程同時(shí)創(chuàng)建Singleton對(duì)象的風(fēng)險(xiǎn),但是引來另外一個(gè)問題:每次去獲取對(duì)象都需要先獲取鎖,并發(fā)性能非常地差,極端情況下,可能會(huì)出現(xiàn)卡頓現(xiàn)象。接下來要做的就是優(yōu)化性能:目標(biāo)是如果沒有實(shí)例化對(duì)象則加鎖創(chuàng)建,如果已經(jīng)實(shí)例化了,則不需要加鎖,直接獲取實(shí)例
所以直接在方法上加鎖的方式就被廢掉了,因?yàn)檫@種方式無論如何都需要先獲取鎖
- public static Singleton getInstance() {
- if (singleton == null) { // 線程A和線程B同時(shí)看到singleton = null,如果不為null,則直接返回singleton
- synchronized(Singleton.class) { // 線程A或線程B獲得該鎖進(jìn)行初始化
- if (singleton == null) { // 其中一個(gè)線程進(jìn)入該分支,另外一個(gè)線程則不會(huì)進(jìn)入該分支
- singleton = new Singleton();
- }
- }
- }
- return singleton;
- }
上面的代碼已經(jīng)完美地解決了并發(fā)安全 + 性能低效問題:
- 第 2 行代碼,如果 singleton 不為空,則直接返回對(duì)象,不需要獲取鎖;而如果多個(gè)線程發(fā)現(xiàn) singleton 為空,則進(jìn)入分支;
- 第 3 行代碼,多個(gè)線程嘗試爭搶同一個(gè)鎖,只有一個(gè)線程爭搶成功,第一個(gè)獲取到鎖的線程會(huì)再次判斷singleton 是否為空,因?yàn)?singleton 有可能已經(jīng)被之前的線程實(shí)例化
- 其它之后獲取到鎖的線程在執(zhí)行到第 4 行校驗(yàn)代碼,發(fā)現(xiàn) singleton 已經(jīng)不為空了,則不會(huì)再 new 一個(gè)對(duì)象,直接返回對(duì)象即可
- 之后所有進(jìn)入該方法的線程都不會(huì)去獲取鎖,在第一次判斷 singleton 對(duì)象時(shí)已經(jīng)不為空了
因?yàn)樾枰獌纱闻锌?,且?duì)類對(duì)象加鎖,該懶漢式寫法也被稱為:Double Check(雙重校驗(yàn)) + Lock(加鎖)
完整的代碼如下所示:
- public class Singleton {
- private static Singleton singleton;
- private Singleton(){}
- public static Singleton getInstance() {
- if (singleton == null) { // 線程A和線程B同時(shí)看到singleton = null,如果不為null,則直接返回singleton
- synchronized(Singleton.class) { // 線程A或線程B獲得該鎖進(jìn)行初始化
- if (singleton == null) { // 其中一個(gè)線程進(jìn)入該分支,另外一個(gè)線程則不會(huì)進(jìn)入該分支
- singleton = new Singleton();
- }
- }
- }
- return singleton;
- }
- }
上面這段代碼已經(jīng)近似完美了,但是還存在最后一個(gè)問題:指令重排
使用 volatile 防止指令重排
創(chuàng)建一個(gè)對(duì)象,在 JVM 中會(huì)經(jīng)過三步:
(1)為 singleton 分配內(nèi)存空間
(2)初始化 singleton 對(duì)象
(3)將 singleton 指向分配好的內(nèi)存空間
指令重排序是指:JVM 在保證最終結(jié)果正確的情況下,可以不按照程序編碼的順序執(zhí)行語句,盡可能提高程序的性能
在這三步中,第 2、3 步有可能會(huì)發(fā)生指令重排現(xiàn)象,創(chuàng)建對(duì)象的順序變?yōu)?1-3-2,會(huì)導(dǎo)致多個(gè)線程獲取對(duì)象時(shí),有可能線程 A 創(chuàng)建對(duì)象的過程中,執(zhí)行了 1、3 步驟,線程 B 判斷 singleton 已經(jīng)不為空,獲取到未初始化的singleton 對(duì)象,就會(huì)報(bào) NPE 異常。文字較為晦澀,可以看流程圖:
使用 volatile 關(guān)鍵字可以防止指令重排序,其原理較為復(fù)雜,這篇文章不打算展開,可以這樣理解:使用 volatile 關(guān)鍵字修飾的變量,可以保證其指令執(zhí)行的順序與程序指明的順序一致,不會(huì)發(fā)生順序變換,這樣在多線程環(huán)境下就不會(huì)發(fā)生 NPE 異常了。
volatile 還有第二個(gè)作用:使用 volatile 關(guān)鍵字修飾的變量,可以保證其內(nèi)存可見性,即每一時(shí)刻線程讀取到該變量的值都是內(nèi)存中最新的那個(gè)值,線程每次操作該變量都需要先讀取該變量。
最終的代碼如下所示:
- public class Singleton {
- private static volatile Singleton singleton;
- private Singleton(){}
- public static Singleton getInstance() {
- if (singleton == null) { // 線程A和線程B同時(shí)看到singleton = null,如果不為null,則直接返回singleton
- synchronized(Singleton.class) { // 線程A或線程B獲得該鎖進(jìn)行初始化
- if (singleton == null) { // 其中一個(gè)線程進(jìn)入該分支,另外一個(gè)線程則不會(huì)進(jìn)入該分支
- singleton = new Singleton();
- }
- }
- }
- return singleton;
- }
- }
破壞懶漢式單例與餓漢式單例
無論是完美的懶漢式還是餓漢式,終究敵不過反射和序列化,它們倆都可以把單例對(duì)象破壞掉(產(chǎn)生多個(gè)對(duì)象)。
利用反射破壞單例模式
下面是一段使用反射破壞單例模式的例子
- public static void main(String[] args) {
- // 獲取類的顯式構(gòu)造器
- Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
- // 可訪問私有構(gòu)造器
- construct.setAccessible(true);
- // 利用反射構(gòu)造新對(duì)象
- Singleton obj1 = construct.newInstance();
- // 通過正常方式獲取單例對(duì)象
- Singleton obj2 = Singleton.getInstance();
- System.out.println(obj1 == obj2); // false
- }
上述的代碼一針見血了:利用反射,強(qiáng)制訪問類的私有構(gòu)造器,去創(chuàng)建另一個(gè)對(duì)象
利用序列化與反序列化破壞單例模式
下面是一種使用序列化和反序列化破壞單例模式的例子
- public static void main(String[] args) {
- // 創(chuàng)建輸出流
- ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
- // 將單例對(duì)象寫到文件中
- oos.writeObject(Singleton.getInstance());
- // 從文件中讀取單例對(duì)象
- File file = new File("Singleton.file");
- ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
- Singleton newInstance = (Singleton) ois.readObject();
- // 判斷是否是同一個(gè)對(duì)象
- System.out.println(newInstance == Singleton.getInstance()); // false
- }
兩個(gè)對(duì)象地址不相等的原因是:readObject() 方法讀入對(duì)象時(shí)它必定會(huì)返回一個(gè)新的對(duì)象實(shí)例,必然指向新的內(nèi)存地址。
讓面試官鼓掌的枚舉實(shí)現(xiàn)
我們已經(jīng)掌握了懶漢式與餓漢式的常見寫法了,通常情況下到這里已經(jīng)足夠了。但是,追求極致的我們,怎么能夠止步于此,在《Effective Java》書中,給出了終極解決方法,話不多說,學(xué)完下面,真的不虛面試官考你了。
在 JDK 1.5 后,使用 Java 語言實(shí)現(xiàn)單例模式的方式又多了一種:枚舉
枚舉實(shí)現(xiàn)單例模式完整代碼如下:
- public enum Singleton {
- INSTANCE;
- public void doSomething() {
- System.out.println("這是枚舉類型的單例模式!");
- }
- }
使用枚舉實(shí)現(xiàn)單例模式較其它兩種實(shí)現(xiàn)方式的優(yōu)勢有 3 點(diǎn),讓我們來細(xì)品。
優(yōu)勢 1 :一目了然的代碼
代碼對(duì)比餓漢式與懶漢式來說,更加地簡潔。最少只需要3行代碼,就可以完成一個(gè)單例模式:
- public enum Test {
- INSTANCE;
- }
我們從最直觀的地方入手,第一眼看到這3行代碼,就會(huì)感覺到少,沒錯(cuò),就是少,雖然這優(yōu)勢有些牽強(qiáng),但寫的代碼越少,越不容易出錯(cuò)。
優(yōu)勢 2:天然的線程安全與單一實(shí)例
它不需要做任何額外的操作,就可以保證對(duì)象單一性與線程安全性。
我寫了一段測試代碼放在下面,這一段代碼可以證明程序啟動(dòng)時(shí)僅會(huì)創(chuàng)建一個(gè) Singleton 對(duì)象,且是線程安全的。
我們可以簡單地理解枚舉創(chuàng)建實(shí)例的過程:在程序啟動(dòng)時(shí),會(huì)調(diào)用 Singleton 的空參構(gòu)造器,實(shí)例化好一個(gè)Singleton 對(duì)象賦給 INSTANCE,之后再也不會(huì)實(shí)例化
- public enum Singleton {
- INSTANCE;
- Singleton() { System.out.println("枚舉創(chuàng)建對(duì)象了"); }
- public static void main(String[] args) { /* test(); */ }
- public void test() {
- Singleton t1 = Singleton.INSTANCE;
- Singleton t2 = Singleton.INSTANCE;
- System.out.print("t1和t2的地址是否相同:" + t1 == t2);
- }
- }
- // 枚舉創(chuàng)建對(duì)象了
- // t1和t2的地址是否相同:true
除了優(yōu)勢1和優(yōu)勢2,還有最后一個(gè)優(yōu)勢是 保護(hù)單例模式,它使得枚舉在當(dāng)前的單例模式領(lǐng)域已經(jīng)是 無懈可擊 了
優(yōu)勢 3:枚舉保護(hù)單例模式不被破壞
使用枚舉可以防止調(diào)用者使用反射、序列化與反序列化機(jī)制強(qiáng)制生成多個(gè)單例對(duì)象,破壞單例模式。
防反射
枚舉類默認(rèn)繼承了 Enum 類,在利用反射調(diào)用 newInstance() 時(shí),會(huì)判斷該類是否是一個(gè)枚舉類,如果是,則拋出異常。
防止反序列化創(chuàng)建多個(gè)枚舉對(duì)象
在讀入 Singleton 對(duì)象時(shí),每個(gè)枚舉類型和枚舉名字都是唯一的,所以在序列化時(shí),僅僅只是對(duì)枚舉的類型和變量名輸出到文件中,在讀入文件反序列化成對(duì)象時(shí),使用 Enum 類的 valueOf(String name) 方法根據(jù)變量的名字查找對(duì)應(yīng)的枚舉對(duì)象。
所以,在序列化和反序列化的過程中,只是寫出和讀入了枚舉類型和名字,沒有任何關(guān)于對(duì)象的操作。
小結(jié):
(1)Enum 類內(nèi)部使用Enum 類型判定防止通過反射創(chuàng)建多個(gè)對(duì)象
(2)Enum 類通過寫出(讀入)對(duì)象類型和枚舉名字將對(duì)象序列化(反序列化),通過 valueOf() 方法匹配枚舉名找到內(nèi)存中的唯一的對(duì)象實(shí)例,防止通過反序列化構(gòu)造多個(gè)對(duì)象
(3)枚舉類不需要關(guān)注線程安全、破壞單例和性能問題,因?yàn)槠鋭?chuàng)建對(duì)象的時(shí)機(jī)與餓漢式單例有異曲同工之妙。
總結(jié)
(1)單例模式常見的寫法有兩種:懶漢式、餓漢式
(2)懶漢式:在需要用到對(duì)象時(shí)才實(shí)例化對(duì)象,正確的實(shí)現(xiàn)方式是:Double Check + Lock,解決了并發(fā)安全和性能低下問題
(3)餓漢式:在類加載時(shí)已經(jīng)創(chuàng)建好該單例對(duì)象,在獲取單例對(duì)象時(shí)直接返回對(duì)象即可,不會(huì)存在并發(fā)安全和性能問題。
(4)在開發(fā)中如果對(duì)內(nèi)存要求非常高,那么使用懶漢式寫法,可以在特定時(shí)候才創(chuàng)建該對(duì)象;
(5)如果對(duì)內(nèi)存要求不高使用餓漢式寫法,因?yàn)楹唵尾灰壮鲥e(cuò),且沒有任何并發(fā)安全和性能問題
(6)為了防止多線程環(huán)境下,因?yàn)橹噶钪嘏判驅(qū)е伦兞繄?bào)NPE,需要在單例對(duì)象上添加 volatile 關(guān)鍵字防止指令重排序
(7)最優(yōu)雅的實(shí)現(xiàn)方式是使用枚舉,其代碼精簡,沒有線程安全問題,且 Enum 類內(nèi)部防止反射和反序列化時(shí)破壞單例。
本文轉(zhuǎn)載自微信公眾號(hào)「 Java建設(shè)者」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系 Java建設(shè)者公眾號(hào)。