DCL之單例模式
- 2021 年 2 月 28 日
- 筆記
所謂的DCL 就是 Double Check Lock,即雙重鎖定檢查,在了解DCL在單例模式中如何應用之前,我們先了解一下單例模式。單例模式通常分為「餓漢」和「懶漢」,先從簡單入手
餓漢
所謂的「餓漢」是因為程式剛啟動時就創建了實例,通俗點說就是剛上菜,大家還沒有開始吃的時候就先自己吃一口。
public class Singleton {
private static final Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return singleton;
}
}
第3行 通過一個私有構造方法限制了創建此類對象的途徑(反射忽略)。這種方法很安全,但從某種程度上有點浪費資源,比方說從一開始就創建了Singleton實例,但很少去用它,這就造成了方法區資源的浪費,因此出現了另外一種單例模式,即懶漢單例模式
懶漢
之所以叫「懶漢」是因為只有真正叫它的時候,才會出現,不叫它它就不理,跟它沒關係。也就是說真正用到它的時候才去創建實例,並不是一開始就創建實例。如下程式碼所示:
public class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance(){
if(null == singleton){
singleton = new Singleton();
}
return singleton;
}
}
看似很簡單的一段程式碼,但存在一個問題,就是執行緒不安全的問題。例如,現在有1000個執行緒,都需要這一個Singleton的實例,驗證一下是否拿到同一個實例,程式碼如下所示:
public class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance(){
if(null == singleton){
try {
Thread.sleep(1);//象徵性的睡了1ms
} catch (InterruptedException e) {
e.printStackTrace();
}
singleton = new Singleton();
}
return singleton;
}
public static void main(String[] args) {
for (int i=0;i<1000;i++){
new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
}
}
}
部分運行結果,亂七八糟:
944436457
1638599176
710946821
67862359
為什麼會這樣?第一個執行緒過來了,執行到第7行,睡了1ms,正在睡的同時第二個執行緒來了,第二個執行緒執行到第5行時,結果肯定為空,因此接下來將會有兩個執行緒各自創建一個對象,這必然會導致Singleton.getInstance().hashCode()
結果不一致。可以通過給整個方法加上一把鎖改進如下:
改進1
public class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static synchronized Singleton getInstance(){
if(null == singleton){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
singleton = new Singleton();
}
return singleton;
}
public static void main(String[] args) {
for (int i=0;i<1000;i++){
new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
}
}
}
通過給getInstance()方法加上synchronized來解決執行緒一致性問題,結果分析雖然顯示所有實例的hashcode都一致,但是synchronized的粒度太大了,即鎖的臨界區太大了,有點影響效率,例如如果第4行和第5行之間有業務處理邏輯,不會涉及共享變數,那麼每次對這部分業務邏輯加鎖必然會導致效率低下。為了解決粗粒度的問題,可以對程式碼進一步改進:
改進2
public class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance(){
/*
一堆業務處理程式碼
*/
if(null == singleton){
synchronized(Singleton.class){//鎖粒度變小
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
singleton = new Singleton();
}
}
return singleton;
}
public static void main(String[] args) {
for (int i=0;i<1000;i++){
new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
}
}
}
部分運行結果 :
391918859
391918859
391918859
1945023194
通過分析運行結果發現,雖然鎖的粒度變小了,但執行緒不安全了。為什麼會這樣呢?因為有種情況,執行緒1執行完if判斷後還沒有拿到鎖的時候時間片用完了,此時執行緒2來了,執行if判斷時發現對象還是空的,繼續往下執行,很順利的拿到鎖了,因此執行緒2創建了一個對象,當執行緒2創建完之後釋放掉鎖,這時執行緒1激活了,順利的拿到鎖,又創建了一個對象。所以程式碼還需要再一步的改進。
改進3
public class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance(){
/*
一堆業務處理程式碼
*/
if(null == singleton){
synchronized(Singleton.class){//鎖粒度變小
if(null == singleton){//DCL
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
singleton = new Singleton();
}
}
}
return singleton;
}
public static void main(String[] args) {
for (int i=0;i<1000;i++){
new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
}
}
}
通過在第10行又加了一層if判斷,也就是所謂的Double Check Lock。也就是說即便拿到鎖了,也得去作一步判斷,如果這時判斷對像不為空,那麼就不用再創建對象,直接返回就可以了,很好的解決了「改進2」中的問題。但這裡第8行是不是可以去了,我個人覺得都行,保留第8行的話,是為了提升效率,因為如果去了,每個執行緒過來就直接搶鎖,搶鎖本身就會影響效率,而if判斷就幾ns,且大部分執行緒是不需要搶鎖的,所以最好保留。
到這DCL 單例的原理就介紹完了,但是還是有一個問題。就是需要考慮指令重排序的問題,因此得加入volatile來禁止指令重排序。繼續分析程式碼,為了分析方便這裡將Singleton程式碼簡化:
public class Singleton {
int a = 5;//考慮指令重排序的問題
}
singleton = new Singleton()
的位元組碼如下:
0: new #2 // class com/reasearch/Singleton
3: dup
4: invokespecial #3 // Method com/reasearch/Singleton."<init>":()V
7: astore_1
先不管dup指令。這裡補充一個知識點,創建對象的時候,先分配空間,類裡面的變數先有一個默認值,等調用了構造方法後才給變數賦值。例如int a = 5
剛開始的時候 a = 0。位元組碼指令執行過程如下,
- new 分配空間,a=0
- invokespecial 構造方法 a=5
- astore_1將對象賦給singleton
這是理想的狀態,2和3語義和邏輯上沒有什麼關聯,因此jvm可以允許這些指令亂序執行,即先執行3再執行2 。回到改進3,假如執行緒1再執行第16行程式碼時,指令的執行順序是1,3,2,當執行完3時,時間片用完了,此時a=0,也就是說初始化到一半時就掛起了。這時執行緒2 來了,第8行判斷,singleton肯定不為空,因此直接返回一個Singleton的對象,但其實這個對象是一個問題對象,是一個半初始化的對象,即a=0
。這就是指令重排序造成的,因此為了防止這種現象的發生加上關鍵字volatile就可以了。因而,最終DCL之單例模式的程式碼完整版如下:
完整版
public class Singleton {
private volatile static Singleton singleton = null;//加上volatile
private Singleton(){}
public static Singleton getInstance(){
/*
一堆業務處理程式碼
*/
if(null == singleton){
synchronized(Singleton.class){//鎖粒度變小
if(null == singleton){//DCL
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
singleton = new Singleton();
}
}
}
return singleton;
}
}
至此,可以告一段落了,相信很多小夥伴都會寫單例,但是了解其中的原理還是有一定的難度,大家一起加油!