单例模式在多线程中的安全性研究
概述
關于一般單例模式的創建和分析在我的另一篇博客《Java設計模式——單件模式》中有詳細說明。只是在上篇博客中的單例是針對于單線程的操作,而對于多線程卻并不適用,本文就從單例模式與多線程安全的角度出發,講解單例模式在多線程中應該如何被使用。
版權說明
著作權歸作者所有。
商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
本文作者:Coding-Naga
發表日期: 2016年4月6日
本文鏈接:http://blog.csdn.net/lemon_tree12138/article/details/51074383
來源:CSDN
更多內容:分類 >> 并發與多線程
目錄
文章目錄
- 概述
- 版權說明
- 目錄
- @[toc]
- 一般情況下的單例模式的創建
- 基于 synchronized 的同步解決方案
- 基于雙重檢查鎖定的解決方案
- 方案分析及測試
- 存在的問題
- 基于 volatile 的解決方案
- 基于類初始化的解決方案
- 基于枚舉的解決方案
- Ref
- 征集
- @[toc]
- 方案分析及測試
- 存在的問題
一般情況下的單例模式的創建
首先我們基于單例模式來編寫一個Student的類。如下:
Student.java
我們將創建學生類的任務交給一個 Runnable 去完成。
CreateRunnable.java
如下是測試代碼:
Client.java
運行結果
線程Thread[Thread-0,5,main]進入,student = null 線程Thread[Thread-1,5,main]進入,student = null 學生類被創建:org.naga.demo.thread.singleton.Student@1dbaf954 學生類被創建:org.naga.demo.thread.singleton.Student@6df90bbf Hashcode:1845038015 Hashcode:498792788從上面程序的運行結果來看,很明顯這里創建了兩個不同的對象。這與單例模式的定義相悖了。因為在多線程環境下,很明顯 getInstance() 方法不能保證原子性,所以這種方法在多線程下是不安全的。
基于 synchronized 的同步解決方案
在一般情況下的單例模式的創建中,我們知道那是一種不安全的創建對象的方案。那么就很容易想到用多線程同步的方法來解決,就是使用關鍵字 synchronized 來實現同步策略。使用 synchronized 之后的代碼及運行結果如下:
Student.java
運行結果
線程Thread[Thread-0,5,main]進入,student = null 學生類被創建:org.naga.demo.thread.singleton.Student@1dbaf954 Hashcode:498792788 學生類被創建:org.naga.demo.thread.singleton.Student@1dbaf954 Hashcode:498792788從運行結果上可以看出,這里的同步策略是有效的,Thread-0 和 Thread-1 創建的是同一個對象。而關于 synchronized 關鍵字的詳細說明請參見《Java多線程之synchronized和volatile的比較》一文。
不過,對于系統而言,synchronized 同步策略的實現其實是一項性能開銷非常大的操作。這可能是 synchronized 需要對對象加鎖的緣故。
基于雙重檢查鎖定的解決方案
方案分析及測試
上面說到 synchronized 同步策略對性能開銷比較大,對于可能存在大量的 getInstance() 方法調用時,對于系統而言可能就會難以負荷或運行緩慢。這里想到的方法就是減少對 synchronized 關鍵字的調用。也就是下面要說的雙重檢查鎖定。
Student.java
運行結果-1
線程Thread[Thread-0,5,main]進入,student = null 學生類被創建:org.naga.demo.thread.singleton.Student@386f4317 學生類被創建:org.naga.demo.thread.singleton.Student@386f4317 Hashcode:946815767 Hashcode:946815767運行結果-2
線程Thread[Thread-1,5,main]進入,student = null 學生類被創建:org.naga.demo.thread.singleton.Student@1dbaf954 Hashcode:498792788 線程Thread[Thread-0,5,main]進入,student = org.naga.demo.thread.singleton.Student@1dbaf954 學生類被創建:org.naga.demo.thread.singleton.Student@1dbaf954 Hashcode:498792788這里的結果是沒有問題的。只是你可能會有疑問,為什么這里采用雙重檢查鎖定?之前我們不是已經對 student 對象進行了判空操作了么,這里怎么還要進行第二次判空?其實在理解了多線程執行的過程,這個問題也就很好回答了。假定有兩個線程 T-0 和 T-1,它們現在同時到達第一個 if (student == null) 判空操作,那么這兩個線程都可以進入到 if (student == null) 的內部,因為在此之前對象的訪問還沒有被鎖定;這個時候,如果 T-0 獲得了鎖,并對對象進行初始化操作,結束后釋放鎖;然后 T-1 獲得了 T-0 釋放的鎖,如果這里不進行第二次判空操作的話,那么 T-1 也會創建一個對象,這個對象與 T-0 創建的是兩個完全不同的對象。而如果這里我們進行了第二次判空操作,那么 T-1 得到的對象不為空,就不會再次創建新的對象了。這個方案設計得十分巧妙,既解決了同步帶來的性能開銷,又保證了單例模式的構建。
存在的問題
對于這一小節,我本人還沒有找到一個可以正確測試的方法。這里所作的邏輯說明是來自于《Java 并發編程的藝術》一書。如果你有好的驗證方法,歡迎以評論的方式與我交流,共同進步。
這里介紹的雙重檢查鎖定的方案,這的確是一個很巧妙的設計。不過也存在一些細微的問題,這個問題就在于 student = new Student(); 這句代碼。對于通過 new 創建對象的過程可以分解成以下3行偽代碼。
而這里的2、3兩個步驟可以被重排序,重排序的結果就像下面的這樣:
memory = allocate(); // 1: 分配對象的內存空間 instance = memory; // 2: 設置 instance 指向剛分配的內存地址// 這時,memory處的對象還沒有被初始化 ctorInstance(memory); // 3: 初始化對象因為這個重排序的過程,所以這里就有一個問題了。假設有一個線程 T-0 當前執行到上面重排序后偽代碼的第2步完成,第3步還沒開始時,有一個線程 T-1 進來了,要進行第一次 if (student == null) 判斷。因為這里 instance 已經被指向了 memory 分配的地址了。所以,這時 T-1 判斷的對象是一個未被初始化的對象。這樣就出現了下面這樣的輸出了。
線程Thread[Thread-1,5,main]進入,student = null 學生類被創建:org.naga.demo.thread.singleton.Student@1dbaf954 Hashcode:498792788 線程Thread[Thread-0,5,main]進入,student = org.naga.demo.thread.singleton.Student@1dbaf954 學生類被創建:org.naga.demo.thread.singleton.Student@1dbaf954 Hashcode:498792788盡管如此,我們還是不能直接就說出了問題,因為這里也有可能 T-1 就是在 T-0 對對象創建完成之后才進來的。這里還是看看最佳的實踐方案吧。
基于 volatile 的解決方案
上面介紹了雙重檢查鎖定存在的一些弊端,不過我們還是有辦法解決的。只要對 student 對象進行 volatile 關鍵字修飾即可。
Student.java
運行結果
線程Thread[Thread-0,5,main]進入,student = null 學生類被創建:org.naga.demo.thread.singleton.Student@6df90bbf 學生類被創建:org.naga.demo.thread.singleton.Student@6df90bbf Hashcode:1845038015 Hashcode:1845038015這樣就保證了多線程之間,對共享變量的可見性。
基于類初始化的解決方案
在類的初始化階段(即在Class被加載之后,且被線程使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲得一個鎖。這個鎖可以同步多個線程對同一個類的同步初始化。
Student.java
public class Student {private Student() {}private static class StudentHolder {private final static Student instance = new Student();}public static Student getInstance() {return StudentHolder.instance;} }運行結果
學生類被創建:org.naga.demo.thread.singleton.Student@386f4317 學生類被創建:org.naga.demo.thread.singleton.Student@386f4317 Hashcode:946815767 Hashcode:946815767基于枚舉的解決方案
說到了這里,其實我們還是有一個 bigger 更高的解決方案。那就是使用枚舉,使用枚舉的好處在于我們不用關心它是否安全,是否真是只有一個實例。下面是采用單例的一些好處:
Student.java
public enum Student {INSTANCE;private String name;public String getName() {return name;}public void setName(String name) {this.name = name;} }Createable.java
public class Createable implements Runnable {@Overridepublic void run() {Student student = Student.INSTANCE;System.out.println("學生類被創建:" + student);System.out.println("Hashcode:" + student.hashCode());}}運行結果
學生類被創建:INSTANCE 學生類被創建:INSTANCE Hashcode:1946798030 Hashcode:1946798030使用枚舉除了線程安全和防止反射強行調用構造器之外,還提供了自動序列化機制,防止反序列化的時候創建新的對象。因此,Effective Java推薦盡可能地使用枚舉來實現單例。
Ref
- 《Java 多線程編程核心技術》
- 《Java 并發編程的藝術》
征集
如果你也需要使用ProcessOn這款在線繪圖工具,可以使用如下邀請鏈接進行注冊:
https://www.processon.com/i/56205c2ee4b0f6ed10838a6d
總結
以上是生活随笔為你收集整理的单例模式在多线程中的安全性研究的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: MySQL 性能优化技巧(一)
- 下一篇: 排序算法系列:Shell 排序算法