單例模式在多線程中的安全性研究

概述

關於一般單例模式的創建和分析在我的另一篇博客《Java設計模式——單件模式》中有詳細說明。只是在上篇博客中的單例是針對於單線程的操作,而對於多線程卻並不適用,本文就從單例模式與多線程安全的角度出發,講解單例模式在多線程中應該如何被使用。


版權說明

著作權歸作者所有。
商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。
本文作者:Coding-Naga
發表日期: 2016年4月6日
本文鏈接:http://blog.csdn.net/lemon_tree12138/article/details/51074383
來源:CSDN
更多內容:分類 >> 併發與多線程


目錄

一般情況下的單例模式的創建

首先我們基於單例模式來編寫一個Student的類。如下:
Student.java

public class Student {

    private static Student student = null;
    
    private Student() {
    }
    
    public static Student getInstance() {
        if (student == null) {
            System.out.println("線程" + Thread.currentThread() + "進入,student = " + student);
            student = new Student();
        }
        return student;
    }
}

我們將創建學生類的任務交給一個 Runnable 去完成。
CreateRunnable.java

public class Createable implements Runnable {

    @Override
    public void run() {
        Student student = Student.getInstance();
        System.out.println("學生類被創建:" + student);
        System.out.println("Hashcode:" + student.hashCode());
    }

}

如下是測試代碼:
Client.java

public class Client {

    public static void main(String[] args) {
        Thread thread1 = new Thread(new Createable());
        Thread thread2 = new Thread(new Createable());
        thread1.start();
        thread2.start();
    }
}

運行結果

線程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

public synchronized static Student getInstance() {
        if (student == null) {
            System.out.println("線程" + Thread.currentThread() + "進入,student = " + student);
            student = new Student();
        }
        return student;
    }

運行結果

線程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

public class Student {
    ... ...    
    public static Student getInstance() {
        System.out.println("線程" + Thread.currentThread() + "進入,student = " + student);
        if (student == null) {
            synchronized(Student.class) {
                if (student == null) {
                    student = new Student();
                }
            }
        }
        return student;
    }
}

運行結果-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行僞代碼。

memory = allocate();    // 1: 分配對象的內存空間
ctorInstance(memory);   // 2: 初始化對象
instance = memory;      // 3: 設置 instance 指向剛分配的內存地址

而這裏的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

public class Student {
    private volatile static Student student = null;
    ... ...
}

運行結果

線程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 更高的解決方案。那就是使用枚舉,使用枚舉的好處在於我們不用關心它是否安全,是否真是隻有一個實例。下面是採用單例的一些好處:

  1. 自由序列化;
  2. 保證只有一個實例(即使使用反射機制也無法多次實例化一個枚舉量);
  3. 線程安全。

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 {

    @Override
    public 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章