Java併發編程 併發安全勁敵 線程死鎖 以及線程不安全引發的問題

死鎖

概念

是指兩個或兩個以上的進程在執行過程中,由於競爭資源或者由於彼此通信 而造成的一種阻塞的現象,若無外力作用,它們都將無法推進下去。此時稱系統處於死鎖狀態或系統產生了死鎖。

舉個例子:
A 和 B 去按摩洗腳,都想在洗腳的時候,同時順便做個頭部按摩, 13 技師擅長足底按摩,14 擅長頭部按摩。
這個時候 A 先搶到 14,B 先搶到 13,兩個人都想同時洗腳和頭部按摩,於 是就互不相讓,揚言我死也不讓你,這樣的話,A 搶到 14,想要 13,B 搶到 13, 想要 14,在這個想同時洗腳和頭部按摩的事情上 A 和 B 就產生了死鎖。怎麼解決這個問題呢?

  • 第一種,假如這個時候,來了個 15,剛好也是擅長頭部按摩的,A 又沒有兩 個腦袋,自然就歸了 B,於是 B 就美滋滋的洗腳和做頭部按摩,剩下 A 在旁邊氣 鼓鼓的,這個時候死鎖這種情況就被打破了,不存在了。
  • 第二種,C 出場了,用武力強迫 A 和 B,必須先做洗腳,再頭部按摩,這種 情況下,A 和 B 誰先搶到 13,誰就可以進行下去,另外一個沒搶到的,就等着, 這種情況下,也不會產生死鎖。

所以總結一下:
死鎖是必然發生在多操作者(M>=2 個)情況下,爭奪多個資源(N>=2 個, 且 N<=M)纔會發生這種情況。很明顯,單線程自然不會有死鎖,只有 B 一個去, 不要 2 個,打十個都沒問題;單資源呢?只有 13,A 和 B 也只會產生激烈競爭, 打得不可開交,誰搶到就是誰的,但不會產生死鎖。同時,死鎖還有一個重要的 要求,爭奪資源的順序不對,如果爭奪資源的順序是一樣的,也不會產生死鎖。

學術化的定義

死鎖的發生必須具備以下四個必要條件。

  • 互斥條件:指進程對所分配到的資源進行排它性使用,即在一段時間內 某資源只由一個進程佔用。如果此時還有其它進程請求資源,則請求者只能等待, 直至佔有資源的進程用畢釋放。
  • 請求和保持條件:指進程已經保持至少一個資源,但又提出了新的資源 請求,而該資源已被其它進程佔有,此時請求進程阻塞,但又對自己已獲得的其 它資源保持不放。
  • 不剝奪條件:指進程已獲得的資源,在未使用完之前,不能被剝奪,只 能在使用完時由自己釋放。
  • 環路等待條件:指在發生死鎖時,必然存在一個進程——資源的環形鏈, 即進程集合{P0,P1,P2,···,Pn}中的 P0 正在等待一個 P1 佔用的資源;P1 正在等待 P2 佔用的資源,……,Pn 正在等待已被 P0 佔用的資源。

理解了死鎖的原因,尤其是產生死鎖的四個必要條件,就可以最大可能地避免、預防和解除死鎖。只要打破四個必要條件之一就能有效預防死鎖的發生:打 破互斥條件:改造獨佔性資源爲虛擬資源,大部分資源已無法改造。打破不可搶 佔條件:當一進程佔有一獨佔性資源後又申請一獨佔性資源而無法滿足,則退出 原佔有的資源。打破佔有且申請條件:採用資源預先分配策略,即進程運行前申 請全部資源,滿足則運行,不然就等待,這樣就不會佔有且申請。打破循環等待 條件:實現資源有序分配策略,對所有設備實現分類編號,所有進程只能採用按 序號遞增的形式申請資源。

避免死鎖常見的算法有有序資源分配法、銀行家算法。

現象、危害和解決

在我們 IT 世界有沒有存在死鎖的情況,有:數據庫裏多事務而且要同時操 作多個表的情況下。所以數據庫設計的時候就考慮到了檢測死鎖和從死鎖中恢復 的機制。比如 oracle 提供了檢測和處理死鎖的語句,而 mysql 也提供了“循環依賴檢測的機制”
在這裏插入圖片描述
在這裏插入圖片描述
在 Java 世界裏存在着多線程爭奪多個資源,不可避免的存在着死鎖。那麼我們在編寫代碼的時候什麼情況下會發生呢?

現象

  • 簡單順序死鎖
/**
 *類說明:演示普通賬戶的死鎖和解決
 */
public class NormalDeadLock {
    private static Object valueFirst = new Object();//第一個鎖
    private static Object valueSecond = new Object();//第二個鎖

    //先拿第一個鎖,再拿第二個鎖
    private static void fisrtToSecond() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        synchronized (valueFirst){
            System.out.println(threadName+" get 1st");
            Thread.sleep(100);
            synchronized (valueSecond){
                System.out.println(threadName+" get 2nd");
            }
        }
    }

    //先拿第二個鎖,再拿第一個鎖
    private static void SecondToFisrt() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        //導致死鎖
        synchronized (valueSecond){
            System.out.println(threadName+" get 2nd");
            Thread.sleep(100);
            synchronized (valueFirst){
                System.out.println(threadName+" get 1st");
            }
        }
        //解決死鎖
          /* synchronized (valueSecond){
            System.out.println(threadName+" get 2nd");
            Thread.sleep(100);
            synchronized (valueFirst){
                System.out.println(threadName+" get 1st");
            }
        }*/
    }

    private static class TestThread extends Thread{

        private String name;

        public TestThread(String name) {
            this.name = name;
        }

        @Override
        public void run(){
            Thread.currentThread().setName(name);
            try {
                SecondToFisrt();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        Thread.currentThread().setName("TestDeadLock");
        TestThread testThread = new TestThread("SubTestThread");
        testThread.start();
        try {
            fisrtToSecond();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 動態順序死鎖

顧名思義也是和獲取鎖的順序有關,但是比較隱蔽,不像簡單順序死鎖,往往從代碼一眼就看出獲取鎖的順序不對。

危害

  1. 線程不工作了,但是整個程序還是活着的
  2. 沒有任何的異常信息可以 供我們檢查
  3. 一旦程序發生了發生了死鎖,是沒有任何的辦法恢復的,只能 重啓程序,對生產平臺的程序來說,這是個很嚴重的問題

實際工作中的死鎖
時間不定,不是每次必現;一旦出現沒有任何異常信息,只知道這個應用的 所有業務越來越慢,最後停止服務,無法確定是哪個具體業務導致的問題;測試 部門也無法復現,併發量不夠。

解決

  • 定位:
    要解決死鎖,當然要先找到死鎖,怎麼找?
    通過 jps 查詢應用的 id,再通過 jstack id 查看應用的鎖的持有情況
    在這裏插入圖片描述
  • 修正:
    關鍵是保證拿鎖的順序一致 兩種解決方式
    1、內部通過順序比較,確定拿鎖的順序;
    2、採用嘗試拿鎖的機制。

參見代碼

/**
 *
 *類說明:銀行轉賬動作接口
 */
public interface ITransfer {
    void transfer(UserAccount from, UserAccount to, int amount)
    		throws InterruptedException;
}
/**
 *
 *類說明:用戶賬戶的實體類
 */
public class UserAccount {
    ///private int id;
    private final String name;//賬戶名稱
    private int money;//賬戶餘額

    private final Lock lock = new ReentrantLock();

    public Lock getLock() {
        return lock;
    }

    public UserAccount(String name, int amount) {
        this.name = name;
        this.money = amount;
    }

    public String getName() {
        return name;
    }

    public int getAmount() {
        return money;
    }

    @Override
    public String toString() {
        return "UserAccount{" +
                "name='" + name + '\'' +
                ", money=" + money +
                '}';
    }

    //轉入資金
    public void addMoney(int amount){
        money = money + amount;
    }

    //轉出資金
    public void flyMoney(int amount){
        money = money - amount;
    }
}
/**
 *
 *類說明:不會產生死鎖的安全轉賬
 */
public class SafeOperate implements ITransfer {

    private static Object tieLock = new Object();//第三把鎖

    @Override
    public void transfer(UserAccount from, UserAccount to, int amount)
            throws InterruptedException {

        int fromHash = System.identityHashCode(from);
        int toHash = System.identityHashCode(to);

        if(fromHash<toHash){
            synchronized (from){
                System.out.println(Thread.currentThread().getName()+" get "+from.getName());
                Thread.sleep(100);
                synchronized (to){
                    System.out.println(Thread.currentThread().getName()+" get "+to.getName());
                    from.flyMoney(amount);
                    to.addMoney(amount);
                    System.out.println(from);
                    System.out.println(to);
                }
            }
        }else if(toHash<fromHash){
            synchronized (to){
                System.out.println(Thread.currentThread().getName()+" get"+to.getName());
                Thread.sleep(100);
                synchronized (from){
                    System.out.println(Thread.currentThread().getName()+" get"+from.getName());
                    from.flyMoney(amount);
                    to.addMoney(amount);
                    System.out.println(from);
                    System.out.println(to);
                }
            }
        }else{
            synchronized (tieLock){
                synchronized (from){
                    synchronized (to){
                        from.flyMoney(amount);
                        to.addMoney(amount);
                    }
                }
            }
        }
    }
}
/**
 *
 *類說明:不會產生死鎖的安全轉賬第二種方法
 */
public class SafeOperateToo implements ITransfer {

    @Override
    public void transfer(UserAccount from, UserAccount to, int amount)
            throws InterruptedException {
        Random r = new Random();
        while(true){
            if(from.getLock().tryLock()){
                System.out.println(Thread.currentThread().getName()
                        +" get"+from.getName());
                try{
                    if(to.getLock().tryLock()){
                        try{
                            System.out.println(Thread.currentThread().getName()
                                    +" get"+to.getName());
                            from.flyMoney(amount);
                            to.addMoney(amount);
                            System.out.println(from);
                            System.out.println(to);
                            break;
                        }finally{
                            to.getLock().unlock();
                        }
                    }
                }finally {
                    from.getLock().unlock();
                }

            }
            //Thread.sleep(r.nextInt(2));
        }

    }
}
/**
 *
 *類說明:模擬支付公司轉賬的動作
 */
public class PayCompany {

	/*執行轉賬動作的線程*/
    private static class TransferThread extends Thread{

        private String name;
        private UserAccount from;
        private UserAccount to;
        private int amount;
        private ITransfer transfer;

        public TransferThread(String name, UserAccount from, UserAccount to,
                              int amount, ITransfer transfer) {
            this.name = name;
            this.from = from;
            this.to = to;
            this.amount = amount;
            this.transfer = transfer;
        }


        @Override
        public void run(){
            Thread.currentThread().setName(name);
            try {
                transfer.transfer(from,to,amount);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }

    public static void main(String[] args) {
        PayCompany payCompany = new PayCompany();
        UserAccount zhangsan = new UserAccount("zhangsan",20000);
        UserAccount lisi = new UserAccount("lisi",20000);
        ITransfer transfer = new SafeOperateToo();
        TransferThread zhangsanToLisi = new TransferThread("zhangsanToLisi"
                ,zhangsan,lisi,2000,transfer);
        TransferThread lisiToZhangsan = new TransferThread("lisiToZhangsan"
                ,lisi,zhangsan,4000,transfer);
        zhangsanToLisi.start();
        lisiToZhangsan.start();

    }

}

其他安全問題

活鎖

兩個線程在嘗試拿鎖的機制中,發生多個線程之間互相謙讓,不斷髮生同一 個線程總是拿到同一把鎖,在嘗試拿另一把鎖時因爲拿不到,而將本來已經持有 的鎖釋放的過程。

解決辦法:每個線程休眠隨機數,錯開拿鎖的時間。

線程飢餓

低優先級的線程,總是拿不到執行時間

併發下的性能

使用併發的目標是爲了提高性能,引入多線程後,其實會引入額外的開銷, 如線程之間的協調、增加的上下文切換,線程的創建和銷燬,線程的調度等等。 過度的使用和不恰當的使用,會導致多線程程序甚至比單線程還要低。
衡量應用的程序的性能:服務時間,延遲時間,吞吐量,可伸縮性等等,其 中服務時間,延遲時間(多快),吞吐量(處理能力的指標,完成工作的多少)。 多快和多少,完全獨立,甚至是相互矛盾的。
對服務器應用來說:多少(可伸縮性,吞吐量)這個方面比多快更受重視。
我們做應用的時候:

  1. 先保證程序正確,確實達不到要求的時候,再提高速度。(黃金原則)
  2. 一定要以測試爲基準。

線程引入的開銷

上下文切換

如果主線程是唯一的線程,那麼它基本上不會被調度出去。另一方面,如果可 運行的線程數大於 CPU 的數量,那麼操作系統最終會將某個正在運行的線程調度 出來,從而使其他線程能夠使用 CPU。這將導致一次上下文切換,在這個過程中將 保存當前運行線程的執行上下文,並將新調度進來的線程的執行上下文設置爲當 前上下文。上下文切換有點像我們同時閱讀幾本書,在來回切換書本的同時我們 需要記住每本書當前讀到的頁碼。

切換上下文需要一定的開銷,而在線程調度過程中需要訪問由操作系統和 JVM 共享的數據結構。應用程序、操作系統以及 JVM 都使用一組相同的 CPU。 在JVM和操作系統的代碼中消耗越多的CPU時鐘週期,應用程序的可用CPU時鐘 週期就越少。但上下文切換的開銷並不只是包含 JVM 和操作系統的開銷。當一 個新的線程被切換進來時,它所需要的數據可能不在當前處理器的本地緩存中,因 此上下文切換將導致一些緩存缺失,因而線程在首次調度運行時會更加緩慢。

當線程由於等待某個發生競爭的鎖而被阻塞時,JVM 通常會將這個線程掛起, 並允許它被交換出去。如果線程頻繁地發生阻塞,那麼它們將無法使用完整的調 度時間片。在程序中發生越多的阻塞(包括阻塞 IO,等待獲取發生競爭的鎖,或者在 條件變量上等待),與 CPU 密集型的程序就會發生越多的上下文切換,從而增加調 度開銷,並因此而降低吞吐量。

上下文切換是計算密集型操作。也就是說,它需要相當可觀的處理器時間。 所以,上下文切換對系統來說意味着消耗大量的 CPU 時間,事實上,可能是操 作系統中時間消耗最大的操作。上下文切換的實際開銷會隨着平臺的不同而變化, 然而按照經驗來看:在大多數通用的處理器中,上下文切換的開銷相當於 50~10000 個時鐘週期,也就是幾微秒。

UNIX系統的 vmstat命令能報告上下文切換次數以及在內核中執行時間所佔 比例等信息。如果內核佔用率較高(超過 10%),那麼通常表示調度活動發生得很頻 繁,這很可能是由 IO 或競爭鎖導致的阻塞引起的。

內存同步
  1. 同步操作的性能開銷包括多個方面。在 synchronized 和 volatile 提供的可見 性保證中可能會使用一些特殊指令,即內存柵欄(MemoryBarrier)。
  2. 內存柵欄可以刷新緩存,使緩存無效刷新硬件的寫緩衝,以及停止執行管道。
  3. 內存柵欄可能同樣會對性能帶來間接的影響,因爲它們將抑制一些編譯器優化操作。在內存柵欄中,大多數操作都是不能被重排序的。
阻塞
  1. 引起阻塞的原因:包括阻塞 IO,等待獲取發生競爭的鎖,或者在條件變量上等待等等。
  2. 阻塞會導致線程掛起【掛起:掛起進程在操作系統中可以定義爲暫時被淘汰 出內存的進程,機器的資源是有限的,在資源不足的情況下,操作系統對在內存 中的程序進行合理的安排,其中有的進程被暫時調離出內存,當條件允許的時候, 會被操作系統再次調回內存,重新進入等待被執行的狀態即就緒態,系統在超過 一定的時間沒有任何動作】。
  3. 很明顯這個操作至少包括兩次額外的上下文切換,還有相關的操作系統級的 操作等等。

如何減少鎖的競爭

減少鎖的粒度

使用鎖的時候,鎖所保護的對象是多個,當這些多個對象其實是獨立變化的 時候,不如用多個鎖來一一保護這些對象。但是如果有同時要持有多個鎖的業務 方法,要注意避免發生死鎖

縮小鎖的範圍

對鎖的持有實現快進快出,儘量縮短持由鎖的的時間。將一些與鎖無關的代 碼移出鎖的範圍,特別是一些耗時,可能阻塞的操作

避免多餘的鎖

兩次加鎖之間的語句非常簡單,導致加鎖的時間比執行這些語句還長,這個 時候應該進行鎖粗化—擴大鎖的範圍。

鎖分段

ConcurrrentHashMap 就是典型的鎖分段。

替換獨佔鎖

在業務允許的情況下:

  1. 使用讀寫鎖
  2. 用自旋 CAS
  3. 使用系統的併發容器

線程安全的單例模式

/**
 * 懶漢式-雙重檢查
 */
public class SingleDcl {
    private static SingleDcl singleDcl;
    private SingleDcl(){
    }

    public static SingleDcl getInstance(){
        if (singleDcl == null){ //第一次檢查,不加鎖
            System.out.println(Thread.currentThread()+" is null");
            synchronized(SingleDcl.class){ //加鎖
                if (singleDcl == null){ //第二次檢查,加鎖情況下
                    System.out.println(Thread.currentThread()+" is null");
                    singleDcl = new SingleDcl();
                }
            }
        }
        return singleDcl;
    }
}

雙重檢查鎖定

在這裏插入圖片描述
解決辦法,加 volatile 關鍵字

解決之道
懶漢式

類初始化模式,也叫延遲佔位模式。在單例類的內部由一個私有靜態內部類 來持有這個單例類的實例。
延遲佔位模式還可以用在多線程下實例域的延遲賦值。

餓漢式

在聲明的時候就 new 這個類的實例,因爲在 JVM 中,對類的加載和類初始化,由虛擬機保證線程安全。
或者使用枚舉

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