用happen-before規則重新審視DCL

轉載自:http://www.iteye.com/topic/260515   侵刪

編寫Java多線程程序一直以來都是一件十分困難的事,多線程程序的bug很難測試,DCL(Double Check Lock)就是一個典型,因此對多線程安全的理論分析就顯得十分重要,當然這決不是說對多線程程序的測試就是不必要的。傳統上,對多線程程序的分析是通過分析操作之間可能的執行先後順序,然而程序執行順序十分複雜,它與硬件系統架構,編譯器,緩存以及虛擬機的實現都有着很大的關係。僅僅爲了分析多線程程序就需要了解這麼多底層知識確實不值得,況且當年選擇學Java就是因爲不用理會煩人的硬件和操作系統,這導致了許多Java程序員不願也不能從理論上分析多線程程序的正確性。雖然99%的Java程序員都知道DCL不對,但是如果讓他們回答一些問題,DCL爲什麼不對?有什麼修正方法?這個修正方法是正確的嗎?如果不正確,爲什麼不正確?對於此類問題,他們一臉茫然,或者回答也許吧,或者很自信但其實並沒有抓住根本。

 

幸好現在還有另一條路可走,我們只需要利用幾個基本的happen-before規則就能從理論上分析Java多線程程序的正確性,而且不需要涉及到硬件和編譯器的知識。接下來的部分,我會首先說明一下happen-before規則,然後使用happen-before規則來分析DCL,最後我以我自己的例子來說明DCL的問題其實很常見,只是因爲對DCL的過度關注反而忽略其問題本身,當然其忽略是有原因的,因爲很多人並不知道DCL的問題到底出在哪裏。

 

 

Happen-Before規則

 

我們一般說一個操作happen-before另一個操作,這到底是什麼意思呢?當說操作A happen-before操作B時,我們其實是在說在發生操作B之前,操作A對內存施加的影響能夠被觀測到。所謂“對內存施加的影響”就是指對變量的寫入,“被觀測到”指當讀取這個變量時能夠得到剛纔寫入的值(如果中間沒有發生其它的寫入)。聽起來很繞口?這就對了,請你保持耐心,舉個例子來說明一下。線程Ⅰ執行了操作A:x=3,線程Ⅱ執行了操作B:y=x。如果操作Ahappen-before操作B,線程Ⅱ在執行操作B之前就確定操作"x=3"被執行了,它能夠確定,是因爲如果這兩個操作之間沒有任何對x的寫入的話,它讀取x的值將得到3,這意味着線程Ⅱ執行操作B會寫入y的值爲3。如果兩個操作之間還有對x的寫入會怎樣呢?假設線程Ⅲ在操作A和B之間執行了操作C: x=5,並且操作C和操作B之前並沒有happen-before關係(後面我會說明時間上的先後並不一定導致happen-before關係)。這時線程Ⅱ執行操作B會講到x的什麼值呢?3還是5?答案是兩者皆有可能,這是因爲happen-before關係保證一定 能夠觀測到前一個操作施加的內存影響,只有時間上的先後關係而並沒有happen-before關係可能但並不保證 能觀測前一個操作施加的內存影響。如果讀到了值3,我們就說讀到了“陳舊 ”的數據。正是多種可能性導致了多線程的不確定性和複雜性,但是要分析多線程的安全性,我們只能分析確定性部分,這就要求找出happen-before關係,這又得利用happen-before規則。

 

下面是我列出的三條非常重要的happen-before規則,利用它們可以確定兩個操作之間是否存在happen-before關係。

  1. 同一個線程中,書寫在前面的操作happen-before書寫在後面的操作。這條規則是說,在單線程 中操作間happen-before關係完全是由源代碼的順序決定的,這裏的前提“在同一個線程中”是很重要的,這條規則也稱爲單線程規則 。這個規則多少說得有些簡單了,考慮到控制結構和循環結構,書寫在後面的操作可能happen-before書寫在前面的操作,不過我想讀者應該明白我的意思。
  2. 對鎖的unlock操作happen-before後續的對同一個鎖的lock操作。這裏的“後續”指的是時間上的先後關係,unlock操作發生在退出同步塊之後,lock操作發生在進入同步塊之前。這是條最關鍵性的規則,線程安全性主要依賴於這條規則。但是僅僅是這條規則仍然不起任何作用,它必須和下面這條規則聯合起來使用才顯得意義重大。這裏關鍵條件是必須對“同一個鎖”的lock和unlock。
  3. 如果操作A happen-before操作B,操作B happen-before操作C,那麼操作A happen-before操作C。這條規則也稱爲傳遞規則。

 

現在暫時放下happen-before規則,先探討一下“一個操作在時間上先於另一個操作發生”和“一個操作happen-before另一個操作之間”的關係。兩者有關聯卻並不相同。關聯部分在第2條happen-before規則中已經談到了,通常我們得假定一個時間上的先後順序然後據此得出happen-before關係。不同部分體現在,首先,一個操作在時間上先於另一個操作發生,並不意味着一個操作happen-before另一個操作 。看下面的例子:

Java代碼 

 

  1. public void setX(int x) {  
      this.x = x;               // (1)  
    }  
      
    public int getX() {  
      return x;                 // (2)  
    }  

     

假設線程Ⅰ先執行setX方法,接着線程Ⅱ執行getX方法,在時間上線程Ⅰ的操作A:this.x = x先於線程Ⅱ的操作B:return x。但是操作A卻並不happen-before操作B,讓我們逐條檢查三條happen-before規則。第1條規則在這裏不適用,因爲這時兩個不同的線程。第2條規則也不適用,因爲這裏沒有任何同步塊,也就沒有任何lock和unlock操作。第3條規則必須基於已經存在的happen-before關係,現在沒有得出任何happen-before關係,因此第三條規則對我們也任何幫助。通過檢查這三條規則,我們就可以得出,操作A和操作B之間沒有happen-before關係。這意味着如果線程Ⅰ調用了setX(3),接着線程Ⅱ調用了getX(),其返回值可能不是3,儘管兩個操作之間沒有任何其它操作對x進行寫入,它可能返回任何一個曾經存在的值或者默認值0。“任何曾經存在的值”需要做點解釋,假設在線程Ⅰ調用setX(3)之前,還有別的線程或者就是線程Ⅰ還調用過setX(5), setX(8),那麼x的曾經可能值爲0, 5和8(這裏假設setX是唯一能夠改變x的方法),其中0是整型的默認值,用在這個例子中,線程Ⅱ調用getX()的返回值可能爲0, 3, 5和8,至於到底是哪個值是不確定的。

 

現在將兩個方法都設成同步的,也就是如下:

Java代碼 

 

  1. public synchronized void setX(int x) {  
      this.x = x;               // (1)  
    }  
      
    public synchronized int getX() {  
      return x;                 // (2)  
    }  

     

做同樣的假設,線程Ⅰ先執行setX方法,接着線程Ⅱ執行getX方法,這時就可以得出來,線程Ⅰ的操作A happen-before線程Ⅱ的操作B。下面我們來看如何根據happen-before規則來得到這個結論。由於操作A處於同步塊中,操作A之後必須定要發生對this鎖的unlock操作,操作B也處於同步塊中,操作B之前必須要發生對this鎖的lock操作,根據假設unlock操作發生lock操作之前,根據第2條happen-before規則,就得到unlock操作happen-before於lock操作;另外根據第1條happen-before規則(單線程規則),操作A happen-before於unlock操作,lock操作happen-before於操作B;最後根據第3條happen-before規則(傳遞規則),A -> unlock, unlock -> lock, lock -> B(這裏我用->表示happen-before關係),有 A -> B,也就是說操作A happen-before操作B。這意味着如果線程Ⅰ調用了setX(3),緊接着線程Ⅱ調用了getX(),如果中間再沒有其它線程改變x的值,那麼其返回值必定是3。

 

如果將兩個方法的任何一個synchronized關鍵字去掉又會怎樣呢?這時能不能得到線程Ⅰ的操作A happen-before線程Ⅱ的操作B呢?答案是得不到。這裏因爲第二條happen-before規則的條件已經不成立了,這時因爲要麼只有線程Ⅰ的unlock操作(如果去掉getX的synchronized),要麼只有線程Ⅱ的lock操作(如果去掉setX的synchronized關鍵字)。這裏也告訴我們一個原則,必須對同一個變量的 所有 讀寫同步,才能保證不讀取到陳舊的數據,僅僅同步讀或寫是不夠的 。

 

其次,一個操作happen-before另一個操作 也並不意味着 一個操作在時間上先於另一個操作發生 。看下面的例子:

Java代碼 

 

  1. x = 3;      (1)  
    y = 2;      (2)  

     

同一個線程執行上面的兩個操作,操作A:x = 3和操作B:y = 2。根據單線程規則,操作A happen-before操作B,但是操作A卻不一定在時間上先於操作B發生,這是因爲編譯器的重新排序等原因,操作B可能在時間上後於操作B發生。這個例子也說明了,分析操作上先後順序是多麼地不靠譜,它可能完全違反直觀感覺。

 

最後,一個操作和另一個操作必定存在某個順序,要麼一個操作或者是先於或者是後於另一個操作,或者與兩個操作同時發生。同時發生是完全可能存在的,特別是在多CPU的情況下。而兩個操作之間卻可能沒有happen-before關係,也就是說有可能發生這樣的情況,操作A不happen-before操作B,操作B也不happen-before操作A,用數學上的術語happen-before關係是個偏序關係。兩個存在happen-before關係的操作不可能同時發生,一個操作A happen-before操作B,它們必定在時間上是完全錯開的,這實際上也是同步的語義之一(獨佔訪問)。

 

在運用happen-before規則分析DCL之前,有必要對“操作”澄清一下,在前面的敘述中我一直將語句是操作的同義詞,這麼講是不嚴格的,嚴格上來說這裏的操作應該是指單個虛擬機的指令,如moniterenter, moniterexit, add, sub, store, load等。使用語句來代表操作並不影響我們的分析,下面我仍將延續這一傳統,並且將直接用語句來代替操作。唯一需要注意的是單個語句實際上可能由多個指令組成,比如語句x=i++由兩條指令(inc和store)組成。現在我們已經完成了一切理論準備,你一定等不及要動手開幹了(我都寫煩了)。

 

 

利用Happen-Before規則分析DCL

 

下面是一個典型的使用DCL的例子:

 

Java代碼 

 

  1. public class LazySingleton {  
        private int someField;  
          
        private static LazySingleton instance;  
          
        private LazySingleton() {  
            this.someField = new Random().nextInt(200)+1;         // (1)  
        }  
          
        public static LazySingleton getInstance() {  
            if (instance == null) {                               // (2)  
                synchronized(LazySingleton.class) {               // (3)  
                    if (instance == null) {                       // (4)  
                        instance = new LazySingleton();           // (5)  
                    }  
                }  
            }  
            return instance;                                      // (6)  
        }  
          
        public int getSomeField() {  
            return this.someField;                                // (7)  
        }  
    }  

     

 

爲了分析DCL,我需要預先陳述上面程序運行時幾個事實:

  1. 語句(5)只會被執行一次,也就是LazySingleton只會存在一個實例,這是由於它和語句(4)被放在同步塊中被執行的緣故,如果去掉語句(3)處的同步塊,那麼這個假設便不成立了。
  2. instance只有兩種“曾經可能存在”的值,要麼爲null,也就是初始值,要麼爲執行語句(5)時構造的對象引用。這個結論由事實1很容易推出來。
  3. getInstance()總是返回非空值,並且每次調用返回相同的引用。如果getInstance()是初次調用,它會執行語句(5)構造一個LazySingleton實例並返回,如果getInstance()不是初次調用,如果不能在語句(2)處檢測到非空值,那麼必定將在語句(4)處就能檢測到instance的非空值,因爲語句(4)處於同步塊中,對instance的寫入--語句(5)也處於同一個同步塊中。

有讀者可能要問了,既然根據第3條事實getInstance()總是返回相同的正確的引用,爲什麼還說DCL有問題呢?這裏的關鍵是 儘管得到了LazySingleton的正確引用,但是卻有可能訪問到其成員變量 的 不正確值 ,具體來說LazySingleton.getInstance().getSomeField()有可能返回someField的默認值0。如果程序行爲正確的話,這應當是不可能發生的事,因爲在構造函數裏設置的someField的值不可能爲0。爲也說明這種情況理論上有可能發生,我們只需要說明語句(1)和語句(7)並不存在happen-before關係。

 

假設線程Ⅰ是初次調用getInstance()方法,緊接着線程Ⅱ也調用了getInstance()方法和getSomeField()方法,我們要說明的是線程Ⅰ的語句(1)並不happen-before線程Ⅱ的語句(7)。線程Ⅱ在執行getInstance()方法的語句(2)時,由於對instance的訪問並沒有處於同步塊中,因此線程Ⅱ可能觀察到也可能觀察不到線程Ⅰ在語句(5)時對instance的寫入,也就是說instance的值可能爲空也可能爲非空。我們先假設instance的值非空,也就觀察到了線程Ⅰ對instance的寫入,這時線程Ⅱ就會執行語句(6)直接返回這個instance的值,然後對這個instance調用getSomeField()方法,該方法也是在沒有任何同步情況被調用,因此整個線程Ⅱ的操作都是在沒有同步的情況下調用 ,這時我們無法利用第1條和第2條happen-before規則得到線程Ⅰ的操作和線程Ⅱ的操作之間的任何有效的happen-before關係,這說明線程Ⅰ的語句(1)和線程Ⅱ的語句(7)之間並不存在happen-before關係,這就意味着線程Ⅱ在執行語句(7)完全有可能觀測不到線程Ⅰ在語句(1)處對someFiled寫入的值,這就是DCL的問題所在。很荒謬,是吧?DCL原本是爲了逃避同步,它達到了這個目的,也正是因爲如此,它最終受到懲罰,這樣的程序存在嚴重的bug,雖然這種bug被發現的概率絕對比中彩票的概率還要低得多,而且是轉瞬即逝,更可怕的是,即使發生了你也不會想到是DCL所引起的。

 

前面我們說了,線程Ⅱ在執行語句(2)時也有可能觀察空值,如果是種情況,那麼它需要進入同步塊,並執行語句(4)。在語句(4)處線程Ⅱ還能夠讀到instance的空值嗎?不可能。這裏因爲這時對instance的寫和讀都是發生在同一個鎖確定的同步塊中,這時讀到的數據是最新的數據。爲也加深印象,我再用happen-before規則分析一遍。線程Ⅱ在語句(3)處會執行一個lock操作,而線程Ⅰ在語句(5)後會執行一個unlock操作,這兩個操作都是針對同一個鎖--LazySingleton.class,因此根據第2條happen-before規則,線程Ⅰ的unlock操作happen-before線程Ⅱ的lock操作,再利用單線程規則,線程Ⅰ的語句(5) -> 線程Ⅰ的unlock操作,線程Ⅱ的lock操作 -> 線程Ⅱ的語句(4),再根據傳遞規則,就有線程Ⅰ的語句(5) -> 線程Ⅱ的語句(4),也就是說線程Ⅱ在執行語句(4)時能夠觀測到線程Ⅰ在語句(5)時對LazySingleton的寫入值。接着對返回的instance調用getSomeField()方法時,我們也能得到線程Ⅰ的語句(1) -> 線程Ⅱ的語句(7),這表明這時getSomeField能夠得到正確的值。但是僅僅是這種情況的正確性並不妨礙DCL的不正確性,一個程序的正確性必須在所有的情況下的行爲都是正確的,而不能有時正確,有時不正確。

 

對DCL的分析也告訴我們一條經驗原則,對引用(包括對象引用和數組引用)的非同步訪問,即使得到該引用的最新值,卻並不能保證也能得到其成員變量(對數組而言就是每個數組元素)的最新值。

 

再稍微對DCL探討一下,這個例子中的LazySingleton是一個不變類,它只有get方法而沒有set方法。由對DCL的分析我們知道,即使一個對象是不變的,在不同的線程中它的同一個方法也可能返回不同的值 。之所以會造成這個問題,是因爲LazySingleton實例沒有被安全發佈,所謂“被安全的發佈”是指所有的線程應該在同步塊中獲得這個實例。這樣我們又得到一個經驗原則,即使對於不可變對象,它也必須被安全的發佈,才能被安全地共享。 所謂“安全的共享”就是說不需要同步也不會遇到數據競爭的問題。在Java5或以後,將someField聲明成final的,即使它不被安全的發佈,也能被安全地共享,而在Java1.4或以前則必須被安全地發佈。

 

關於DCL的修正

 

既然理解了DCL的根本原因,或許我們就可以修正它。

 

既然原因是線程Ⅱ執行getInstance()可能根本沒有在同步塊中執行,那就將整個方法都同步吧。這個毫無疑問是正確的,但是這卻回到最初的起點(返樸歸真了),也完全違背了DCL的初衷,儘可能少的減少同步。雖然這不能帶任何意義,卻也說明一個道理,最簡單的往往是最好的。

 

如果我們嘗試不改動getInstance()方法,而是在getSomeField()上做文章,那麼首先想到的應該是將getSomeField設置成同步,如下所示:

 

Java代碼 

 

  1.  
public synchronized int getSomeField() {  
    return this.someField;                                // (7)  
}  

 

這種修改是不是正確的呢?答案是不正確。這是因爲,第2條happen-before規則的前提條件並不成立。語句(5)所在同步塊和語句(7)所在同步塊並不是使用同一個鎖。像下面這樣修改纔是對的:

Java代碼 

 

  1. public int getSomeField() {  
        synchronized(LazySingleton.class) {  
            return this.someField;  
        }  
    }  

     

但是這樣的修改雖然能保證正確性卻不能保證高性能。因爲現在每次讀訪問getSomeField()都要同步,如果使用簡單的方法,將整個getInstance()同步,只需要在getInstance()時同步一次,之後調用getSomeField()就不需要同步了。另外getSomeField()方法也顯得很奇怪,明明是要返回實例變量卻要使用Class鎖。這也再次驗證了一個道理,簡單的纔是好的。

 

好了,由於我的想象力有限,我能想到的修正也就僅限於此了,讓我們來看看網上提供的修正吧。

 

首先看Lucas Lee的修正(這裏 是原帖):

Java代碼 

 

  1. private static LazySingleton instance;  
    private static int hasInitialized = 0;  
          
    public static LazySingleton getInstance() {  
        if (hasInitialized == 0) {                                          // (4)  
            synchronized(LazySingleton.class) {                         // (5)  
                if (instance == null) {                                 // (6)  
                    instance = new LazySingleton();                     // (7)  
                    hasInitialized = 1;  
                }  
            }  
        }  
        return instance;                                                // (8)  
    }  

     

如果你明白我前面所講的,那麼很容易看出這裏根本就是一個僞修正,線程Ⅱ仍然完全有可能在非同步狀態下返回instance。Lucas Lee的理由是對int變量的賦值是原子的,但實際上對instance的賦值也是原子的,Java語言規範規定對任何引用變量和基本變量的賦值都是原子的,除了long和double以外。使用hasInitialized==0和instance==null來判斷LazySingleton有沒有初始化沒有任何區別。Lucas Lee對http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html 中的最後一個例子有些誤解,裏面的計算hashCode的例子之所以是正確的,是因爲它返回的是int而不是對象的引用,因而不存在訪問到不正確成員變量值的問題。

 

neuzhujf的修正:

Java代碼 

 

  1. public static LazySingleton getInstance() {  
        if (instance == null) {                                         // (4)  
            synchronized(LazySingleton.class) {                         // (5)  
                if (instance == null) {                                 // (6)  
                    LazySingleton localRef = new LazySingleton();  
                    instance = localRef;                        // (7)  
                }  
            }  
        }  
        return instance;                                                // (8)  
    }  

     

這裏只是引入了一個局部變量,這也容易看出來只是一個僞修正,如果你弄明白了我前面所講的。

 

既然提到DCL,就不得不提到一個經典的而且正確的修正。就是使用一個static holder,kilik在回覆中給出了這樣的一個修正。由於這裏一種完全不同的思路,與我這裏講的內容也沒有太大的關係,暫時略了吧。另外一個修正是使用是threadlocal,都可以參見這篇文章 。

 

步入Java5

 

前面所講的都是基於Java1.4及以前的版本,java5對內存模型作了重要的改動,其中最主要的改動就是對volatile和final語義的改變。本文使用的happen-before規則實際上是從Java5中借鑑而來,然後再移花接木到Java1.4中,因此也就不得不談下Java5中的多線程了。

 

在java 5中多增加了一條happen-before規則:

  • 對volatile字段的寫操作happen-before後續的對同一個字段的讀操作。

利用這條規則我們可以將instance聲明爲volatile,即:

Java代碼 

 

  1. private volatile static LazySingleton instance;  

     

 根據這條規則,我們可以得到,線程Ⅰ的語句(5) -> 語線程Ⅱ的句(2),根據單線程規則,線程Ⅰ的語句(1) -> 線程Ⅰ的語句(5)和語線程Ⅱ的句(2) -> 語線程Ⅱ的句(7),再根據傳遞規則就有線程Ⅰ的語句(1) -> 語線程Ⅱ的句(7),這表示線程Ⅱ能夠觀察到線程Ⅰ在語句(1)時對someFiled的寫入值,程序能夠得到正確的行爲。

 

在java5之前對final字段的同步語義和其它變量沒有什麼區別,在java5中,final變量一旦在構造函數中設置完成(前提是在構造函數中沒有泄露this引用),其它線程必定會看到在構造函數中設置的值。而DCL的問題正好在於看到對象的成員變量的默認值,因此我們可以將LazySingleton的someField變量設置成final,這樣在java5中就能夠正確運行了。

 

 

遭遇同樣錯誤

 

在Java世界裏,框架似乎做了很多事情來隱藏多線程,以至於很多程序員認爲不再需要關注多線程了。 這實際上是個陷阱,這它只會使我們對多線程程序的bug反應遲鈍。大部分程序員(包括我)都不 會特別留意類文檔中的線程不安全警告,自己寫程序時也不會考慮將該類是否線程安全寫入文檔中。做個測試,你知道java.text.SimpleDateFormat不是線程安全的嗎?如果你不知道,也不要感到奇怪,我也是在《Java Concurrent In Practice 》這書中纔看到的。

 

現在我們已經明白了DCL中的問題,很多人都只認爲這只不過是不切實際的理論者整天談論的話題,殊不知這樣的錯誤其實很常見。我就犯過,下面是從我同一個項目中所寫的代碼中摘錄出來的,讀者也不妨拿此來檢驗一下自己,你自己犯過嗎?即使沒有,你會毫不猶豫的這樣寫嗎?

 

第一個例子:

Java代碼 

 

  1. public class TableConfig {  
        //....  
        private FieldConfig[] allFields;  
          
        private transient FieldConfig[] _editFields;  
      
        //....  
          
        public FieldConfig[] getEditFields() {  
            if (_editFields == null) {  
                List<FieldConfig> editFields = new ArrayList<FieldConfig>();  
                for (int i = 0; i < allFields.length; i++) {  
                    if (allFields[i].editable) editFields.add(allFields[i]);  
                }  
                _editFields = editFields.toArray(new FieldConfig[editFields.size()]);  
            }  
            return _editFields;  
        }  
    }  

     

這裏緩存了TableConfig的_editFields,免得以後再取要重新遍歷allFields。這裏存在和DCL同樣的問題,_editFields數組的引用可能是正確的值,但是數組成員卻可能null! 與DCL不同的是 ,由於對_editFields的賦值沒有同步,它可能被賦值多次,但是在這裏沒有問題,因爲每次賦值雖然其引用值不同,但是其數組成員是相同的,對於我的業務來說,它們都等價的。由於我的代碼是要用在java1.4中,因此唯一的修復方法就是將整個方法聲明爲同步。

 

第二個例子:

Java代碼 

 

  1. private Map selectSqls = new HashMap();  
      
    public Map executeSelect(final TableConfig tableConfig, Map keys) {  
        if (selectSqls.get(tableConfig.getId()) == null) {  
            selectSqls.put(tableConfig.getId(), constructSelectSql(tableConfig));  
        }  
        PreparedSql psql = (PreparedSql) selectSqls.get(tableConfig.getId());  
      
        List result = executeSql(...);  
             
           return result.isEmpty() ? null : (Map) result.get(0);  
       }  

     

 

上面的代碼用constructSelectSql()方法來動態構造SQL語句,爲了避免構造的開銷,將先前構造的結果緩存在selectSqls這個Map中,下次直接從緩存取就可以了。顯然由於沒有同步,這段代碼會遭遇和DCL同樣的問題,雖然selectSqls.get(...)可能能夠返回正確的引用,但是卻有可能返回該引用成員變量的非法值。另外selectSqls使用了非同步的Map,併發調用時可能會破壞它的內部狀態,這會造成嚴重的後果,甚至程序崩潰。可能的修復就是將整個方法聲明爲同步:

 

 

Java代碼 

 

  1. public synchronized Map executeSelect(final TableConfig tableConfig, Map keys)  {  
        // ....  
       }  

     

但是這樣馬上會遭遇吞吐量的問題,這裏在同步塊執行了數據庫查詢,執行數據庫查詢是是個很慢的操作,這會導致其它線程執行同樣的操作時造成不必要的等待,因此較好的方法是減少同步塊的作用域,將數據庫查詢操作排除在同步塊之外:

Java代碼 

 

  1. public Map executeSelect(final TableConfig tableConfig, Map keys)  {  
        PreparedSql psql = null;  
        synchronized(this) {  
        if (selectSqls.get(tableConfig.getId()) == null) {  
            selectSqls.put(tableConfig.getId(), constructSelectSql(tableConfig));  
        }  
        psql = (PreparedSql) selectSqls.get(tableConfig.getId());  
        }  
      
        List result = executeSql(...);  
          
        return result.isEmpty() ? null : (Map) result.get(0);  
    }  

     

現在情況已經改善了很多,畢竟我們將數據庫查詢操作拿到同步塊外面來了。但是仔細觀察會發現將this作爲同步鎖並不是一個好主意,同步塊的目的是保證從selectSqls這個Map中取到的是一致的對象,因此用selectSqls作爲同步鎖會更好,這能夠提高性能。這個類中還存在很多類似的方法executeUpdate,executeInsert時,它們都有自己的sql緩存,如果它們都採用this作爲同步鎖,那麼在執行executeSelect方法時需要等待executeUpdate方法,而這種等待原本是不必要的。使用細粒度的鎖,可以消除這種等待,最後得到修改後的代碼:

Java代碼 

 

  1. private Map selectSqls = Collections.synchronizedMap(new HashMap())  
       public Map executeSelect(final TableConfig tableConfig, Map keys)  {  
        PreparedSql psql = null;  
        synchronized(selectSqls) {  
            if (selectSqls.get(tableConfig.getId()) == null) {  
                selectSqls.put(tableConfig.getId(), constructSelectSql(tableConfig));  
            }  
            psql = (PreparedSql) selectSqls.get(tableConfig.getId());  
        }  
      
        List result = executeSql(...);  
             
           return result.isEmpty() ? null : (Map) result.get(0);  
       }  

     

我對selectSqls使用了同步Map,如果它只被這個方法使用,這就不是必須的。作爲一種防範措施,雖然這會稍微降低性能,即便當它被其它方法使用了也能夠保護它的內部結構不被破壞。並且由於Map的內部鎖是非競爭性鎖,根據官方說法,這對性能影響很小,可以忽略不計。這裏我有意無意地提到了編寫高性能的兩個原則,儘量減少同步塊的作用域,以及使用細粒度的鎖 ,關於細粒度鎖的最經典例子莫過於讀寫鎖了。這兩個原則要慎用,除非你能保證你的程序是正確的。

 

 

結束語

 

在這篇文章中我主要講到happen-before規則,並運用它來分析DCL問題,最後我用例子來說明DCL問題並不只是理論上的討論,在實際程序中其實很常見。我希望讀者能夠明白用happen-before規則比使用時間的先後順序來分析線程安全性要有效得多,作爲對比,你可以看看這篇經典的文章 中是如何分析DCL的線程安全性的。它是否講明白了呢?如果它講明白了,你是否又能理解?我想答案很可能是否定的,不然的話就不會出現這麼多對DCL的誤解了。當然我也並不是說要用happen-before規則來分析所有程序的線程安全性,如果你試着分析幾個程序就會發現這是件很困難的事,因爲這個規則實在是太底層了,要想更高效的分析程序的線程安全性,還得總結和利用了一些高層的經驗規則。關於這些經驗規則,我在文中也談到了一些,很零碎也不完全。

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