避免活躍性危險

安全性與活躍性之間一般存在着某種制衡:數據庫

咱們平時使用加鎖機制來確保線程安全,但若是過量地使用加鎖,則可能致使鎖順序死鎖。windows

一樣,咱們使用線程池和信號量來限制對資源的使用,但這些被限制的行爲可能致使資源死鎖。安全

1、死鎖

經典的「哲學家進餐」問題很好地描述了死鎖情況。

5個哲學家圍坐在一個圓桌上,每兩個哲學家之間都有一隻筷子,哲學家平時進行思考,只有當他們飢餓時,纔拿起筷子吃飯。規定每一個哲學家只能先取其左邊筷子,而後取其右邊筷子,而後才能夠吃飯。若是5個哲學家同時拿起本身左邊的筷子,就會發生死鎖。每一個人都擁有其餘人須要的資源,同時又等待其餘人已經擁有的資源,而且每一個人在得到全部須要的資源以前都不會放棄已經擁有的資源。bash

當一個線程永遠地持有一個鎖,而且其餘線程都嘗試得到這個鎖時,那麼它們將永遠被阻塞。這種狀況就是最簡單的死鎖形式(稱爲抱死[Deadly Embrace]),其中多個線程因爲存在環路的鎖依賴關係而永遠等待下去。(把每一個線程假想爲有向圖的一個節點,圖中每條邊表示的關係是:「線程A等待線程B所佔有的資源」。若是圖中造成一條環路,那麼就存在一個死鎖)。網絡

1.1 鎖順序死鎖

LeftRightDeadlock 存在死鎖的風險。兩個方法分別獲取了left鎖和right鎖,若是其中一個線程在獲取了left鎖的同時,去獲取right鎖,而另外一個線程在獲取了right鎖的同時去獲取left鎖,那麼他們就會發生死鎖。

發生死鎖的緣由是:兩個線程試圖以不一樣的順序來得到相同的鎖。若是按照相同的順序來請求鎖,那麼就不會出現循環的加鎖依賴性,不會產生死鎖。若是每一個須要鎖L和鎖M的線程都以相同的順序來獲取L和M,就不會發生死鎖併發

若是全部的線程以固定的順序來得到鎖,那麼在程序中就不會出現鎖順序死鎖問題。

1.2 動態的鎖順序死鎖

考慮下方的代碼,它將資金從一個帳戶轉入到另外一個帳戶。在開始轉帳以前,首先要得到這兩個Account對象的鎖,以卻不經過原子方式來更新兩個帳戶中的餘額,同時又不能破壞一些不變性條件,例如「帳戶的餘額不能爲負數」。框架

//容易發生死鎖
public void transferMoney(Account fromAccount,
                          Account toAccount,
                          DollarAmount amount)
           throws InsufficientFundsException {
   synchronized (fromAccount) {
     synchronized (toAccount) {
        if (fromAccount.getBalance().compareTo(amount) < 0)
            throw new InsufficientFundsException();
        else {
           fromAccount.debit(amount);
           toAccount.credit(amount);
        }
    }
  }
複製代碼

全部的線程彷佛按相同的順序來得到鎖,但事實上鎖的順序取決與傳遞給transferMoney的參數順序,而這些參數順序又取決與外部輸入。 若是兩個線程同時調用transferMoney,其中一個線程從X向Y轉帳,而另外一個線程從Y向X轉帳,那麼就會發生死鎖:dom

A: transferMoney(myAccount, yourAccount, 10);ide

B: transferMoney(yourAccount, myAccount, 20);工具

若是執行時序不當,那麼A可能得到myAccount的鎖並等待yourAccount的鎖,然而B此時擁有yourAccount的鎖並正在等到myAccount的鎖。

在制定鎖的順序時,可使用System.identityHashCode方法,該方法將返回由Object.hashCode返回的值。下方給出另外一個版本的transferMoney,使用了System.identityHashCode來定義鎖的順序。 雖然加了一些新的代碼,但卻消除了死鎖的可能性。

//         經過鎖順序來避免死鎖
private static final Object tieLock=new Object();

   public void transferMoney(final Account fromAcct,
                             final Account toAcct,
                             final DollarAmount amount)
               throws InsufficientFundsException{  //自定義異常類,繼承Exception類,當取款的數額大於存款時拋出
       class Helper{
           public void transfer()throws InsufficientFundsException {
               if(fromAcct.getBalance().compareTo(amount)<0)
                   throw new InsufficientFundsException();
               else{
                   fromAcct.debit(amount);   //debit記入借方,fromAcct減小amount
                   toAcct.credit(amount);    //credit記入貸方,toAcct增長amount
               }
           }
       }
       //使用了System.identityHashCode來定義鎖的順序
       ////返回給定對象的哈希碼,該代碼與默認的方法 hashCode() 返回的代碼同樣,不管給定對象的類是否重寫 (override)hashCode()。
       int fromHash=System.identityHashCode(fromAcct); 
       int toHash=System.identityHashCode(toAcct);

       if(fromHash<toHash){
           synchronized (fromAcct) {
             synchronized (toAcct) {
                 new Helper().transfer();   
            }
           }
        }else if(fromHash>toHash){
            synchronized (toAcct) {
                synchronized (fromAcct) {
                    new Helper().transfer();
                }
            }
        }else{   //若是獲得兩個相同的hashcode,使用加時賽鎖,從而保證每次只有一個線程以未知的順序獲得這兩個鎖
            synchronized (tieLock) {
                synchronized (fromAcct) {
                    synchronized (toAcct) {
                        new Helper().transfer();
                    }
                }
            }
    }
   }
複製代碼

在極少數狀況下,兩個對象可能擁有相同的散列值(HashCode),此時必須經過某種任意的方法來決定鎖的順序,而這可能又會從新引入死鎖。爲了不這種狀況,可使用「加時賽(Tie-Breaking)鎖」。在得到兩個Account鎖以前,首先得到這個「加時賽」鎖,從而保證每次只有一個線程以未知的順序獲得這兩個鎖,從而消除了死鎖發生的可能性(只要一致地使用這種機制)。

若是常常出現散列衝突(hash collisions),那麼這種技術可能會稱爲併發性的一個瓶頸(相似與在整個程序中只有一個鎖的狀況,由於常常要等待得到加時賽鎖),但因爲System.identityHashCode中出現散列衝突的頻率很是低,所以這項技術以最小的代價,換來了最大的安全性。

若是在Account中包含一個惟一的,不可變的,而且具有可比性的鍵值,例如帳號,那麼要指定鎖的順序就更加容易:經過鍵值對對象進行排序,於是不須要使用「加時賽」鎖。

1.3 在協做對象之間發生的死鎖

某些獲取多個鎖的操做並不想上面那麼明顯,這兩個鎖不必定在同一個方法中被獲取。下方中兩個互相協做的類,在出租車調度系統中可能會用到它們。Taxi表明一個出租車對象,包含位置和目的地兩個屬性,Dispatcher表明一個出租車車隊。

//   在互相協做對象之間的鎖順序死鎖(不要這樣作)  
//   容易發生死鎖
  class Taxi{
        private Point location,destination;  //Taxi表明一個出租車對象,包含位置和目的地兩個屬性
        private final Dispatcher dispatcher; //Dispatcher表明一個出租車車隊

        public Taxi(Dispatcher dispatcher){  
            this.dispatcher=dispatcher;
        }
        public synchronized Point getLocation(){
            return location;
        }
        public synchronized void setLocation(Point location){
            this.location=location;
            if(location.equals(destination))
                dispatcher.notifyAvailable(this);
        }
    }

    class Dispatcher{
        private final Set<Taxi> taxis;
        private final Set<Taxi> availableTaxis;

        public Dispatcher(){
            taxis=new HashSet<Taxi>();
            availableTaxis=new HashSet<Taxi>();
        }
        public synchronized void notifyAvailable(Taxi taxi){  //notify 通知 ,available 空閒的
            availableTaxis.add(taxi);
        }
        public synchronized Image getImage(){
            Image image = new Image();
            for (Taxi t : taxis)
               image.drawMarker(t.getLocation());
            return image;
        }   
    }
複製代碼

儘管沒有任何方法顯式地獲取兩個鎖,但setLocation和getImage等方法的調用者都會得到兩個鎖。若是一個線程在收到GPS接收器的更新事件時調用setLocation,那麼它將首先更新出租車的位置,而後判斷它是否到達了目的地。若是到達了,它會通知Dispatcher:它須要一個新目的地。由於setLocation和notifyAvailable都是同步方法,所以調用setLocation的線程將首先得到Taxi的鎖,而後再得到Dispatcher的鎖。一樣,調用getImage的線程將首先獲取Dispatcher鎖,而後再獲取每個Taxi的鎖(每次獲取一個)。這與LeftRightDeadlock中的狀況相同,兩個線程按照不一樣的順序來獲取兩個鎖,所以可能產生死鎖。

在LeftRightDeadlock和transferMoney中,要查找死鎖時比較簡單的:只須要找出那些須要獲取兩個鎖的方法。然而要在Taxi和Dispatcher中查找死鎖是比較困難的:若是在持有鎖的狀況下須要調用某個外部方法,就須要警戒死鎖。

若是在持有鎖時調用某個外部方法,那麼將出現活躍性問題。在這個外部方法中可能或獲取其餘鎖(這可能產生死鎖),或者阻塞時間過長,致使其餘線程沒法及時得到當前被持有的鎖。

1.4 開放調用(Open Calls)

方法調用至關於一種抽象屏障,你無需瞭解在調用方法中所執行的操做,也正是因爲不知道在被調用方法中執行的操做,所以在持有鎖的時候對調用某個外部方法將難以進行分析,從而可能出現死鎖。

若是在調用某個方法時不須要持有鎖,那麼這種調用被稱爲開放調度(Open Call)。

依賴於開放調度的類一般能表現出更好的行爲,而且與那些在調度方法時須要持有鎖的類相比,也更易於編寫。 這種經過開放來避免死鎖的方法,相似於採用封裝機制來提供線程安全的方法:雖然在沒有封裝的狀況下也能確保構建線程安全的類,但對一個使用了封裝的程序進行線程安全分析,要比分析沒有使用封裝的程序容易得多。 同理,分析一個徹底依賴於開放調用的程序的活躍性,要比分析那些不依賴開放調用的程序的活躍性簡單。 經過儘量地使用開放調用,將更容易找出那些須要獲取多個鎖的代碼路徑,所以也就更容易確保採用一直的順序來得到鎖。

將上方代碼修改成開放調用,從而消除死鎖的風險,這須要使同步代碼塊僅被用於保護那些涉及共享狀態的操做。

一般,若是隻是爲了語法緊湊或簡單性(而不是由於整個方法必須經過一個鎖來保護)而使用同步方法(而不是同步代碼塊),將致使上方代碼中的問題。

//經過公開調用來避免在互相協做的對象之間產生死鎖
 class Taxi{
        private Point location,destination;  //Taxi表明一個出租車對象,包含位置和目的地兩個屬性
        private final Dispatcher dispatcher; //Dispatcher表明一個出租車車隊

        public Taxi(Dispatcher dispatcher){
            this.dispatcher=dispatcher;
        }
        public synchronized Point getLocation(){
            return location;
        }
        public void setLocation(Point location){
            boolean reachedDestination;
            synchronized (this) {
                this.location=location;
                reachedDestination=location.equals(destination);
            }
            if(reachedDestination)
                dispatcher.notifyAvailable(this);
        }
    }

    class Dispatcher{   //調度
        private final Set<Taxi> taxis;
        private final Set<Taxi> availableTaxis;

        public Dispatcher(){
            taxis=new HashSet<Taxi>();
            availableTaxis=new HashSet<Taxi>();
        }
        public synchronized void notifyAvailable(Taxi taxi){  //notify 通知 ,available 空閒的
            availableTaxis.add(taxi);
        }
        public  Image getImage(){
            Set<Taxi> copy;
            synchronized (this) {
                copy=new HashSet<Taxi>(taxis);
            }
            Image image=new Image();
            for(Taxi t:copy)
                image.drawMarker(t.getLocation());
            return image;
        }   
    }
複製代碼

在程序中應儘可能使用開放調用,與那些在持有鎖時調用外部方法的程序相比,更容易對依賴於開放調用的程序進行死鎖分析。

有時候在從新編寫同步代碼塊以使用開放調用時會產生意想不到的結果,由於這會使得某個原子操做變成非原子操做。 在許多狀況下,某個操做失去原子性是能夠接受的。例如,對於兩個操做:更新出租車位置以及通知調度程序這輛出租車已準備好出發去一個新的目的地,這兩個操做並不須要實現爲一個原子操做。

然而,在某些狀況下,丟失原子性會引起錯誤,此時須要經過另外一種技術來實現原子性。 例如,在構造一個併發對象時,使得每次只有單個線程執行使用了開放調用的代碼路徑。 例如,在關閉某個服務時,你可能但願全部正在運行的操做執行完成之後,再釋放這些服務佔用的資源。若是在等待操做完成的同時持有該服務的鎖,那麼將容易致使死鎖,但若是在服務關閉以前就釋放服務的鎖,則可能致使其餘線程開始新的操做。 這個問題的解決方法是,在將服務的狀態更新爲「關閉」以前一直持有鎖,這樣其餘想要開始新操做的線程,包括想關閉該服務的其餘操做,會發現服務已經不可用,所以也就不會試圖開始新的操做。而後,你能夠等待關閉操做結束,而且知道當開放調用完成後,只有執行關閉操做的線程才能訪問服務的狀態。所以,這項技術依賴於一些協議(而不是經過加鎖)來防止其餘線程來進入代碼的臨界區。

1.5 資源死鎖

正如當多個線程相互持有彼此正在等待的鎖而不釋放本身已持有的鎖時發生死鎖,當它們在相同的資源集合上等待時,也會發生死鎖。

假設有兩個資源池,例如兩個不一樣數據庫的鏈接池。資源池一般採用信號量來實現(5.5.3)當資源池爲空的阻塞行爲。若是一個任務須要鏈接兩個數據庫,而且在請求這兩個資源時不會始終遵循相同的順序,那麼線程A可能持有與數據庫D1的鏈接,並等待與數據庫D2的鏈接,而線程B則持有與D2的鏈接並等待與D1的鏈接(資源池越大,出現這種狀況的可能性就越小,若是每一個資源池都有N個鏈接,那麼在發生死鎖時不只須要N個循環等待的線程,並且還須要大量不恰當的執行時序)

另外一種基於資源的死鎖形式就是線程飢餓死鎖(Thread-Starvation Deadlock)。

一個示例:一個任務提交另外一個任務,並等待被提交任務在單線程的Executor中執行完成。這種狀況天,第一個任務將永遠等待下去,並使得另外一個任務以及在這個Executor中執行的全部其餘任務都中止執行。若是某些任務須要等待其餘任務的結果,那麼這些任務每每時產生線程飢餓死鎖的主要來源,有界線程池/資源池與相互依賴的任務不能一塊兒使用。

2、死鎖的避免與診斷

若是必須獲取多個鎖,那麼在設計時必須考慮鎖的順序:儘可能減小潛在的加鎖 交互數量,將獲取鎖時須要遵循的協議寫入正式文檔並始終遵循這些協議。

在使用細粒度(fine-grained)鎖的程序中,能夠經過使用一種兩階段策略(Two-Part Strategy)來檢查代碼中的死鎖:首先,找出在什麼地方將獲取多個鎖(使這個集合儘可能小),而後對全部這些實例進行全局分析,從而確保它們在整個程序中獲取鎖的順序都保持一致。儘量地使用開放調用,這能極大地簡化分析過程。 若是全部的調用都是開放調用,那麼要發現獲取多個鎖的實例是很是簡單的,能夠經過代碼審查或者藉助自動化的源代碼分析工具。

2.1 支持定時的鎖

當使用內置鎖時,只要沒有得到鎖,就會永遠等待下去,而顯式鎖則能夠執行一個超時時限(Timeout),在等待超過該事件後tryLock會返回一個失敗信息。

若是超時時限要比獲取鎖的時間要長不少,那麼就能夠在發生某個之外狀況後從新得到控制權。

當定時鎖失敗時,並不須要知道失敗的緣由。或許是由於發生了死鎖,或許某個線程在持有鎖時錯誤地進入了無限循環,還多是某個操做的執行時間遠遠超出了預期。 然而,至少能記錄所發生的失敗,以及關於此次操做的其餘有用信息,並經過一種更平緩的方法來從新啓動計算,而不是關閉整個進程。

即便在整個系統中沒有始終使用定時鎖,使用定時鎖來獲取多個鎖也能有效地應對死鎖問題。 若是在獲取鎖時超時,那麼能夠釋放這個鎖,而後後退並在一段時間後再次蠶食,從而消除了死鎖發生的條件,使程序恢復過來。(這項技術只有在同時獲取兩個鎖時纔有效,若是在嵌套的方法調用中請求多個鎖,那麼即便你知道已經有了外層的鎖,也沒法釋放它)

2.2 經過線程轉儲信息來分析死鎖

JVM經過線程轉儲(Thread Dump)來幫助識別死鎖的發生。

線程轉儲包括各個運行中的線程的棧追蹤信息,這相似於發生異常時的棧追蹤信息。

線程轉儲還包含加鎖信息,例如每一個線程持有了哪些鎖,在那些棧幀中得到這些鎖,以及被阻塞的線程正在等待獲取哪個鎖。 在生成線程轉儲以前,JVM將在等待關係圖中經過搜索循環來找出死鎖。若是發現了一個死鎖,則獲取相應的死鎖信息,例如在死鎖中涉及哪些鎖和線程,以及這個鎖的獲取操做位於程序的哪些位置.

要在UNIX平臺上觸發線程轉儲操做,能夠經過向JVM的進程發送SIGQUIT信息(kill-3),或者在UNIX平臺中按下Ctrl-\鍵,在windows平臺中按下Ctrl-Break鍵。在許多IDE(Integrated Development Environment,集成開發環境)中均可以請求線程轉儲。

當有死鎖發生時,能夠發現相似以下的信息: Found One Java-level deadlock:

內置鎖與得到它們所在的線程棧幀時相關聯的,而顯式的Lock只得到它的線程相關聯。

3、其餘活躍性危險

死鎖是最多見的活躍性危險,在併發線程中還存在一些其餘的活躍性危險,包括:飢餓,丟失信號和活鎖等。

3.1 飢餓(Starvation)

當線程因爲沒法訪問它所須要的資源而不能繼續執行時,就發生了「飢餓(Starvation)」。

引起飢餓的最多見資源就是CPU時鐘週期。若是在Java應用程序中對線程的優先級使用不當,或者在持有鎖時執行一些沒法結束的結構(例如無限循環,或無限制等待某個資源),那麼也可能致使飢餓,由於其餘須要這個鎖的線程將沒法獲得它。

在Thread API定義的線程優先級只是做爲線程調度的參考。在Thread API中定義了10個優先級,JVM根據須要將它們映射到操做系統的調度優先級,這種映射時與特定平臺(不一樣的操做系統)相關的。在某些操做系統中,若是優先級的數量少於10個,那麼有多個Java優先級會被映射到同一個優先級。

要避免使用線程優先級,由於這會增長平臺依賴性,並可能致使活躍性問題。在大多數併發應用程序中,均可以使用默認的線程優先級。

3.2 糟糕的響應性

若是在GUI應用程序中使用了後臺線程,那麼糟糕的響應性時是常見的。

GUI框架中,若是你的後臺任務是cpu密集型的,會與主的事件線程競爭cpu的時鐘週期,可能致使cpu主線程的響應性,這時能夠下降後臺線程的優先級。

不良的鎖管理也可能致使糟糕的響應性。若是某個線程長時間佔有一個鎖(或者正在對一個大容器進行迭代,而且對每一個元素進行計算密集的處理),而其餘想要訪問這個容器的線程就必須等待很長時間。

3.3 活鎖(Livelock)

活鎖(Livelock)是另外一種形式的活躍性問題,儘管不會阻塞線程,但也不能繼續執行,由於線程不斷重複執行相同的操做,並且總會失敗。

活鎖一般發生在處理事務消息的應用程序中:若是不能成功地處理某個消息,那麼消息處理機制將回滾整個事務,並將它從新放到隊列的開頭。若是消息處理其在處理某種特定類型的消息時存在錯誤並致使它失敗,那麼每當這個消息從隊列中取出並傳遞到存在錯誤的處理器時,都會發生事務回滾。因爲這條消息又被放回到隊列開頭,所以處理器將被反覆調用,並返回先溝通的結果(有時候也被稱爲毒藥消息,Poison Message)。

雖然處理信息的線程沒有阻塞,但也沒法繼續執行下去。這種形式的活鎖一般時由過分的錯誤恢復代碼形成的,由於它錯誤將不可修復的錯誤做爲可修復的錯誤。

當多個相互協做的線程都對彼此進行響應從而修改各自的狀態,並使得任何一個線程都沒法繼續執行時,就發生了活鎖。 這就像兩個過於禮貌的人在半路上面對面相遇了:他們彼此都讓出對方的路,而後又在另外一條路上相遇了,所以他們就這樣反覆地避讓下去。

要解決這種活鎖問題,須要在重試機制中引入隨機性(randomness)。 例如,在網絡上,若是有兩臺機器嘗試使用相同的載波來發送數據包,那麼這些數據包就會發生衝突。這兩臺機器都檢查到了衝突,並都在稍後再次發送。 若是兩者都選擇了在0.1秒後重試,那麼會再次衝突,而且不斷衝突下去,於是即便有大量閒置的寬帶,也沒法使數據包發送出去。 爲了不這種狀況發生,須要讓它們分別等待一段隨機的時間(以太協議定義了在重複發生衝突時採用指數方式回退機制,從而下降在多臺存在衝突的機器之間發生擁塞和反覆失敗的風險)。

在併發應用程序中,經過等待隨機長度的時間和回退能夠有效地避免活鎖的發生。

相關文章
相關標籤/搜索