Java死鎖問題簡析

前言

在多線程編程中死鎖是一個常見的問題,我們都知道死鎖的出現有四個必要條件:資源互斥使用,也就是說每個資源一次只能有一個線程使用;佔有並請求,所有的線程都持有它們目前請求到的資源並且申請還未得到的資源;不可剝奪,也就是說所有線程請求到的資源都無法被其他線程搶佔;循環等待,也就是線程之間互相等待對方釋放己方需要的資源。這裏先通過Java代碼實現簡單的死鎖問題,然後通過一個銀行轉賬的示例學習如何預防死鎖的出現。

簡單示例

這裏使用synchronized先後請求兩個鎖對象,由於兩個線程都只請求到了其中一個鎖,等待對方釋放另外一個鎖從而導致死鎖產生。

public class DeadLock {
    // 第一個所對象
    private static final Object lock1 = new Object();
    // 第二個鎖對象
    private static final Object lock2 = new Object();

    private static final Runnable task1 = new Runnable() {
        @Override
        public void run() {
            synchronized (lock1) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                }

                synchronized (lock2) {
                    System.out.println("Task1 got two locks");
                }
            }
        }
    };

    private static final Runnable task2 = new Runnable() {
        @Override
        public void run() {
            synchronized (lock2) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                }

                synchronized (lock1) {
                    System.out.println("Task2 got two locks");
                }
            }
        }
    };

    public static void main(String[] args) {
        new Thread(task1).start();
        new Thread(task2).start();
    }
}

直接運行上面的代碼會發現整個程序就卡住了,完全沒有任何前進,使用jdk的命令行工具jps查看卡住的Java進程號,通過jstack打印程序的內部狀態,可以看出內部發生了死鎖。

> jps
57139 Launcher
57140 DeadLock
57192 Jps
56863

> jstack 57140
Java stack information for the threads listed above:
===================================================
"Thread-1":
    at myjvm.DeadLock$2.run(DeadLock.java:35)
    - waiting to lock <0x000000079582e1e0> (a java.lang.Object)
    - locked <0x000000079582e1f0> (a java.lang.Object)
    at java.lang.Thread.run(Thread.java:745)
"Thread-0":
    at myjvm.DeadLock$1.run(DeadLock.java:18)
    - waiting to lock <0x000000079582e1f0> (a java.lang.Object)
    - locked <0x000000079582e1e0> (a java.lang.Object)
    at java.lang.Thread.run(Thread.java:745)

Found 1 deadlock.

在Android開發中如果出現死鎖通常都會導致ANR問題,在/data/anr/traces.txt文件中通常就記錄着ANR出現時候的日誌,查看那些死鎖ANR的日誌就會出現如上面的報錯,這時就需要開發者仔細分析哪些線程請求不同鎖導致主線程無法響應。

預防死鎖

我們都知道死鎖有四個必要條件,第一個是互斥訪問,對於大部分資源都無法多線程同時訪問,這個條件無法被輕易的破壞,後面有一個循環等待,如果能按照規定的順序請求資源,這樣就會破壞循環等待的必要條件。

// 普通的賬號類
class Account {
    private String name;
    private String id;
    private int amount;
    private Lock lock;

    public Account(String name, String id, int amount) {
        this.name = name;
        this.id = id;
        this.amount = amount;
        lock = new ReentrantLock();
    }

    public String getId() {
        return id;
    }

    public int getAmount() {
        return amount;
    }

    public void setAmount(int amount) {
        this.amount = amount;
    }

    public Lock getLock() {
        return lock;
    }

    @Override
    public String toString() {
        return "Account{" +
                "name='" + name + '\'' +
                ", id='" + id + '\'' +
                ", amount=" + amount +
                '}';
    }
}

public class DeadLockTest {
    public static void main(String[] args) {
        Account a = new Account("Account-A", "10000", 5000);
        Account b = new Account("Account-B", "20000", 5000);

        Thread threadA = new Thread(() -> {
            transfer(a, b);
        });
        Thread threadB = new Thread(() -> {
            transfer(b, a);
        });

        threadA.start();
        threadB.start();

        try {
            threadA.join();
            threadB.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 通過先鎖定A賬號再鎖定B賬號最後在做轉賬操作
    public static void transfer(Account a, Account b) {
        synchronized (a) {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (b) {
                a.setAmount(a.getAmount() - 1000);
                b.setAmount(b.getAmount() + 1000);
            }
        }
    }
}

上面的例子需要線程同時獲取AB兩個賬號,之後才能做轉賬操作,不過由於另外一個線程在做BA轉賬功能,threadA佔有了A的鎖,threadB佔有B的鎖,threadA繼續獲取B的鎖無法成功,同理threadB在獲取A也無法成功,這就形成了一個死鎖。前面講到破壞循環等待的條件就能夠避免死鎖問題發生,如果有一個強制的順序要求必須先申請某個帳號,再申請另外一個帳號這就破壞了循環等待條件。

/**
 * 按順序請求加鎖
 */
public static void transferOrdered(Account a, Account b) {
    Account first = a, second = b;
    // 根據id排序獲取a和b的順序,如果誰的id比較小就先請求誰的鎖
    if (a.getId().compareTo(b.getId()) > 0) {
        first = b;
        second = a;
    }
    // 先請求id比較小的資源lock,在請求id比較大的資源lock
    synchronized (first) {
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (second) {
            a.setAmount(a.getAmount() - 1000);
            b.setAmount(b.getAmount() + 1000);
            System.out.println(a);
            System.out.println(b);
        }
    }
}

上面的例子使用帳號的id來排序,轉賬的時候必須先獲取帳號id小的帳號鎖,之後在獲取帳號id大的帳號鎖,實際運行就會發現沒有再出現死鎖問題,轉賬功能成功實現。除了循環等待條件破壞掉,還有不可被剝奪這個條件,如果在請求第二把鎖的時候無法請求到就釋放第一把鎖之後再重試,這樣就破壞了資源不可剝奪的條件,也能預防死鎖出現。

/**
 * 加鎖後可以自己釋放
 */
public static void transferNoHold(Account a, Account b) {
    boolean transfered = false;
    while (!transfered) {
        // 首先獲取a的鎖
        a.getLock().lock();
        try {
            boolean locked = false;
            try {
                // 嘗試獲取b的鎖,如果1s內無法獲取b的鎖,就退出這次迭代,同時將a的鎖也放棄
                locked = b.getLock().tryLock(1, TimeUnit.SECONDS);

                if (locked) {
                    // 如果a和b的鎖都已經獲得,執行轉款
                    a.setAmount(a.getAmount() - 1000);
                    b.setAmount(b.getAmount() + 1000);
                    System.out.println(a);
                    System.out.println(b);
                    // 執行完成後退出循環
                    transfered = true;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (locked) {
                    b.getLock().unlock();
                }
            }
        } finally {
            // 放棄a的鎖,開始下次循環
            a.getLock().unlock();

            // 放棄a的鎖之後休眠一會,讓別的線程有機會得到a的鎖
            try {
                Thread.sleep(new Random().nextInt(500));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

由於synchronized實現的鎖機制無法被手動撤銷,這裏使用了J.U.C裏的Lock接口實現加鎖功能,首先在獲取到第一個帳號的鎖之後,嘗試使用1秒tryLock第二個帳號的鎖,如果無法獲取第二個帳號的鎖就把第一個帳號的也釋放掉並且休眠一會方其他線程有機會能夠獲取第一個帳號的鎖,只要沒有成功轉賬就一直重複嘗試獲取兩個鎖對象,實際運行上面的代碼會發現確實沒有發生死鎖現象。

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