【活躍性問題】全面分析死鎖、活鎖和飢餓

死鎖

死鎖的概述

死鎖發生在併發中,並且互不相讓。

描述:當兩個或者多個線程(或者進程)互相持有對方所佔有的資源,又不主動釋放自己的資源,導致線程陷入阻塞,即爲死鎖。多個線程如果存在環形的鎖依賴關係,可能導致死鎖。

例子:有兩個人見面分別向對方鞠躬,然而出於紳士風度,兩人都不想早於對方起身。
在這裏插入圖片描述

死鎖的影響

數據庫事務發生死鎖時,數據庫會強行終止事務。但JVM無法自動處理死鎖。陷入死鎖的線程可能非常重要,不能放棄鎖,因此由程序員處理。

死鎖發生的機率不高,但是很大。一般發生在高併發場景,影響用戶多;根據死鎖在系統中位置不同,可能導致系統崩潰、性能降低等。

壓力測試無法找出所有潛在的死鎖。例如,線程等待鎖被調起是隨機的;短時間持有鎖,是爲了降低鎖的競爭程度,但卻增加了測試中找出潛在死鎖的難度。

一段必然死鎖的代碼–代碼演示

public class MustDeadLock implements Runnable {
    private int flag;

    public MustDeadLock(int flag) {
        this.flag = flag;
    }

    private static final Object resourceA = new Object();
    private static final Object resourceB = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(new MustDeadLock(0));
        Thread thread2 = new Thread(new MustDeadLock(1));
        thread1.start();
        thread2.start();
    }

    @Override
    public void run() {
        if (flag == 0) {
            synchronized (resourceA) {
                System.out.println(Thread.currentThread().getName() + "獲取A鎖");
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resourceB) {
                    System.out.println(Thread.currentThread().getName() + "獲取B鎖");
                }
            }
        } else {
            synchronized (resourceB) {
                System.out.println(Thread.currentThread().getName() + "獲取B鎖");
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resourceA) {
                    System.out.println(Thread.currentThread().getName() + "獲取A鎖");
                }
            }
        }
    }
}
Thread-1獲取B鎖
Thread-0獲取A鎖

Process finished with exit code -1 / 130
控制檯正常終止結束信號是0。
代碼演示:轉賬

轉賬需要兩把鎖:轉出賬戶和轉入賬戶,兩者都不能同時轉入轉出。獲取兩把鎖成功,並且餘額大於0,則扣除轉出賬戶,增加轉入賬戶餘額,是原子操作。互相轉錢時可能導致死鎖。

死鎖的4個必要條件

  1. 互斥條件:資源同時只有一個線程佔用。
  2. 請求與保持條件:線程請求被其他線程佔用的資源,而不釋放自己佔用的資源。
  3. 不可剝奪條件:線程佔用的資源在使用完之前不能被其他線程搶佔。
  4. 循環等待條件:發生死鎖時,必然存在線程資源依賴的環形鏈。

四個條件是必要條件,必須同時滿足纔會發生死鎖。

定位死鎖

jstack精確定位死鎖

使用jps命令定位線程PID

> jps
25604 MustDeadLock

使用jstack打印死鎖信息

> jstack -l 25604

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-HSjqYKvc-1583594286763)(931BFB9548734896AD717D643BD3713E)]

jstack無法應對複雜死鎖,但是可以用來分析棧信息。

ThreadMXBean檢查死鎖

// 使用ThreadMXBean檢測死鎖
Thread.sleep(1000);
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadLockThreads = threadMXBean.findDeadlockedThreads();
if (deadLockThreads != null && deadLockThreads.length != 0) {
    for (long deadLockThread : deadLockThreads) {
        ThreadInfo threadInfo = threadMXBean.getThreadInfo(deadLockThread);
        System.out.println("死鎖線程:" + threadInfo.getThreadName());
    }
}

死鎖修復策略

平時開發需要提前預防死鎖,如果線上出現死鎖,很難做到無損失的解決。因此要保存好堆棧信息,然後立刻重啓服務器,避免影響用戶體驗。

死鎖的修復策略有以下幾種:

  • 預防與避免策略:轉賬換序方案、哲學家就餐的換手方案。
  • 檢查與恢復策略:一段時間檢查是否有死鎖,如果有,剝奪資源,打破死鎖。
  • 鴕鳥策略:死鎖發生概率很低,直接忽略;等到死鎖發生時,人工修復。

預防策略–轉賬換序方案【代碼演示】

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

public class TransferMoneyNotDeadLock implements Runnable {
    private int flag;

    public TransferMoneyNotDeadLock(int flag) {
        this.flag = flag;
    }

    /**
     * 兩個賬戶鎖
     */
    private static Account account1 = new Account(500);
    private static Account account2 = new Account(500);

    /**
     * 額外的鎖,用於hashcode相同時規範獲取鎖順序
     */
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread thread0 = new Thread(new TransferMoneyNotDeadLock(0));
        Thread thread1 = new Thread(new TransferMoneyNotDeadLock(1));
        thread0.start();
        thread1.start();
        thread0.join();
        thread1.join();
        System.out.println("賬戶1餘額:" + account1.blance);
        System.out.println("賬戶2餘額:" + account2.blance);
    }

    @Override
    public void run() {
        if (flag == 0) {
            transMoney(account1, account2, 200);
        } else {
            transMoney(account2, account1, 200);
        }
    }

    private void transMoney(Account from, Account to, int account) {
        // 獲取兩個賬戶的hashcode,通過比較大小規範獲取鎖的順序
        int fromHashCode = System.identityHashCode(from);
        int toHashCode = System.identityHashCode(to);
        if (fromHashCode < toHashCode) {
            synchronized (from) {
                synchronized (to) {
                    transfer(from, to, account);
                }
            }
        } else if (fromHashCode > toHashCode) {
            synchronized (to) {
                synchronized (from) {
                    transfer(from, to, account);
                }
            }
        } else { // 此時hashcode值相同
            synchronized (lock) {
                synchronized (from) {
                    synchronized (to) {
                        transfer(from, to, account);
                    }
                }
            }
        }
    }

    private void transfer(Account from, Account to, int account) {
        if (from.blance < account) {
            throw new RuntimeException("賬戶餘額不足");
        }
        from.blance -= account;
        to.blance += account;
        System.out.println("成功轉賬" + account + "元");
    }

    static class Account {
        int blance;

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

案例中,使用鎖的hashcode值決定鎖的順序,在hash衝突時採用加時賽(使用額外的鎖)。如果對象數據有能夠標識順序的字段(例如自增主鍵),則不需要額外的鎖。

實際場景不會直接加鎖,可能用到事務保證轉賬的一致性和原子性,使用消息隊列異步處理,即使加鎖也會用分佈式加鎖。

預防策略–哲學家就餐的換手方案

哲學家就餐問題:假設五位哲學家圍成一桌,兩人中間放置一根筷子。哲學家除了思考就是喫飯,哲學家喫飯時先拿左手邊筷子,再拿右手邊筷子,此時下一位哲學家喫飯只能等待左手邊筷子。如果五位哲學家同時喫飯,同時拿起了左手邊筷子,就會導致死鎖和資源耗盡。

哲學家就餐問題的解決方案:

  1. 就餐前檢查資源是否足夠–預防免策略
  2. 改變一個哲學家就餐的順序–預防策略
  3. 限制同時就餐人數,同一時間最多隻能有4個科學家就餐–預防策略
  4. 檢查與恢復

避免策略 – 銀行家算法

描述:以銀行借貸分配策略爲基礎,判斷並保證系統處於安全狀態

  • 客戶在第一次申請貸款時,聲明所需最大資金量,在滿足所有貸款要求並完成項目時,及時歸還。
  • 在客戶貸款數量不大於銀行最大值時,銀行家儘量滿足客戶需要

角色

  • 銀行家–操作系統
  • 資金–資源
  • 客戶–申請資源的線程

思想:銀行的剩餘資源滿足某個申請的未來需要,並回收資源。以此迭代,不斷滿足未來需要、回收資源,構成一個安全序列。不滿足未來需要,表示不安全。

檢查與恢復策略–代碼演示

思想:一段時間檢查是否有死鎖,如果有,剝奪資源,打破死鎖。

【代碼演示】使用顯式鎖Lock中的定時tryLock功能代替內置鎖機制。

public class TryLockDeadLock implements Runnable {
    private int flag;

    public TryLockDeadLock(int flag) {
        this.flag = flag;
    }

    private static final Lock lock1 = new ReentrantLock();
    private static final Lock lock2 = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(new TryLockDeadLock(0));
        Thread thread2 = new Thread(new TryLockDeadLock(1));
        thread1.start();
        thread2.start();

    }

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            if (flag == 0) {
                try {
                    if (lock1.tryLock(1, TimeUnit.SECONDS)) {
                        System.out.println(Thread.currentThread().getName() + "獲取lock1");
                        TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        if (lock2.tryLock(1, TimeUnit.SECONDS)) {
                            System.out.println(Thread.currentThread().getName() + "獲取lock2,此時擁有兩把鎖");
                            lock2.unlock();
                            lock1.unlock();
                            break;
                        } else {
                            System.out.println(Thread.currentThread().getName() + "獲取lock2失敗,重試");
                            lock1.unlock();
                            TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println(Thread.currentThread().getName() + "獲取lock1失敗,重試");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } else {
                try {
                    if (lock2.tryLock(1, TimeUnit.SECONDS)) {
                        System.out.println(Thread.currentThread().getName() + "獲取lock2");
                        TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        if (lock1.tryLock(1, TimeUnit.SECONDS)) {
                            System.out.println(Thread.currentThread().getName() + "獲取lock1,此時擁有兩把鎖");
                            lock1.unlock();
                            lock2.unlock();
                            break;
                        } else {
                            System.out.println(Thread.currentThread().getName() + "獲取lock1失敗,重試");
                            lock2.unlock();
                            TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println(Thread.currentThread().getName() + "獲取lock2失敗,重試");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }
    }
}

檢測與恢復策略–操作系統概念

檢測算法:鎖的調用鏈路

  1. 允許系統進入死鎖狀態
  2. 每次調用鎖都記錄,維護鎖調用鏈路
  3. 定期檢查鎖的調用鏈路圖,是否存在死鎖
  4. 一旦發生死鎖,採用死鎖恢復機制進行恢復。

恢復方法
進程終止

  • 終止所有線程,重新計算
  • 逐個終止線程直到死鎖消失。每終止一個線程都要做死鎖檢測,開銷很大。

資源搶佔

  • 選取一個犧牲品(線程),最有效的是講線程回滾到足夠打破死鎖。但某個線程一直被犧牲,可能造成飢餓,因此需要限制回滾次數。

實際開發中如何避免死鎖

1. 設置超時時間

造成超時的可能性大概有:發生死鎖、線程陷入死循環、線程執行慢。

  • Lock的tryLock具備超時功能,如果獲取鎖失敗,可以打印日誌以及報警等。
  • synchronized不具備嘗試鎖能力。不能中斷試圖獲取monitor鎖的線程

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

使用併發類發生死鎖的概率低。

  • 併發類:ConcurrentHashMap、ConcurrentLinkeedQueue、AatomicBoolean
  • 實際應用中java.uti.concurrent.atomic包中的類
  • 多使用併發集合少用同步集合 ,併發集合比同步集合的可擴展性更好
  • 併發場景需要用到map,首先想到用ConcurrentHashMap

3. 降低鎖的使用粒度:用不同的鎖而不是一個鎖。鎖的保護範圍(臨界區)越小越好。

4. 如果可以使用同步代碼塊,不使用同步方法:自己指定鎖對象。同步方法比同步代碼塊範圍大。

5. 給線程起有意義的名字,便於debug和排查。

6. 避免鎖的嵌套:例如MustDeadLock類

7. 分配資源前先看看資源能不能收回來:銀行家算法

8. 儘量不要多個功能使用同一把鎖:專鎖專用。


活鎖

活鎖:線程沒有阻塞,始終在運行,但是程序得不到進展,重複做同樣的事情。
活鎖與死鎖效果一樣,程序無法正常運行。發生死鎖,線程陷入阻塞;活鎖不會阻塞,會更加耗費CPU資源。

舉例1:類似於死鎖例子,有兩個人見面分別向對方鞠躬,然而處於紳士風度,兩人都不想早於對方起身。因此兩個人分別向對方說“你先請”,陷入循環,最終二人仍未起身。

舉例2:例如哲學家就餐問題,五位哲學家同時拿起左手邊筷子會造成死鎖。如果給鎖加5秒超時,5秒超時後獲取鎖失敗,哲學家再次同時拿起筷子,循環往復,便是活鎖。

活鎖演示–謙讓的紳士

public class LiveLock {

    public static void main(String[] args) {
        People gentleman1 = new People("紳士1");
        People gentleman2 = new People("紳士2");

        Action getUpAction = new Action(gentleman1);

        new Thread(() -> {
            try {
                gentleman1.doAction(getUpAction, gentleman2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            try {
                gentleman2.doAction(getUpAction, gentleman1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }

}

class Action {
    private People executor;

    public Action(People executor) {
        this.executor = executor;
    }

    public synchronized void execute() {
        System.out.println(executor.getName() + " 執行起身動作");
    }

    public People getexecutor() {
        return executor;
    }

    public void setexecutor(People executor) {
        this.executor = executor;
    }
}

class People {
    private String name; // 姓名
    private boolean isBendOver; // 是否彎腰

    public People(String name) {
        this.name = name;
        this.isBendOver = true;
    }

    public void doAction(Action action, People other) throws InterruptedException {
        while (isBendOver) {
            // 如果對方正在起身,等對方先起身
            if (action.getexecutor() != this) {
                TimeUnit.MILLISECONDS.sleep(1);
                continue;
            }
            // 如果對方仍在彎腰,請對方起身
            if (other.isBendOver) {
                // 使用隨機元素打破活鎖
                // Random rand = new Random();
                // if (other.isBendOver && rand.nextInt(10) < 9) {
                System.out.println(name + "對" + other.getName() + "說,您先起身。");
                action.setexecutor(other);
                continue;
            }
            action.execute();
            isBendOver = false;
            System.out.println(name + ":我起身了");
            action.setexecutor(other);
        }
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

活鎖解決方法:加入隨機因素,減小獲取鎖碰撞的概率。

在消息隊列中,如果依賴服務出現問題,例如宕機,消息處理失敗,由於消息在消息隊列頭部,會導致消息隊列一直重試發送消息。

解決方法:(1)放到消息隊列末尾;(2)限制重試上限,超出上限的數據可以持久化到數據庫中,觸發報警機制,等待調度再次調起。

飢餓

出現線程飢餓可能的原因:

  • 當線程需要某些資源(一般是CPU),但是始終無法得到。
  • 線程優先級設置過低,或者其他線程持有鎖不釋放。

線程飢餓時,無法得到很好地執行,導致系統響應性差。

結合系統優先級描述。

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