多線程編程的設計模式 臨界區模式(一)
臨界區模式 Critical Section Pattern 是指在一個共享范圍中只讓一個線程執行的模式.
它是所有其它多線程設計模式的基礎,所以我首先來介紹它.
把著眼點放在范圍上,這個模式叫臨界區模式,如果把作眼點放在執行的線程上,這個模式就叫
單線程執行模式.
首先我們來玩一個鉆山洞的游戲,我 Axman,朋友 Sager,同事 Pentium4.三個人在八角游樂場
循環鉆山洞(KAO,減肥訓練啊),每個人手里有一個牌子,每鉆一次洞口的老頭會把當前的次序,
姓名,牌號顯示出來,并檢查名字與牌號是否一致.
OK,這個游戲的參與者有游樂場老頭Geezer,Player,就是我們,還有山洞 corrie.
public class Geezer {
??? public static void main(String[] args){
???????
??????? System.out.println("預備,開始!");
??????? Corrie c = new Corrie();//只有一個山洞,所以生存一個實例后傳給多個Player.
??????? new Player("Axman","001",c).start();
??????? new Player("Sager","002",c).start();
??????? new Player("Pentium4","003",c).start();
??? }
}
這個類暫時沒有什么多說的,它是一個Main的角色.
public class Player extends Thread{
??? private final String name;
??? private final String number;
??? private final Corrie corrie;
??? public Player(String name,String number,Corrie corrie) {
??????? this.name = name;
??????? this.number = number;
??????? this.corrie = corrie;
??? }
???
??? public void run(){
??????? while(true){
??????????? this.corrie.into(this.name,this.number);
??????? }
??? }
}
在這里,我們把成員字段都設成final的,為了說明一個Player一旦構造,他的名字和牌號就不能改
變,簡單說在游戲中,我,Sager,Pentium4三個人不會自己偷偷把自己的牌號換了,也不會偷偷地去
鉆別的山洞,如果這個游戲一旦發生錯誤,那么錯誤不在我們玩家.
import java.util.*;
public class Corrie {
??? private int count = 0;
??? private String name;
??? private String number;
??? private HashMap lib = new HashMap();//保存姓名與牌號的庫
???
??? public Corrie(){
???????
??????? lib.put("Axman","001");
??????? lib.put("Sager","002");
??????? lib.put("Pentium4","003");
?
??? }
???
??? public void into(String name,String number){
??????? this.count ++;
??????? this.name = name;
??????? this.number = number;
??????? if(this.lib.get(name).equals(number))
?test():
??? }
???
??? public String display(){
??????? return this.count+": " + this.name + "(" + this.number + ")";
??? }
??? private void test(){
??????? if(this.lib.get(name).equals(number))
??????????? ;
??????????? //System.out.println("OK:" + display());
??????? else
??????????? System.out.println("ERR:" + display());
??? }
}
這個類中增加了一個lib的HashMap,相當于一個玩家姓名與牌號的庫,因為明知道Corrie只有一個實例,
所以我用了成員對象而不是靜態實例,只是為了能在構造方法中初始化庫中的內容,從真正意義中說應
該在一個輔助類中實現這樣的數據結構封裝的功能.如果不提供這個lib,那么在check的時候就要用
if(name.equasl("Axman")){
?if(!number.equals("001")) //出錯
}
else if .......
這樣復雜的語句,如果player大多可能會寫到手抽筋,所以用一個lib來chcek就非常容象.
運行這個程序需要有一些耐心,因為即使你的程序寫得再差在很多單線程測試環境下也能可是正確的.
而且多線程程序在不同的機器上表現不同,要發現這個例子的錯識,可能要運行很長一段時間,如果你的
機器是多CPU的,那么出現錯誤的機會就大好多.
在我的筆記本上最終出現錯誤是在11分鐘以后,出現的錯誤有幾鐘情況:
1: ERR:Axman(003)
2: ERR:Sager(002)
第一種情況是檢查到了錯誤,我的牌號明明是001,卻打印出來003,而第二種明明沒有錯誤,卻打印了錯誤.
事實上根據以前介紹的多線程知識,不難理解這個例子的錯誤出現,因為into不是線程安全的,所以在其中
一個線程執行this.name = "Axman";后,本來應該執行this.numner="001",卻被切換到另一個線程中執行
this.number="003",然后又經過不可預知的切換執行其中一個的if(this.lib.get(name).equals(number))
而出現1的錯誤,而在打印這個錯誤時因為display也不是線程安全的,正要打印一個錯誤的結果時,由于
this.name或this.number其中一個字段被修改卻成了正確的匹配而出現錯誤2.
另外還有可能會出現序號顛倒或不對應,但這個錯誤我們無法直觀地觀察,因為你根本不知道哪個序號"應該"
給哪個Player,而序號顛倒則有可能被滾動的屏幕所掩蓋.
[正確的Critical Section模式的例子]
我們知道出現這些錯誤是因為Corrie類的方法不是線程安全的,那么只要修改Corrie類為線程安全的類就行
了.其它類則不需要修改,上面說過,如果出現錯誤那一定不是我們玩家的事:
?
import java.util.*;
public class Corrie {
??? private int count = 0;
??? private String name;
??? private String number;
??? private HashMap lib = new HashMap();//保存姓名與牌號的庫
???
??? public Corrie(){
???????
??????? lib.put("Axman","001");
??????? lib.put("Sager","002");
??????? lib.put("Pentium4","003");
?
??? }
???
??? public synchronized void into(String name,String number){
??????? this.count ++;
??????? this.name = name;
??????? this.number = number;
?test();
??? }
???
??? public synchronized String display(){
??????? return this.count+": " + this.name + "(" + this.number + ")";
??? }
??? private void test(){
??????? if(this.lib.get(name).equals(number))
??????????? ;
??????????? //System.out.println("OK:" + display());
??????? else
??????????? System.out.println("ERR:" + display());
??? }
}
運行這個例子,如果你的耐心,開著你的機器運行三天吧.雖然測試100天并不能說明第101天沒有出錯,
at least,現在的正確性比原來那個沒有synchronized 保護的例子要可靠多了!
到這里我們對Critical Section模式的例程有了直觀的了解,在詳細解說這個模式之前,請想一下,test
方法安全嗎?為什么?
所謂模式就是脫離特定的例子使用更一般化的,通用化的表達方式來察看,描述,總結相同的問題.現在
我們來研究這個模式:
共享資源(sharedResource)參與者:
在臨界區模式中,一定有一個或一個以上的共享資源角色的參與.在上面這個例子中就是山洞(Corrie).
共享資源參與者會被多個線程訪問,這個角色的訪問方法有兩種類型,一種是多個線程訪問也不會發生問
題的方法,稱為線程安全的方法,另一種就是在多個線程同時訪問時會發生問題需要保護的方法,稱為不安
全的方法.
這里所說的線程安全和不安全的方法,不用多說大家都知道是指公開的方法.對上節最后我留下的問題而
言,test方法是安全的,因為它是private的,只會被into方法調用,而into方法是同步的,簡單說test中的
代碼一定會在同步塊中執行,而display方法是public的,有可能被任何線程調用,所以它需要同步.
對于線程安全的方法,不需要多說.而對于不安全的方法,只要定義為synchronized的就可以達到保護的
目的.也就是多個線程同時執行該段代碼時,只有一個線程有機會執行,具體機制我們在多線程中同步對象
鎖中已經說明過.我們把這種只有一個線程能進入的程序范圍,稱為[臨界區]
盡管JDK5以后提供了很多功能更強,語義更準確的并發控制的接口供程序員調用,但我還是極力推薦在大
多數情況下(除非需要有效的控制)還是使用synchronized來保護臨界區,因為synchronized塊的開始和結
束是自動控制的,在離開同步塊時會自動釋放同步對象鎖.而使用java的lock對象時,你不得不每時每刻小
心地在finally從句中調用lock對象的unlock方法,這比在finally從句中釋放數據庫連結更重要!
[適用環境]
1.單線程環境:單線程環境中肯定只有一個線程執行,無論是否在臨界區中反正只有一個線程執行,所以沒
有必要用synchronized保護,當然如果你非想用synchronized保護沒有問題,只是會引起性能的降低,但不
會降低太大.這就象一個人在家里已經關上了大門,還關著臥室的小門,除了會給你帶來一些不便之處,沒有
什么太大的損失.
2.多線程環境:如果這些多線程環境中各自完全獨立地運行,當然沒有問題.但如果多個線程可能訪問同一
SharedResource對象時,就需要使用臨界區模式來保護.有時管理線程的環境會提供一種SafeThread環境來
確保線程的獨立,這種情況就不需要使用臨界區模式.
3.SharedResource的狀態會發生改變的情況才需要使用這個模式,如果SharedResource對象一經生成就不
會改變,當然不需要保護.(只讀模式)
4.在必要的確保安全性的時候使用這個模式.比如java數據結構類大多數都不是線程安全的.因為很多情況
下發生多個線程共享沖突對程序本身并無大礙,比如用一個ArrayList或HashMap存放在線人數,對于在線
人數這種數據本來就不可能精確地計算,只是相對時間內的一個概數,所以多個線程訪問對產生沖突對其幾
乎沒有影響.
但是對于需要確保線程安全的時候,java仍然提供了大量的線程安全的數據結構的封裝,由Collections類
提供的synchronizedXXX()方法可以將傳入的數據結構封裝為線程安全的.
[性能因素]
在程序設計中,大多數情況下,各種優點無法共存,事實上如果使用一個模式能給其它方面的優點也帶來提
升那簡單就沒有理由不使用該模式了.對于安全性的提升往往要以犧牲性能為代價,所以臨界區模式會帶來
一些性能方面的損失.如何權衡這它們之間的比例,要看程序運行的環境,目的等各方面的因素.
1.獲取對象鎖的操作本身是要花時間的.一個線程在獲取同步對象鎖時,其實就是一個全局對象的自旋鎖,這
個全局對象是要注冊到線程管理系統中的.這個過程本身需要一定的時間.但這個過程性能影響并不大.
2.同步對象鎖被其它線程占用時需要等待.當一個線程進入同步塊時,獲取該同步對象的鎖,如果該鎖被其它
線程擁有測當前線程必須等待,從而降低性能,這方面性能的降低較大.
提高性能的方法一是盡量減少共享資源的數量.二是盡量減小臨界區的范圍.雙檢鎖模式就是減小臨界區范
圍的一種手段.
[死鎖問題]
臨界區模式中非常重要的一點是多線程程序的生命指數.再安全的程序如果運行一定時間就結束自己的生命
而不能繼續運行,那就根本不能達到設計的目的.除去系統突發因素,影響生命指數的最大原因就是死鎖.
對于大家都熟悉的五個哲學家(好象是故意調侃哲學家)吃面條的例子,我們用最簡單的模型簡單為兩個哲學
家.然后從中抽象出死鎖的最一般的條件:
1.有多個共享資源被多線程共享.對于兩個吃面的哲學家而言就是刀和叉兩上以上的共享資源.
2.對一個共享資源的占用還沒有釋放鎖又獲取另一個共享資源.占用了刀的時候又要獲取叉.
3.對共享資源的占用順序是不固定的.如果哲學家按一定順序使用刀和叉,一個用完了思考時再讓給另一個
用那就能很好地完成目標而不會發生死鎖,正時因為對共享資源占用的順序是無法確定的.當一個結程占用
一個共享資源時,要獲取另一個線程占用的共享資源,而另一個線程釋放這個共享資源的條件是以獲取被原
先被占用的共享資源時,才會發生死鎖.
所以如果我們破壞上面其中之一的條件就不會發生死鎖問題,也就是在設計時要考慮不要同時發生上面的
三程情況.
[嵌套鎖定]
對于同一對象的嵌套鎖定,例子如下:
synchronized(this){//1
????System.out.println("outter");
????synchronized(this){//2
????????System.out.println("inner");
????}
}
這個例子能運行嗎?答案是可以很好地運行.
一般以為線程運行到1時,獲取了當前對象鎖,打印outter后,運行到2,又要獲取當前對象鎖,而此時當前對象
鎖還沒有釋放,所以線程一直等在這兒發生死鎖.
其實java是一種smart?language,在編譯的時候,它就會檢查對同一對象的嵌套鎖定.因為不可能發生在層同
步塊中有多個線程進入而其中一個線程要進入內層同步塊的情況,也就是外層同步塊本身就可以保證只有一
個線程獲取同步對象的鎖,所以內層同一對象的同步塊在編譯的時候已經失去它的作用.
[繼承和擴展]
對于臨界區模式而言,即使我們已經使用synchronized方法對共享資源進行保護,但是子類在擴展接口時很可
能將共享資源以不安全方式暴露出去.這是非常值得注意的問題.設計時應該盡時將對共享資源的訪問方法加
以保護,可以使用private和final等限制,另外在子類設計時也要充分考慮對父類共享資源的訪問.
[高級主題:關于synchronized]
其實在多線程編程基礎部分,我已經談過synchronized相關的內容.但臨界區模式是其它多線程編程模式的基
礎,所以在這里繼續深入一下談談synchronized相關的一些內容.
只要見到synchronized關鍵字,第一要想到的問題就是,synchronized在保護誰?
在上面的例子中,synchronized保護的是Corrie對象的counter,name,number三個字段不被"交差賦值",也就是
這三個字段同時只能被一個線程訪問.
其次我們要考慮的問題是:這些對象都被妥善地保護了嗎?
這是非常重要的問題.無論你花巨資打造一把高安全性鎖,把自己的家門牢牢地鎖住,可是你卻把門旁邊的窗子
敞開著,那么你花巨資打造的鎖又要什么意義呢?所以要確保從任何一個通道訪問被保護的對象都被加鎖控制
的,比如字段是否都private或protected的,對于protected的子類中的擴展方法是否能保護被保護對象.
對于上面的例子因為display有可能被外面的方法單獨調用,所以它也必須是同步的.而test方法只會在into中
調用,簡單說它只是所有通道被加了鎖的大房子中的一個小單元,所以不必擔心有人會從外部訪問它.
要注意保護的范圍是三個同時需要保護的字段,如果它們被分別放在synchronized方法中保護,并不能保證它們
本個字段同時只有一個線程訪問.
那么我們就有一個問題,獲取誰的鎖呢?
要保護一個對象,當然直接獲取這個對象的鎖,我們上面的例子可以理解為要同時保護三個對象,那么其實就是
要保護這個本個對象的容器.也就是它們所在的實例.如果不相關的三個對象要同時保護,一定要放在同時容納
它們的容器中,否則無法同時保護它們的狀態.對于上面的例子我們同樣可以理解為要保護的是Corrie的實例,
因為這個實例是這三個字段的容器.所以我們用synchronized方法就是等同于synchronized(this){.......}
如果這個游戲中有多個山洞,而只有一塊顯示牌,那以我們就需要保護多個實例的三個字段同時只被一個線程
訪問,我們就需要synchronized(Corrie.class)來保證多個實例被多個線程訪問時只有一個對程能同時對三個
字段訪問.
所以獲取誰的鎖定也是一個很重要的問題,如果你選擇了錯誤的對象,就象你花巨資打了一把鎖卻鎖了別人的
門.
synchronized就是原子操作,簡單說在一個線程進行同步塊中的代碼時不能進入,這是很明顯的.但同時,多個
同步方法或多個獲取同一對象的同步塊在同一時候也只能一個線程能訪問其中之一,因為控制誰能訪問的是要
獲得那個同步對象的鎖.如:
class C{
?synchronized? a(){}
?synchronized? b(){}
}
當一個線程進入同步方法a后那么其它線程當然不能進入a,同時也不能進入b,因為能進入的條件是獲取this對
象的鎖.一個結程進入a后this對象的鎖被這個線程獲取,其它線程進入b也同樣要獲取這個鎖,而不僅僅是進入
a要獲取這個鎖.這一點一定要理解.
理解上面的知識我們再回過頭來看原子操作.
JLS規定對于基本類型(除long和double)以外的賦值和引用都是原子操作,并且對于引用類型的賦值和引用也是
原子操作.
注意這里有兩個方面的知識點:
1.對于long和double的操作非原子性的.需要說明這只是JLS的規定,但大多數JVM的實現其實已經保證了long和
double的賦值和引用也是原子性的,只是允許某種實現可以不是原子性的操作.
對于其它基本類型如int,如果一個線程執行x = 1;另一個線程執行x = 2;
由于可見性的問題(多線程編程系統中已經介紹),x要么就是1,要么就是2,看誰先同步到主存儲區.
但對于long,l = 1;l = 2;分別由兩個線程執行的結果有可能不是你想象的,它們有可能是0,或1,或2,或一個其
它的隨機數,簡單說兩上線程中l的值的部分bit位可能被另一個線程改寫.所以最可靠的是放在synchronized中
或用volatile 保護.當然這里說的是"有非常可靠的需要",一般而言現在的JVM已經能保證long和double也是原
子操作的.
2.我們看到,對于引用對象的賦值和引用也是原子的.
我們還是看javaworld上dcl的例子.
?那個錯誤的例子誤了好多人,(JAVA與模式的作者就是受害人),我們先不說JAVA內存模型的原因(前面我已經從
JAVA內存模型上說明了那個例子是錯誤的,我是說對那個例子的分析是錯誤的).單從對于"引用對象的賦值和引
用也是原子的"這句話,就知道對于引用字段的賦值,絕對不可能出現先分配空間,然后再還沒有被始化或還沒有
調構造方法之前又被別的線程引用.因為當一個線程在執行賦值的時候是原子性的操作,其它線程的引用操作也是原子性的操作?的,在賦值操作沒有完成之前其它線程根本不可能見到"分配了空間卻沒有
初始化或沒有調用構造方法"的這個對象.
不知道什么原因,這樣的一個例子從它誕生開始竟然是所有人都相信了,也許有人責疑過但我不知道.如果你有足
夠的基礎知識,就不必跟著別人的感覺走!
因為這是一個最最基礎的模式,暫時不介紹它與其它模式的關系.在以后介紹其它模式時反過來再和它進行比較.
而一些復雜的模式都是在這個簡單的模式的基礎上延伸的.