Java多線程之死鎖、活鎖與飢餓

Java多線程之死鎖

死鎖發生在併發情況中,當兩個(或者多個)線程(進程)相互持有對方所需要的資源,又不主動釋放,導致所有人都無法繼續前進,導致程序陷入無盡的阻塞,這就是死鎖。

死鎖的影響

死鎖的影響在不同數據庫中是不一樣的,這取決於系統對死鎖的處理能力

  • 數據庫中:檢測並放棄事務
  • JVM中:無法自動處理(但是可以檢測)

死鎖發生的機率不高,但是危害大:

  • 不一定發生,但是遵守“墨菲定律”
  • 一旦發生,多是高併發場景,影響用戶多
  • 整個系統崩潰、子系統崩潰、性能下降
  • 壓力測試無法找出所有潛在的死鎖(併發量和死鎖是正相關,而不是必然關係)
    ##發生死鎖的例子
  • 最簡單的情況
package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 08:42
 * @Description: 必定發生死鎖的情況
 */
public class MustDeadLock implements Runnable{

    int flag = 1;
    static Object o1 = new Object();
    static Object o2 = new Object();
    public static void main(String[] args) {
        MustDeadLock r1 = new MustDeadLock();
        MustDeadLock r2 = new MustDeadLock();
        r1.flag = 1;
        r2.flag = 0;
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r2);
        t1.start();
        t2.start();
    }
    @Override
    public void run() {
        System.out.println("flag = " + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("線程1成功拿到兩把鎖");
                }
            }
        }
        if (flag == 0) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("線程2成功拿到兩把鎖");
                }
            }
        }
    }
}

  • 實際生產中的例子:轉賬
package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 09:22
 * @Description: 轉賬操作遇到死鎖,一旦打開註釋,便會發生死鎖
 */
public class TransferMoney implements Runnable{
    int flag = 1;
    Account a = new Account(600);
    Account b = new Account(500);
    @Override
    public void run() {
        if (flag == 1) {
            transferMoney(a, b, 200);
        }
        if (flag == 0) {
            transferMoney(b, a, 200);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        TransferMoney r1 = new TransferMoney();
        TransferMoney r2 = new TransferMoney();
        r1.flag = 1;
        r2.flag = 0;
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r2);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("a的餘額: " + r1.a.balance);
        System.out.println("b的餘額: " + r2.b.balance);
    }

    public static void transferMoney(Account from, Account to, int amount) {
        synchronized (from) {
            //打開註釋,則會發生死鎖
//            try {
//                Thread.sleep(500);
//            } catch (InterruptedException e) {
//                e.printStackTrace();
//            }
            synchronized (to) {
                if (from.balance - amount < 0) {
                    System.out.println("餘額不足, 轉賬失敗。");
                }
                from.balance -= amount;
                to.balance += amount;
                System.out.println("成功轉賬" + amount + "元");
            }
        }
    }

    static class Account {
        int balance;

        public Account(int balance) {
            this.balance = balance;
        }
    }
}

  • 模擬多人隨機轉賬
package deadlock;

import java.util.Random;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 10:31
 * @Description: 多人同時轉賬,還是很危險
 */
public class MultiTransferMoney {
    private static final int NUM_ACCOUNTS = 500;
    private static final int NUM_MONEY = 1000;
    private static final int NUM_INTERATIONS = 1000000;
    private static final int NUM_THREADS = 20;
    
    public static void main(String[] args) {
        Random rnd = new Random();
        TransferMoney.Account[] accounts = new TransferMoney.Account[NUM_ACCOUNTS];
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = new TransferMoney.Account(NUM_MONEY);

        }
        class TransferThread extends Thread {
            @Override
            public void run() {
                for (int i = 0; i <NUM_INTERATIONS; i++) {
                    int fromAcct = rnd.nextInt(NUM_ACCOUNTS);
                    int toAcct = rnd.nextInt(NUM_ACCOUNTS);
                    int amount = rnd.nextInt(NUM_MONEY);
                    TransferMoney.transferMoney(accounts[fromAcct],
                            accounts[toAcct], amount);
                }
            }
        }
        for (int i = 0; i < NUM_THREADS; i++) {
            new TransferThread().start();
        }
    }
}

  • 運行結果:
    在這裏插入圖片描述

死鎖的四個必要條件

  1. 互斥條件
  2. 請求與保持條件
  3. 不可剝奪條件
  4. 循環等待條件
    注意:缺一不可

如何定位死鎖

  • jstack(命令行)
  • ThreadMXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//返回一系列陷入死鎖的線程
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();

修復死鎖的策略

線上發生死鎖應該怎麼辦

  • 保存案發現場(堆棧信息)並重啓服務器
  • 暫時保證線上服務的安全,再利用前面保存的信息,排查死鎖,修改代碼,重新發

常見修復策略

避免策略

思想:避免相反的獲取鎖的順序

//轉賬時避免死鎖
//通過hashcode來決定獲取鎖的順序、衝突時需要“加時賽”
//當有主見時就更加方便
int fromHash = System.identityHashCode(from);
        int toHash = System.identityHashCode(to);
        if (fromHash < toHash) {
            synchronized (from) {
                synchronized (to) {
                    new Helper().transfer();
                }
            }
        }
        else if (fromHash > toHash) {
            synchronized (to) {
                synchronized (from) {
                    new Helper().transfer();
                }
            }
        } else {
            //避免hash衝突時導致死鎖
            synchronized (lock) {
                synchronized (to) {
                    synchronized (from) {
                        new Helper().transfer();
                    }
                }
            }
        }
哲學家就餐問題

問題描述:哲學家就餐問題可以這樣表述,假設有五位哲學家圍坐在一張圓形餐桌旁,做以下兩件事情之一:喫飯,或者思考。喫東西的時候,他們就停止思考,思考的時候也停止喫東西。餐桌中間有一大碗意大利麪,每兩個哲學家之間有一隻餐叉。因爲用一隻餐叉很難喫到意大利麪,所以假設哲學家必須用兩隻餐叉喫東西。他們只能使用自己左右手邊的那兩隻餐叉。哲學家就餐問題有時也用米飯和筷子而不是意大利麪和餐叉來描述,因爲很明顯,喫米飯必須用兩根筷子。哲學家從來不交談,這就很危險,可能產生死鎖,每個哲學家都拿着左手的餐叉,永遠都在等右邊的餐叉(或者相反)。即使沒有死鎖,也有可能發生資源耗盡。例如,假設規定當哲學家等待另一隻餐叉超過五分鐘後就放下自己手裏的那一隻餐叉,並且再等五分鐘後進行下一次嘗試。這個策略消除了死鎖(系統總會進入到下一個狀態),但仍然有可能發生“活鎖”。如果五位哲學家在完全相同的時刻進入餐廳,並同時拿起左邊的餐叉,那麼這些哲學家就會等待五分鐘,同時放下手中的餐叉,再等五分鐘,又同時拿起這些餐叉。在實際的計算機問題中,缺乏餐叉可以類比爲缺乏共享資源。一種常用的計算機技術是資源加鎖,用來保證在某個時刻,資源只能被一個程序或一段代碼訪問。當一個程序想要使用的資源已經被另一個程序鎖定,它就等待資源解鎖。當多個程序涉及到加鎖的資源時,在某些情況下就有可能發生死鎖。例如,某個程序需要訪問兩個文件,當兩個這樣的程序各鎖了一個文件,那它們都在等待對方解鎖另一個文件,而這永遠不會發生。
在這裏插入圖片描述

package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 11:55
 * @Description: 掩飾哲學家就餐問題導致的死鎖
 */
public class DiningPhilosophers {
    public static void main(String[] args) {
        Philosopher[] philosophers = new Philosopher[5];
        Object[] chopsticks = new Object[philosophers.length];
        for (int i = 0; i < chopsticks.length; i++) {
            chopsticks[i] = new Object();
        }
        for (int i = 0; i < philosophers.length; i++) {
            Object leftChopstick = chopsticks[i];
            Object rightChopstick = chopsticks[(i+1) % chopsticks.length];
            philosophers[i] = new Philosopher(leftChopstick, rightChopstick);
            new Thread(philosophers[i], "哲學家" + (i + 1) + "號").start();
        }
    }
    public static class Philosopher implements Runnable{

        private Object leftChopstick;
        private Object rightChopstick;

        public Philosopher(Object leftChopstick, Object rightChopstick) {
            this.leftChopstick = leftChopstick;
            this.rightChopstick = rightChopstick;
        }

        @Override
        public void run() {
            while (true) {
                try {
                    doAction("Thinking");
                    synchronized (leftChopstick) {
                        doAction("Picked up left chopstick");
                        synchronized (rightChopstick) {
                            doAction("Picked up right chopstick - eating");
                            doAction("Put down right chopstick");
                        }
                        doAction("Put down left chopstick");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

        private void doAction(String action) throws InterruptedException {
            System.out.println(Thread.currentThread().getName() + " " +action);
            Thread.sleep((long)(Math.random()*10));
        }
    }
}

多種解決方案
  • 服務員檢查(避免策略),相當於引入了外界的協調
  • 改變一個哲學家拿叉子的順序(避免策略)
  • 餐票(避免策略)
  • 領導調節(檢測與恢復策略)

檢測與恢復策略

死鎖檢測算法
  • 允許發生死鎖
  • 每次調用鎖都記錄
  • 定期檢查“鎖的調用鏈路圖”中是否存在環路
  • 一旦檢測發生了死鎖,就用死鎖恢復機制進行恢復
死鎖恢復機制
  • 恢復方法1:進程終止(逐個終止線程,直到死鎖消除)
  • 恢復方法2:資源搶佔(把已經分發出去的鎖給收回來,讓線程回退幾部,不用結束整個線程,成本比較低,但是可能同一個線程一直被搶佔,就會造成飢餓

鴕鳥策略

如果死鎖發生概率極低,那麼可以忽略,等到死鎖出現再進行人工修復

實際工程中如何避免死鎖

  1. 設置超時時間
  • Lock的tryLock(long timeout, TimeUnit unit)
  • sunchronized不具備嘗試鎖的能力
  • 造成超時的可能性多:發生了死鎖、線程陷入了死循環、線程執行很慢
  • 獲取鎖失敗:打日誌、發報警郵件、重啓等
package deadlock;

import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 14:47
 * @Description: 用tryLock來避免死鎖
 */
public class TryLockDeadlock implements Runnable{

    int flag = 1;
    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        TryLockDeadlock r1 = new TryLockDeadlock();
        TryLockDeadlock r2 = new TryLockDeadlock();
        r1.flag = 1;
        r2.flag = 0;
        new Thread(r1).start();
        new Thread(r2).start();
    }
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if (flag == 1) {
                try {
                    if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
                        System.out.println("線程1獲取到了鎖1");
                        //隨機休眠,模擬真實情況
                        Thread.sleep(new Random().nextInt(1000));
                        if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
                            System.out.println("線程1獲取到了鎖2");
                            System.out.println("線程1成功獲取到了兩把鎖");
                            lock2.unlock();
                            lock1.unlock();
                            break;
                        } else {
                            System.out.println("線程1嘗試獲取鎖2失敗,已重試");
                            lock1.unlock();
                            //隨機休眠,模擬真實情況
                            Thread.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println("線程1獲取鎖1失敗,已重試");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (flag == 0) {
                try {
                    if (lock2.tryLock(3000, TimeUnit.MILLISECONDS)) {
                        System.out.println("線程2獲取到了鎖2");
                        //隨機休眠,模擬真實情況
                        Thread.sleep(new Random().nextInt(1000));
                        if (lock1.tryLock(3000, TimeUnit.MILLISECONDS)) {
                            System.out.println("線程2獲取到了鎖1");
                            System.out.println("線程2成功獲取到了兩把鎖");
                            lock1.unlock();
                            lock2.unlock();
                            break;
                        } else {
                            System.out.println("線程2嘗試獲取鎖1失敗,已重試");
                            lock2.unlock();
                            //隨機休眠,模擬真實情況
                            Thread.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println("線程2獲取鎖2失敗,已重試");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

執行結果:

2. 多使用併發類而不是自己設計鎖

  • ConcurrentHashMap、ConcurrnetLinkedQueue、AutomicBoolean等
  • 實際應用中java.util.concurrent.atomic十分有用,簡單方便且效率比使用Lock更高
  • 多用併發集合少用同步集合,併發集合比同步集合的課擴展性更好
  • 併發場景需要用到map,優先考慮ConcurrentHashMap
  1. 儘量降低鎖的使用粒度,用不同的鎖而不是同一個鎖
  2. 如果能使用同步代碼塊,就不使用同步方法,自己使用鎖對象
  3. 給線程起個有意義的名字:debug和排查時效率更高
  4. 避免鎖的嵌套:MustDeadLock類
  5. 分配資源前先看能不能收回來:銀行家算法
  6. 儘量不要幾個功能用同一把鎖:專鎖專用

其他活性故障

死鎖是最常見的活躍性問題,但是除了死鎖,還有一些類似的問題,會導致程序無法順利執行,統稱爲活躍性問題

  • 活鎖(LiveLock)
  • 飢餓

活鎖

在銀行家算法場景中的不同體現:
在完全相同的時刻同時坐上餐桌,並同時拿起左邊的筷子,那麼這些哲學家就會等待五分鐘分鐘,同時放下手中的筷子,再等五分鐘以後又拿起來,在實際計算機的問題中,將這類問題比喻爲缺乏共享資源

什麼是活鎖

  • 雖然線程並沒有阻塞,也始終在運行,但是程序卻得不到進展,因爲線程始終重複做同樣的事
package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 15:40
 * @Description: 展示活鎖的問題
 */
public class LiveLock {
    static class Spoon {
        private Diner owner;
        public Spoon(Diner owner) {
            this.owner = owner;
        }
        public Diner getOwner() {
            return owner;
        }
        public void setOwner(Diner owner) {
            this.owner = owner;
        }
        public synchronized void use() {
            System.out.printf("%s has eaten!", owner.name);
        }
    }
    static class Diner{
        private String name;
        private boolean isHungry;
        public Diner(String name) {
            this.name = name;
            isHungry = true;
        }
        public void eatWith(Spoon spoon, Diner spouse) {
            while (isHungry) {
                if (spoon.owner != this) {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    continue;
                }
                if (spouse.isHungry) {
                    System.out.println(name + ":親愛的" + spouse.name + "你先喫吧");
                    spoon.setOwner(spouse);
                    continue;
                }
                spoon.use();
                isHungry = false;
                System.out.println(name + ":我喫完了");
                spoon.setOwner(spouse);
            }
        }
    }
    public static void main(String[] args) {
        Diner husband = new Diner("Messi");
        Diner wife = new Diner("Suarez");
        Spoon spoon = new Spoon(husband);
        new Thread(new Runnable() {
            @Override
            public void run() {
                husband.eatWith(spoon, wife);
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                wife.eatWith(spoon, husband);
            }
        }).start();
    }
}

運行結果:
(img-8qygRSSH-1581938358164)(./1581926810669.png)]

如何解決活鎖問題

原因:重試機制不變,消息隊列始終重試,始終相互謙讓

  1. 以太網的指數退避算法(重試時間不固定)
  2. 加入隨機因素

工程中的活鎖實例:消息隊列

策略:如果消息處理失敗,就在隊列開頭重試
由於依賴服務出了問題,處理該消息一直失敗
沒有阻塞,但是程序無法繼續
解決:放到隊列尾部、重試限制

飢餓

  • 當線程需要某些資源(如CPU),但是卻始終得不到
  • 線程的優先級設置得過低,或者由於某線程持有鎖同時又無限循環而不釋放鎖,或者某程序始終佔用某文件的寫鎖
  • 飢餓可能會導致響應性差
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章