概述
關於一般單例模式的創建和分析在我的另一篇博客《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 更高的解決方案。那就是使用枚舉,使用枚舉的好處在於我們不用關心它是否安全,是否真是隻有一個實例。下面是採用單例的一些好處:
- 自由序列化;
- 保證只有一個實例(即使使用反射機制也無法多次實例化一個枚舉量);
- 線程安全。
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