單例模式之執行緒安全解析
阿新 • • 發佈:2019-01-01
面試的時候,常常會被問到這樣一個問題:請您寫出一個單例模式(Singleton Pattern)吧。
單例的目的是為了保證執行時Singleton類只有唯一的一個例項,最常用的地方比如拿到資料庫的連線,Spring的中建立BeanFactory這些開銷比較大的操作,而這些操作都是呼叫他們的方法來執行某個特定的動作。
很容易,順手寫一個《Java與模式》中的第一個例子:
Java程式碼
這種寫法就是所謂的餓漢式,每個物件在沒有使用之前就已經初始化了。
問題來了,問題1:單例會帶來什麼問題?如果這個物件很大呢?沒有使用這個物件之前,就把它載入到了記憶體中去是一種巨大的浪費。
針對這種情況,我們可以對以上的程式碼進行改進,使用一種新的設計思想——延遲載入(Lazy-load Singleton)。
Java程式碼
這種寫法就是所謂的懶漢式。它使用了延遲載入來保證物件在沒有使用之前,是不會進行初始化的。
通常這個時候面試官又會提問新的問題來刁難一下。他會問:這種寫法執行緒安全嗎?回答必然是:不安全。
這是因為在多個執行緒可能同時執行到第九行,判斷instance為null,於是同時進行了初始化,出現建立多個例項的情況。
實際上使用什麼樣的單例實現取決於不同的生產環境,懶漢式適合於單執行緒程式,多執行緒情況下需要保護getInstance()方法,否則可能會產生多個Singleton物件的例項。
所以,這是面臨的問題是如何使得這個程式碼執行緒安全?很簡單,在getInstance()方法前面加一個synchronized關鍵字,鎖定整個方法就OK了。
Java程式碼
寫到這裡,面試官可能仍然會狡猾的看了你一眼,繼續刁難到:這個寫法有沒有什麼效能問題呢?答案肯定是有的!同步的代價必然會一定程度的使程式的併發度降低。
鎖定整個方法的是比較耗費資源的,程式碼中實際會產生多執行緒訪問問題的只有 Java程式碼
為了降低 synchronized 塊效能方面的影響,把同步的粒度降低,只在初始化物件的時候進行同步,故只鎖定初始化物件語句即可。
Java程式碼
分析這種實現方式,兩個執行緒可以併發地進入第一次判斷instance是否為空的if 語句內部,第一個執行緒執行new操作,第二個執行緒阻斷,當第一個執行緒執行完畢之後,第二個執行緒沒有進行判斷就直接進行new操作,所以這樣做也並不是安全的。
為了避免第二次進入synchronized塊沒有進行非空判斷的情況發生,新增第二次條件判斷,即一種新的設計思想——雙重檢查鎖(Double-Checked Lock)。
Java程式碼
這種寫法使得只有在載入新的物件進行同步,在載入完了之後,其他執行緒在第5行就可以判斷跳過鎖的的代價直接到第12行程式碼了。做到很好的併發度。
至此,上面的寫法一方面實現了Lazy-Load,另一個方面也做到了併發度很好的執行緒安全,一切看上很完美。
但是二次檢查自身會存在比較隱蔽的問題,查了Peter Haggar在DeveloperWorks上的一篇文章,對二次檢查的解釋非常的詳細:
“雙重檢查鎖定背後的理論是完美的。不幸地是,現實完全不同。雙重檢查鎖定的問題是:並不能保證它會在單處理器或多處理器計算機上順利執行。雙重檢查鎖定失敗的問題並不歸咎於 JVM 中的實現 bug,而是歸咎於 Java 平臺記憶體模型。記憶體模型允許所謂的“無序寫入”,這也是這些習語失敗的一個主要原因。”
使用二次檢查的方法也不是完全安全的,原因是 java 平臺記憶體模型中允許所謂的“無序寫入”會導致二次檢查失敗,所以使用二次檢查的想法也行不通了。
Peter Haggar在最後提出這樣的觀點:“無論以何種形式,都不應使用雙重檢查鎖定,因為您不能保證它在任何 JVM 實現上都能順利執行。”
問題在哪裡?
假設執行緒A執行到了第5行,它判斷物件為空,於是執行緒A執行到第8行去初始化這個物件,但初始化是需要耗費時間的,但是這個物件的地址其實已經存在了。此時執行緒B也執行到了第5行,它判斷不為空,於是直接跳到12行得到了這個物件。但是,這個物件還沒有被完整的初始化!得到一個沒有初始化完全的物件有什麼用!!
關於這個Double-Checked Lock的討論有很多,目前公認這是一個Anti-Pattern,不推薦使用!
那麼有沒有什麼更好的寫法呢?
有!這裡又要提出一種新的模式——Initialization on Demand Holder. 這種方法使用內部類來做到延遲載入物件,在初始化這個內部類的時候,JLS(Java Language Sepcification)會保證這個類的執行緒安全。這種寫法最大的美在於,完全使用了Java虛擬機器的機制進行同步保證,沒有一個同步的關鍵字。
Java程式碼
上面的方式是值得借鑑的,在ResourceFactory中加入了一個私有靜態內部類ResourceHolder ,對外提供的介面是 getResource()方法,也就是隻有在ResourceFactory .getResource()的時候,Resource物件才會被建立,
這種寫法的巧妙之處在於ResourceFactory 在使用的時候ResourceHolder 會被初始化,但是ResourceHolder 裡面的resource並沒有被建立,
這裡隱含了一個是static關鍵字的用法,使用static關鍵字修飾的變數只有在第一次使用的時候才會被初始化,而且一個類裡面static的成員變數只會有一份,這樣就保證了無論多少個執行緒同時訪問,所拿到的Resource物件都是同一個。
值得注意的是,餓漢式的實現方式雖然貌似開銷比較大,但是不會出現執行緒安全的問題,也是解決執行緒安全的單例實現的有效方式。
所以本文提出的第一個例子(也是《Java與模式》中的例子),也是使用單例模式的有效方法之一。這種方式沒有使用同步,並且確保了呼叫static getInstance()方法時才建立Singleton的引用(static 的成員變數在一個類中只有一份)。
附:
餓漢式單例類可以在Java語言實現,但不易在C++內實現,因為靜態初始化在C++裡沒有固定的順序,因而靜態的instance變數的初始化與類的載入順序沒有保證,可能會出問題。這就是為什麼GoF在提出單例類的概念時,舉的例子是懶漢式的。他們的書影響之大,以致Java語言中單例類的例子也大多是懶漢式的。實際上,本書認為餓漢式單例類更符合Java語言本身的特點。
單例的目的是為了保證執行時Singleton類只有唯一的一個例項,最常用的地方比如拿到資料庫的連線,Spring的中建立BeanFactory這些開銷比較大的操作,而這些操作都是呼叫他們的方法來執行某個特定的動作。
很容易,順手寫一個《Java與模式》中的第一個例子:
Java程式碼
- public final class Singleton {
- private static Singleton instance = new Singleton();
-
private
- public static Singleton getInstance() {
- return instance;
- }
- }
這種寫法就是所謂的餓漢式,每個物件在沒有使用之前就已經初始化了。
問題來了,問題1:單例會帶來什麼問題?如果這個物件很大呢?沒有使用這個物件之前,就把它載入到了記憶體中去是一種巨大的浪費。
針對這種情況,我們可以對以上的程式碼進行改進,使用一種新的設計思想——延遲載入(Lazy-load Singleton)。
Java程式碼
-
public final
- private static Singleton instance = null;
- private Singleton(){}
- public static Singleton getInstance(){
- if(instance == null){
- instance = new Singleton();
- }
- return instance;
- }
- }
這種寫法就是所謂的懶漢式。它使用了延遲載入來保證物件在沒有使用之前,是不會進行初始化的。
通常這個時候面試官又會提問新的問題來刁難一下。他會問:這種寫法執行緒安全嗎?回答必然是:不安全。
這是因為在多個執行緒可能同時執行到第九行,判斷instance為null,於是同時進行了初始化,出現建立多個例項的情況。
實際上使用什麼樣的單例實現取決於不同的生產環境,懶漢式適合於單執行緒程式,多執行緒情況下需要保護getInstance()方法,否則可能會產生多個Singleton物件的例項。
所以,這是面臨的問題是如何使得這個程式碼執行緒安全?很簡單,在getInstance()方法前面加一個synchronized關鍵字,鎖定整個方法就OK了。
Java程式碼
- public final class Singleton{
- private static Singleton instance=null;
- private Singleton(){}
- public static synchronized Singleton getInstance(){
- if(instance==null){
- instance=new Singleton();
- }
- return instance;
- }
- }
寫到這裡,面試官可能仍然會狡猾的看了你一眼,繼續刁難到:這個寫法有沒有什麼效能問題呢?答案肯定是有的!同步的代價必然會一定程度的使程式的併發度降低。
鎖定整個方法的是比較耗費資源的,程式碼中實際會產生多執行緒訪問問題的只有 Java程式碼
- instance = new Singleton();
為了降低 synchronized 塊效能方面的影響,把同步的粒度降低,只在初始化物件的時候進行同步,故只鎖定初始化物件語句即可。
Java程式碼
- public final Singleton getInstance(){
- if(instance == null){
- synchronize(this){
- instance = new Singleton();
- }
- }
- return instance;
- }
分析這種實現方式,兩個執行緒可以併發地進入第一次判斷instance是否為空的if 語句內部,第一個執行緒執行new操作,第二個執行緒阻斷,當第一個執行緒執行完畢之後,第二個執行緒沒有進行判斷就直接進行new操作,所以這樣做也並不是安全的。
為了避免第二次進入synchronized塊沒有進行非空判斷的情況發生,新增第二次條件判斷,即一種新的設計思想——雙重檢查鎖(Double-Checked Lock)。
Java程式碼
- public final class Singleton{
- private static Singleton instance=null;
- private Singleton(){}
- public static Singleton getInstance(){
- if(instance == null){
- synchronize(this){
- if(instance == null){
- instance = new Singleton();
- }
- }
- }
- return instance;
- }
- }
這種寫法使得只有在載入新的物件進行同步,在載入完了之後,其他執行緒在第5行就可以判斷跳過鎖的的代價直接到第12行程式碼了。做到很好的併發度。
至此,上面的寫法一方面實現了Lazy-Load,另一個方面也做到了併發度很好的執行緒安全,一切看上很完美。
但是二次檢查自身會存在比較隱蔽的問題,查了Peter Haggar在DeveloperWorks上的一篇文章,對二次檢查的解釋非常的詳細:
“雙重檢查鎖定背後的理論是完美的。不幸地是,現實完全不同。雙重檢查鎖定的問題是:並不能保證它會在單處理器或多處理器計算機上順利執行。雙重檢查鎖定失敗的問題並不歸咎於 JVM 中的實現 bug,而是歸咎於 Java 平臺記憶體模型。記憶體模型允許所謂的“無序寫入”,這也是這些習語失敗的一個主要原因。”
使用二次檢查的方法也不是完全安全的,原因是 java 平臺記憶體模型中允許所謂的“無序寫入”會導致二次檢查失敗,所以使用二次檢查的想法也行不通了。
Peter Haggar在最後提出這樣的觀點:“無論以何種形式,都不應使用雙重檢查鎖定,因為您不能保證它在任何 JVM 實現上都能順利執行。”
問題在哪裡?
假設執行緒A執行到了第5行,它判斷物件為空,於是執行緒A執行到第8行去初始化這個物件,但初始化是需要耗費時間的,但是這個物件的地址其實已經存在了。此時執行緒B也執行到了第5行,它判斷不為空,於是直接跳到12行得到了這個物件。但是,這個物件還沒有被完整的初始化!得到一個沒有初始化完全的物件有什麼用!!
關於這個Double-Checked Lock的討論有很多,目前公認這是一個Anti-Pattern,不推薦使用!
那麼有沒有什麼更好的寫法呢?
有!這裡又要提出一種新的模式——Initialization on Demand Holder. 這種方法使用內部類來做到延遲載入物件,在初始化這個內部類的時候,JLS(Java Language Sepcification)會保證這個類的執行緒安全。這種寫法最大的美在於,完全使用了Java虛擬機器的機制進行同步保證,沒有一個同步的關鍵字。
Java程式碼
- public class ResourceFactory{
- private static class ResourceHolder{
- public static Resource resource = new Resource();
- }
- public static Resource getResource() {
- return ResourceFactory.ResourceHolder.resource;
- }
- }
上面的方式是值得借鑑的,在ResourceFactory中加入了一個私有靜態內部類ResourceHolder ,對外提供的介面是 getResource()方法,也就是隻有在ResourceFactory .getResource()的時候,Resource物件才會被建立,
這種寫法的巧妙之處在於ResourceFactory 在使用的時候ResourceHolder 會被初始化,但是ResourceHolder 裡面的resource並沒有被建立,
這裡隱含了一個是static關鍵字的用法,使用static關鍵字修飾的變數只有在第一次使用的時候才會被初始化,而且一個類裡面static的成員變數只會有一份,這樣就保證了無論多少個執行緒同時訪問,所拿到的Resource物件都是同一個。
值得注意的是,餓漢式的實現方式雖然貌似開銷比較大,但是不會出現執行緒安全的問題,也是解決執行緒安全的單例實現的有效方式。
所以本文提出的第一個例子(也是《Java與模式》中的例子),也是使用單例模式的有效方法之一。這種方式沒有使用同步,並且確保了呼叫static getInstance()方法時才建立Singleton的引用(static 的成員變數在一個類中只有一份)。
附:
餓漢式單例類可以在Java語言實現,但不易在C++內實現,因為靜態初始化在C++裡沒有固定的順序,因而靜態的instance變數的初始化與類的載入順序沒有保證,可能會出問題。這就是為什麼GoF在提出單例類的概念時,舉的例子是懶漢式的。他們的書影響之大,以致Java語言中單例類的例子也大多是懶漢式的。實際上,本書認為餓漢式單例類更符合Java語言本身的特點。
——《Java與模式》作者