死锁的两种体现形式分析和解决方案

>>2020,微服务装逼指南

死锁在多线程开发过程中比较经常遇到,并且这个问题很隐性,很难排查到问题的所在,即使是查看项目的日志都找不到,很让人头疼。死锁的体现形式主要有两种,分别是简单的死锁和动态死锁,简单死锁在写代码的时候很容易避免,动态死锁就很麻烦。因为出现死锁的主要原因是两个锁的加锁顺序不同,动态死锁看似是加载顺序都相同,但是实际不同,所以一旦发生,就很难排查。

死锁的原因和体现形式

原因

  • 两个线程分别是A线程、B线程

  • 两个资源分别是SA和SB

在操作SA资源同时也要对SB资源进行操作,为了让两个资源能够保证线程安全,需要锁定两个资源后才能对其进行操作。
当A线程获取SA资源锁后,CPU切换执行到了B线程,这个时候B线程锁了SB资源,当再切换到A线程后,想要获取SB资源锁,却发现锁已经被持有,只能等待释放,同样的B线程也在等SA资源锁被释放,这两个线程就会僵持不下,最终导致死锁。

死锁的两种体现形式

  • 简单死锁

  • 动态死锁

简单死锁

代码体现

public class SimpleDeadLock {

    //定义两把锁
    private static final Object firstLock = new Object();
    private static final Object secondLock = new Object();

    public static class DeadRun implements Runnable {

        private final Object firstLock;
        private final Object secondLock;

        DeadRun(Object firstLock, Object secondLock) {
            this.firstLock = firstLock;
            this.secondLock = secondLock;
        }

        public void run() {
            String name = Thread.currentThread().getName();
            synchronized (firstLock) {
                System.out.println(name + ">>>get firstLock");
                synchronized (secondLock) {
                    System.out.println(name + ">>>get secondLock");
                    // 操作逻辑 ……
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {

        new Thread(new DeadRun(firstLock, secondLock)).start();//创建线程并执行

        String name = Thread.currentThread().getName();
        synchronized (secondLock) {
            System.out.println(name + ">>>get secondLock");
            synchronized (firstLock) {
                System.out.println(name + "get firstLock");
                // 操作逻辑 ……
            }
        }
    }
}
/*
输出结果:
main>>>get secondLock
Thread-0>>>get firstLock
*/

两个资源firstLock和secondLock,主线程和子线程都要去对两个资源加锁并操作,其中主线程会先锁secondLock,再锁firstLock,子线程是先锁firstLock,再锁secondLock。这样就很容易导致主线程持有secondLock不放等待firstLock被释放,子线程持有firstLock不放等待secondLock被释放,结果就是两个线程都无法继续实现,被阻塞。

原因分析和解决方案

这里其实比较简单,可以比较容易发现和避免,只要理解死锁的基本原理就可以。
只要将主线程和子线程多两个锁的获取顺序改为相同即可。

动态死锁

代码体现

一个在多线程中经常使用的例子,银行转账。

// 银行账户实体类
public class Account {

    private String name;
    private Integer amount;

    Account(String name, Integer amount) {
        this.name = name;
        this.amount = amount;
    }

    public void addMoney(Integer amount) {
        this.amount += amount;
    }

    public void flyMoney(Integer amount) {
        this.amount -= amount;
    }
}
// 转账机器接口
public interface TransferMachine {

    /**
     * 转账操作
     *
     * @param from   转出账户
     * @param to     转入账户
     * @param amount 交易金额
     */

    void transfer(Account from, Account to, Integer amount);
}
// 转账机器的实现
public class TransferMachineImpl implements TransferMachine {

    /**
     * 转账操作
     *
     * @param from   转出账户
     * @param to     转入账户
     * @param amount 交易金额
     */

    public void transfer(Account from, Account to, Integer amount) {
        String name = Thread.currentThread().getName();
        synchronized (from) {
            System.out.println(name + ">>>lock from success");
            synchronized (to) {
                System.out.println(name + ">>>lock to success");
                from.flyMoney(amount);
                to.addMoney(amount);
            }
        }
    }
}
// 转账动作的模拟
public class TransferMain {

    public static class DynamicRun implements Runnable {

        private Account a;
        private Account b;
        private TransferMachine t;
        private Integer amount;

        DynamicRun(TransferMachine t, Account a, Account b, Integer amount) {
            this.t = t;
            this.a = a;
            this.b = b;
            this.amount = amount;
        }

        public void run() {
            t.transfer(a, b, amount);//转账操作
        }
    }

    public static void main(String[] args) throws InterruptedException {

        //初始化两个账户
        Account firstAccount = new Account("zhangsan"20000);
        Account secondAccount = new Account("lisi"20000);

        //张三给李四转账1000
        new Thread(new DynamicRun(new TransferMachineImpl(), firstAccount, secondAccount, 1000)).start();
        //李四给张三转账2000
        new Thread(new DynamicRun(new TransferMachineImpl(), secondAccount, firstAccount, 2000)).start();
    }
}

zhangsan和lisi账户初始金额都是20000,在某个时刻,zhangsan和lisi互相转账,这个时候可以看的出来,在转账机器实现中,都是先锁转出账户再锁转入账户,顺序是不变的。但是运行代码会发现,在转账的过程中发生了死锁,明明这里已经保证了多个线程对两个相同的资源操作加锁的顺序相同,为什么还是会死锁。

原因分析

这里其实稍微思考一下就知道,看起来锁的顺序相同,但是实际是不同的,因为这个方法里面两个资源都是通过变量传过来的,当在传入的两个变量的顺序相反时,自然锁的顺序就被偷换。
想要解决就要想办法将两个锁的顺序保持相同。如何保持相同?

解决方案

方式一:通过hash值得比较实现加锁顺序相同
public class TransferMachineImpl implements TransferMachine {

    private final Object fairLock = new Object();//公平竞争锁

    /**
     * 转账操作
     *
     * @param from   转出账户
     * @param to     转入账户
     * @param amount 交易金额
     */

    public void transfer(Account from, Account to, Integer amount) {
        String name = Thread.currentThread().getName();
        final Integer fromHash = System.identityHashCode(from);
        final Integer toHash = System.identityHashCode(to);
        if (fromHash < toHash) {
            synchronized (fromHash) {
                System.out.println(name + ">>>lock from success");
                synchronized (toHash) {
                    System.out.println(name + ">>>lock to success");
                    from.flyMoney(amount);
                    to.addMoney(amount);
                }
            }
        } else if (toHash < fromHash) {
            synchronized (toHash) {
                System.out.println(name + ">>>lock from success");
                synchronized (fromHash) {
                    System.out.println(name + ">>>lock to success");
                    from.flyMoney(amount);
                    to.addMoney(amount);
                }
            }
        } else {
            synchronized (fairLock) {
                synchronized (from) {
                    System.out.println(name + ">>>lock from success");
                    synchronized (to) {
                        System.out.println(name + ">>>lock to success");
                        from.flyMoney(amount);
                        to.addMoney(amount);
                    }
                }
            }
        }
    }
}

通过System.identityHashCode(Object o)的方法获取当前对象的原生hash值,然后比较两个资源的hash值大小,每次都是先锁hash值小的账户,但是难免会遇到hash值相同的时候,这个时候就让他们共同竞争一把锁。但是为什么不直接使用else代码来实现呢。
我们都知道加锁和释放锁的过程都是一种性能的消耗,明显的是else代码中要多一层锁,但是我们也知道在计算得到两个不同对象的hash值后,出现相同的几率是很低的,因此else代码极少的会执行到。对性能的影响可以忽略不计,但是直接使用这块代码,就很难保证了。
上面计算hash值是一种方式,如果我们账户里面有一个数值类型字段id,并且能够保证不同的账户id绝对不会出现相同,那么使用账户的id来代替hash也不失为一种变通方式。

方式二:通过显示锁的方式实现

变动就是在账户实体类中加上一个显示锁的字段,并提供get方法。

private final Lock lock = new ReentrantLock();
public Lock getLock() {
    return this.lock;
}

这个时候就将转账机器实现类的代码改成如下的代码:

public class TransferMachineImpl implements TransferMachine {

    /**
     * 转账操作
     *
     * @param from   转出账户
     * @param to     转入账户
     * @param amount 交易金额
     */

    public void transfer(Account from, Account to, Integer amount) {
        String name = Thread.currentThread().getName();
        while (true) {
            if (from.getLock().tryLock()) {
                try {
                    System.out.println(name + ">>>lock from success");
                    if (to.getLock().tryLock()) {
                        try {
                            System.out.println(name + ">>>lock to success");
                            // 转账操作
                            from.flyMoney(amount);
                            to.addMoney(amount);
                            break;
                        } finally {
                            to.getLock().unlock();
                        }
                    }
                } finally {
                    from.getLock().unlock();
                }
            }
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
/*
输出结果:
Thread-0>>>lock from success
Thread-1>>>lock from success
Thread-1>>>lock to success
Thread-0>>>lock from success
Thread-0>>>lock to success
*/

两个线程同时执行,并没有出现死锁现象,但是输出的结果有点意思,按道理说应该是四行,两个lock from,两次lock to,但是这里输出的貌似有点多。
分析一下:Thread-0拿到转出账户后加锁,Thread-1拿到转出账户后加锁,接着Thread-0尝试去拿转入账户的锁,发现锁被其他线程持有了,没有拿成功,然后把转出账户的锁释放,休眠100毫秒,这个时候Thread-1去拿转入账户的锁,因为Thread-0释放了,Thread-1加锁成功,完成转账。等Thread-0从休眠中结束后再去拿转出和转入的锁,就很轻松了,和其竞争的Thread-1已经执行结束。这里有点绕。
为什么这里代码中要让其中一个线程在获取两把锁失败的时候休眠呢。仔细思考的小伙伴可能已经知道,如果不休眠,可能会导致两个线程都拿到第一个锁,第二个锁拿不到,然后释放第一个锁,这两线程来回的释放锁获取锁,虽然最终在某个时间点,一个线程可以拿到两个锁,但是这个就会导致循环执行次数很多。

总结

在整个分析的过程中,一个不变的目标就是让锁被获取的顺序相同,保证执行不会死锁,换句话说就是不论是简单死锁还是动态死锁,只要能保证两个锁的获取和释放顺序绝对相同,那么死锁问题就解决了。当然后面的显示锁实现方式有点例外。

推荐阅读(点击即可跳转阅读)

1. SpringBoot内容聚合

2. 面试题内容聚合

3. 设计模式内容聚合

4. Mybatis内容聚合

5. 多线程内容聚合

死锁的两种体现形式分析和解决方案

原文始发于微信公众号(后端技术精选):死锁的两种体现形式分析和解决方案