跳至主要內容

Java死锁详解

fangzhipeng约 2109 字大约 7 分钟

死锁是指两个或多个线程互相等待对方释放资源而无法继续执行的情况。在 Java 中,死锁通常发生在多个线程同时持有多个锁的情况下,导致彼此相互等待对方释放锁。

Java死锁示例

以下是一个简单的 Java 死锁示例:

package io.github.forezp.concurrentlab.deadlock;


public class DeadLock1 {

    public static void main(String[] args) throws InterruptedException {
        Account a1 = new Account(100);
        Account a2 = new Account(100);
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
               
                a1.fundTransfer(a2, 10);
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {

                a2.fundTransfer(a1, 10);
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

    static class Account {
        private int fund;

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

        void fundTransfer(Account account, int transferMoney) {
            synchronized (this) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (account) {
                    this.fund = this.fund + transferMoney;
                    account.fund = account.fund - transferMoney;
                }
            }
        }
    }
}

死锁示例图
死锁示例图

上面的代码展示了一个典型的死锁情况。在DeadLock1类中,我们有两个Account对象:a1a2,每个对象都有一定数量的资金。Account类包含一个方法fundTransfer,用于从一个账户向另一个账户转移一定金额的资金。

main方法中,我们创建了两个线程(t1t2),分别执行两个账户之间的资金转移。t1线程调用a1.fundTransfer(a2, 10),而t2线程调用a2.fundTransfer(a1, 10)。在fundTransfer方法中,我们可以看到以下代码块:

synchronized (this) {
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    synchronized (account) {
        this.fund = this.fund + transferMoney;
        account.fund = account.fund - transferMoney;
    }
}

在这段代码中,我们使用synchronized关键字来同步代码块。线程先获取当前对象(this)的锁,然后在休眠100毫秒后,试图获取account对象的锁。在main方法中,t1线程获取了a1对象的锁,而t2线程获取了a2对象的锁。但是,每个线程在进行资金转移时都试图获取对方对象(即a2a1)的锁。

这种情况下可能会发生死锁。因为线程t1持有a1对象的锁,正在等待获取a2对象的锁,而线程t2持有a2对象的锁,正在等待获取a1对象的锁。这样就形成了循环等待的死锁情况,两个线程都无法继续执行,程序会停止响应。

死锁发生的条件

死锁是指在并发程序中,两个或多个线程因竞争资源而陷入无限等待的状态。死锁发生一般需要满足四个条件,即下面所述的死锁发生条件:

  1. 互斥条件(Mutual Exclusion):共享资源 X 和 Y 只能被一个线程占用;

  2. 占有且等待(Hold and Wait):线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;

  3. 不可抢占(No Preemption):其他线程不能强行抢占线程 T1 占有的资源;

  4. 循环等待条件(Circular Wait):线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

当以上四个条件同时满足时,就有可能发生死锁。只要避免其中一个条件,就能够预防死锁的发生。在编程中,要解决或避免死锁,需要合理地设计资源分配策略、锁使用策略,并充分考虑线程之间的依赖关系和顺序。

如何避免死锁

在上面死锁发生的四个条件中,

确实,我们可以通过破坏死锁发生条件中的三个条件来避免死锁的发生。下面是对这三个条件的反向分析,并提供解决方法:

  1. 破坏占有且等待(Hold and Wait):一种方法是使用资源分配策略,即一次性申请所有所需的资源。这意味着在开始执行之前,线程必须成功地获取所有需要的资源,不允许等待。另一种方法是资源预分配,即在开始执行之前,线程一次性获取它所需的所有资源,并且不会释放这些资源直到线程完成任务。

  2. 破坏不可抢占(No Preemption):在某些情况下,资源可以被强制剥夺并重新分配给其他线程。这可以通过引入资源的优先级和超时机制来实现。当其他线程请求被当前线程持有的资源时,如果当前线程在一定时间内没有完成任务,资源可以被剥夺并分配给等待的线程。

  3. 破坏循环等待条件(Circular Wait):通过为资源定义线性顺序,可以避免循环等待。线程在申请资源时必须按照相同的顺序申请,即先申请资源序号较小的,再申请资源序号较大的。这样可以避免线程之间的循环等待。

破坏占有且等待

把死锁转账的例子进行改造:

package io.github.forezp.concurrentlab.deadlock;

import java.util.ArrayList;
import java.util.List;

public class DeadLockDemo2 {

    public static void main(String[] args) throws InterruptedException {
        ResManager resManager = new ResManager();
        Account a1 = new Account(resManager, 100);
        Account a2 = new Account(resManager, 100);
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                a1.fundTransfer(a2, 10);
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {

                a2.fundTransfer(a1, 10);
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }


    static class ResManager {
        private List<Account> list = new ArrayList<>();

        synchronized boolean apply(Account res1, Account res2) {
            if (list.contains(res1) || list.contains(res2)) {
                return false;
            } else {
                list.add(res1);
                list.add(res2);
            }
            return true;
        }

        synchronized void release(Account res1, Account res2) {
            list.remove(res1);
            list.remove(res2);
        }
    }

    static class Account {
        ResManager resManager;
        private int fund;

        public Account(ResManager resManager, int fund) {
            this.resManager = resManager;
            this.fund = fund;
        }

        void fundTransfer(Account account, int transferMoney) {

            while (!resManager.apply(this, account)) {

            }
            try {
                synchronized (this) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (account) {
                        this.fund = this.fund + transferMoney;
                        account.fund = account.fund - transferMoney;
                        System.out.println("fundTransfer success");
                    }
                }
            } finally {
                resManager.release(this, account);
            }
        }
    }
}

上面的代码展示了一个使用资源管理器(ResManager)来避免死锁的例子。在DeadLockDemo2类中,我们有两个Account对象(a1a2),每个对象都有一定数量的资金。在这里,我们引入了ResManager类来管理资源的申请和释放。

main方法中,创建了两个线程(t1t2),分别执行两个账户之间的资金转移。在ResManager类中,apply方法用于申请资源,release方法用于释放资源。在Account类的fundTransfer方法中,使用ResManager来避免死锁。具体来说:

  1. fundTransfer方法中,通过resManager.apply(this, account)来申请资源,如果资源申请不成功,即返回false,线程会一直循环等待直到资源可用。这里一次性申请性申请了所有的资源,破换了占有且等待条件
  2. 在执行资金转移操作时,先获取ResManager的锁,然后再获取账户之间资金转移所需的锁。在转移完成后,释放资源。

破坏循环等待条件

再看一下破坏循环等待条件的例子:


public class DeadLockDemo3 {


    public static void main(String[] args) throws InterruptedException {
        Account a1 = new Account(100, 1);
        Account a2 = new Account(100, 3);
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {

                a1.fundTransfer(a2, 10);
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {

                a2.fundTransfer(a1, 10);
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

    private static class Account {
        private int fund;
        private int id;

        public Account(int fund, int id) {
            this.fund = fund;
            this.id = id;
        }

        void fundTransfer(Account account, int transferMoney) {
            Account account1;
            Account account2;
            if (this.id < account.id) {
                account1 = this;
                account2 = account;
            } else {
                account1 = account;
                account2 = this;
            }
            synchronized (account1) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (account2) {
                    this.fund = this.fund + transferMoney;
                    account.fund = account.fund - transferMoney;
                    System.out.println("fundTransfer success");
                }
            }
        }
    }
}

上面的代码展示了另一种避免死锁的方式。在DeadLockDemo3类中,我们依然有两个Account对象(a1a2),每个对象都有一定数量的资金。在Account类中,我们通过比较id字段的大小,决定获取锁的顺序。具体来说:

  1. main方法中,创建了两个线程(t1t2),分别执行两个账户之间的资金转移。
  2. fundTransfer方法中,通过比较当前账户和目标账户的id字段,决定获取锁的顺序。如果当前账户的id小于目标账户的id,先获取当前账户的锁,再获取目标账户的锁;反之,顺序相反。
  3. 在完成资源获取后,执行资金转移操作,并释放锁。

通过这种方式,我们通过线性化的方式来获取锁,避免了遇到不同顺序而导致的循环等待,从而避免了死锁的发生。

需要注意的是,这种方式假设每个账户具有唯一的id,且id是确定的、不会发生变化的。否则,可能会导致获取锁的顺序不一致,无法成功避免死锁。