死锁的前世今生

177 阅读9分钟

声明:本文是自己自学慕课网悟空老师的《Java并发核心知识体系精讲》的死锁部分后整理而成课程笔记。

如有侵权,请私信我并第一时间删除本文。

image-20220211172820677

image-20220211174107495

image-20220211174221413

image-20220213151257261

1. 死锁是什么?有什么危害

1.1什么是死锁

  • 发生在并发中

  • 互不相让:当两个(或更多)线程(或进程)相互持有对方所需要的资源,又不主动释放,导致所有人都无法继续前进, 导致程序陷入无尽的阻塞,这就是死锁。

  • 一图胜千言

    image-20220211174525062

  • 多个线程造成死锁的情况

如果多个线程之间的依赖关系是环形,存在环路的锁的依赖关系,那么也可能会发生死锁

image-20220212192135055

1.2 死锁的影响

死锁的影响在不同系统中是不一样的,这取决于系统对死锁的处理能力

◆数据库中:检测并放弃事务

◆JVM中:无法自动处理

image-20220213152431795

2. 发生死锁例子

image-20220213160448472

image-20220211194619428

2.1 最简单的死锁

/**
 * 描述:     必定发生死锁的情况
 */
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成功拿到两把锁");
                }
            }
        }
    }
}

分析

  • 当类的对象flag=1时(T1) ,先锁定O1,睡眠500毫秒, 然后锁定O2 ;
  • 而T1在睡眠的时候另一个flag=0的对象(T2)线程启动,先锁定O2,睡眠500毫秒,等待T1释放01 ;
  • T1睡眠结束后需要锁定O2才能继续执行,而此时O2已被T2锁定;
  • T2睡眠结束后需要锁定O1才能继续执行,而此时O1已被T1锁定;
  • T1、T2相互等待,都需要对方锁定的资源才能继续执行,从而死锁。

image-20220212200127427

注意看退出信号: Process finished with exit code 130(interrupted by signal 2: SIGINT) ,是不正常退出的信号,对比正常结束的程序的结束信号是0

2.2 实际生产中的例子:转账

  • 需要两把锁

  • 获取两把锁成功,且余额大于0 ,则扣除转出人,增加收款人的余额,是原子操作

  • 顺序相反导致死锁

    /**
     * 描述:     转账时候遇到死锁,一旦打开注释,便会发生死锁
     */
    public class TransferMoney implements Runnable {
    ​
        int flag = 1;
        static Account a = new Account(500);
        static Account b = new Account(500);
        static Object lock = new Object();
    ​
        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的余额" + a.balance);
            System.out.println("b的余额" + b.balance);
        }
    ​
        @Override
        public void run() {
            if (flag == 1) {
                transferMoney(a, b, 200);
            }
            if (flag == 0) {
                transferMoney(b, a, 200);
            }
        }
    ​
        public static void transferMoney(Account from, Account to, int amount) {
           synchronized (from){
               /**加上sleep就造成死锁了
               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 {
    ​
            public Account(int balance) {
                this.balance = balance;
            }
    ​
            int balance;
    ​
        }
    }
    

    未加注释代码部分,运行结果如下

image-20220211175040852

加上注释后运行结果如下,由于请求锁的顺序相反,造成相互等待,互不退让,造成死锁。

image-20220212192417768

2.3 模拟多人随机转账

  • 5万人很多,但是依然会发生死锁,墨菲定律
  • 复习:发生死锁几率不高但危害大
import java.util.Random;
​
/**
 * 描述:     多人同时转账,依然很危险
 */
public class MultiTransferMoney {
​
    private static final int NUM_ACCOUNTS = 500;
    private static final int NUM_MONEY = 1000;
    private static final int NUM_ITERATIONS = 1000000;
    private static final int NUM_THREADS = 20;
​
    public static void main(String[] args) {
​
        Random rnd = new Random();
        Account[] accounts = new Account[NUM_ACCOUNTS];
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = new Account(NUM_MONEY);
        }
        
        
        class TransferThread extends Thread {
            @Override
            public void run() {
                for (int i = 0; i < NUM_ITERATIONS; i++) {
                    int fromAcct = rnd.nextInt(NUM_ACCOUNTS);
                    int toAcct = rnd.nextInt(NUM_ACCOUNTS);
                    int amount = rnd.nextInt(NUM_MONEY);
                    transferMoney(accounts[fromAcct], accounts[toAcct], amount);
                }
                System.out.println("运行结束");
            }
        }
        for (int i = 0; i < NUM_THREADS; i++) {
            new TransferThread().start();
        }
    }
    
        public static void transferMoney(Account from, Account to, int amount) {
           synchronized (from){
               synchronized (to){
                   if (from.balance - amount < 0){
                       System.out.println("余额不足,转账失败。");
                   }
                   from.balance -= amount;
                   to.balance += amount;
                   System.out.println("成功转账" + amount + "元");
               }
           }
        }
​
    
     static class Account {
​
        public Account(int balance) {
            this.balance = balance;
        }
​
        int balance;
​
    }
}

运行结果,发生死锁

image-20220211193627103

3. 死锁的4个必要条件(缺一不可)

1.互斥条件

每个资源每次只能被一个线程(或进程,下同)使用。

为什么资源不能同时被多个线程或进程使用呢?这是因为如果每个人都可以拿到想要的资源,那就不需要等待,所以是不可能发生死锁的。

2.请求与保持条件

一个线程因请求资源而阻塞时,则需对已获得的资源保持不放。

如果在请求资源时阻塞了,并且会自动释放手中资源(例如锁)的话,那别人自然就能拿到我刚才释放的资源,也就不会形成死锁。

3.不剥夺条件

指线程已获得的资源,在未使用完之前,不会被强行剥夺。

比如我们数据库,它就有可能去强行剥夺某一个事务所持有的资源,这样就不会发生死锁了。所以要想发生死锁,必须满足不剥夺条件,也就是说当现在的线程获得了某一个资源后,别人就不能来剥夺这个资源,这才有可能形成死锁。

4.循环等待条件

只有若干线程之间形成一种头尾相接的循环等待资源关系时,才有可能形成死锁。

比如在两个线程之间,这种“循环等待”就意味着它们互相持有对方所需的资源、互相等待;而在三个或更多线程中,则需要形成环路,例如依次请求下一个线程已持有的资源等

4. 如何定位死锁

4.1 jstack

image-20220213161002817

4.2 死锁检测工具类(ThreadMXBean)

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
​
/**
 * 描述:     用ThreadMXBean检测死锁
 */
public class ThreadMXBeanDetection implements Runnable {
​
    int flag = 1;
​
    static Object o1 = new Object();
    static Object o2 = new Object();
​
    public static void main(String[] args) throws InterruptedException {
        ThreadMXBeanDetection r1 = new ThreadMXBeanDetection();
        ThreadMXBeanDetection r2 = new ThreadMXBeanDetection();
        r1.flag = 1;
        r2.flag = 0;
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r2);
        t1.start();
        t2.start();
        Thread.sleep(1000);
        
        //以下是核心代码
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
        if (deadlockedThreads != null && deadlockedThreads.length > 0) {
            for (int i = 0; i < deadlockedThreads.length; i++) {
                ThreadInfo threadInfo = threadMXBean.getThreadInfo(deadlockedThreads[i]);
                System.out.println("发现死锁" + threadInfo.getThreadName());
            }
        }
        //以上是核心代码
    }
​
    @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成功拿到两把锁");
                }
            }
        }
    }
}

image-20220211193730828

5. 修复死锁的策略

5.1 线上发生了死锁怎么办

线上问题都需要防患于未然,不造成损失地扑灭几乎已经是 不可能

保存案发现场然后立刻重启服务器

◆暂时保证线上服务的安全,然后在利用刚才保存的信息,排查死锁,修改代码,重新发版

5.2 常见的修复策略

  • 死锁避免策略:哲学家就餐的换手方案、转账换序方案
  • 检测与恢复策略: 一段时间检测是否有死锁,如果有就剥夺某一个资源,来打开死锁。
  • 鸵鸟策略:鸵鸟这种动物在遇到危险的时候,通常就会把头埋在地上,这样一来它就看不到危险了。而鸵鸟策略的意思就是说,如果我们发生死锁的概率极其低,那么我们就直接忽略它,直到死锁发生的时候,再人工修复

5.2.1 死锁避免策略(包含哲学家就餐问题)

  • 思路:避免相反的获取锁的顺序

  • 转账时避免死锁

  • 实际上不在乎获取锁的顺序

    代码演示

     public class TransferMoney implements Runnable {
     
         int flag = 1;
         static Account a = new Account(500);
         static Account b = new Account(500);
         static Object lock = new Object();
     
         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的余额" + a.balance);
             System.out.println("b的余额" + b.balance);
         }
     
         @Override
         public void run() {
             if (flag == 1) {
                 transferMoney(a, b, 200);
             }
             if (flag == 0) {
                 transferMoney(b, a, 200);
             }
         }
     
         public static void transferMoney(Account from, Account to, int amount) {
             class Helper {
     
                 public void transfer() {
                     if (from.balance - amount < 0) {
                         System.out.println("余额不足,转账失败。");
                         return;
                     }
                     from.balance -= amount;
                     to.balance = to.balance + amount;
                     System.out.println("成功转账" + amount + "元");
                 }
             }
             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  {    //通过hashcode来决定获取锁的顺序、冲突时需要“加时赛”,有主键更方便
                 synchronized (lock) {
                     synchronized (to) {
                         synchronized (from) {
                             new Helper().transfer();
                         }
                     }
                 }
             }
     
         }
    

避免策略:哲学家就餐的换手方案、转账换序方案

image-20220212203607916

image-20220212195706623

image-20220212204654816

image-20220213152931202

有死锁和资源耗尽的风险

◆死锁:每个哲学家都拿着左手的餐叉,永远都在等右边的餐叉(或者相反)

/**
 * 描述:     演示哲学家就餐问题导致的死锁
 */
public class DiningPhilosophers {
​
    public static class Philosopher implements Runnable {
​
        private Object leftChopstick;
​
        public Philosopher(Object leftChopstick, Object rightChopstick) {
            this.leftChopstick = leftChopstick;
            this.rightChopstick = rightChopstick;
        }
​
        private Object rightChopstick;
​
        @Override
        public void run() {
            try {
                while (true) {
                    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));
        }
    }
​
    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();
        }
    }
}

哲学家问题的多种解决方案

  • 服务员检查(避免策略)

  • 改变一个哲学家拿叉子的顺序(避免策略)

    /**
     * 描述: 其他不变,修改main函数
     */
        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];
                //以下为不同部分
                if (i == philosophers.length - 1) {
                    philosophers[i] = new Philosopher(rightChopstick, leftChopstick);
                } else {
                    philosophers[i] = new Philosopher(leftChopstick, rightChopstick);
                }
                //以上为不同部分
                new Thread(philosophers[i], "哲学家" + (i + 1) + "号").start();
            }
        }
    }
    
  • 餐票(避免策略)

  • 领导调节(检测与恢复策略)

5.2.2 死锁的检测与恢复策略

检测算法:锁的调用链路图

image-20220211210400760

◆允许发生死锁

◆每次调用锁都记录

◆定期检查"锁的调用链路图”中是否存在环路

◆一旦发生死锁,就用死锁恢复机制进行恢复

恢复方法1:进程终止

逐个终止线程,直到死锁消除。

◆终止顺序:

1.优先级(是前台交互还是后台处理)

2.已占用资源、还需要的资源

3.已经运行时间

恢复方法2:资源抢占

◆把已经分发出去的锁给收回来

◆让线程回退几步,这样就不用结束整个线程,成本比较低

◆缺点:可能同一个线程一直被抢占,那就造成饥饿

6. 实际工程中如何避免死锁

  1. 设置超时时间

    • Lock的tryLock(long timeout, TimeUnit unit)
    • synchronized不具备尝试锁的能力
    • 造成超时的可能性多: 发生了死锁、线程陷入死循环、线程 执行很慢
    • 获取锁失败:打日志、发报警邮件、重启等
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
​
/**
 * 描述:     用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();
                }
            }
        }
    }
}

image-20220211204822126

2.多使用并发类而不是自己设计锁

◆ConcurrentHashMapConcurrentLinkedQueue、 AtomicBoolean等

◆实际应用中java.util.concurrent.atomic十分有用,简单方便且效率比使用Lock更高

◆多用并发集合少用同步集合,并发集合比同步集合的可扩展性更好

◆并发场景需要用到map ,首先想到用ConcurrentHashMap

3.尽量降低锁的使用粒度 :用不同的锁而不是一个锁

4.如果能使用同步代码块,就不使用同步方法:自己指定锁对象

5.给你的线程起个有意义的名字: debug和排查时事半功倍,框架 和JDK都遵守这个最佳实践

6.避免锁的嵌套: MustDeadLock类

7.分配资源前先看能不能收回来:银行家算法

8.尽量不要几个功能用同一把锁:专锁专用

7. 其他活性故障(又叫活跃性问题)

◆死锁是最常见的活跃性问题,不过除了刚才的死锁之外,还有一些类似的问题,会导致程序无法顺利执行,统称为活跃性问题

◆活锁( LiveLock )

有死锁和资源耗尽的风险

死锁:每个哲学家都拿着左手的餐叉,永远都在等右边的餐 叉(或者相反) 活锁:在完全相同的时刻进入餐厅,并同时拿起左边的餐叉, 那么这些哲学家就会等待五分钟,同时放下手中的餐叉,再 等五分钟,又同时拿起这些餐叉。

在实际的计算机问题中, 缺乏餐叉可以类比为缺乏共享资源

7.1 活锁

◆什么是活锁

  • 虽然线程并没有阻塞,也始终在运行(所以叫做“活”锁,线程是“活”的),但是程序却得不到进展,因为线程始终重复做同样的事
  • 如果这里死锁,那么就是这里两个人都始终一动不动,直到对方先抬头,他们之间不再说话了,只是等待
  • 如果发生活锁,那么这里的情况就是,双方都不停地对对方说 “你先起来吧,你先起来吧”, 双方都一直在说话,在运行
  • 死锁和活锁的结果是一样的,就是谁都不能先抬头

◆代码演示

import java.util.Random;
​
/**
 * 描述:     演示活锁问题
 */
public class LiveLock {
    //spoon为勺子
    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吃完了!", owner.name);
​
​
        }
    }
    //Diner为就餐者
    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("牛郎");
        Diner wife = new Diner("织女");
​
        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();
    }
}
​

image-20220212210602303

原因:重试机制不变,消息队列始终重试,吃饭始终谦让

如何解决活锁问题:

  • 以太网的指数退避算法
  • 加入随机因素
        public void eatWith(Spoon spoon, Diner spouse) {
            while (isHungry) {
                if (spoon.owner != this) {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    continue;
                }
                //加入随机
                Random random = new Random();
                if (spouse.isHungry && random.nextInt(10) < 9) {
                    System.out.println(name + ": 亲爱的" + spouse.name + "你先吃吧");
                    spoon.setOwner(spouse);
                    continue;
                }
​
                spoon.use();
                isHungry = false;
                System.out.println(name + ": 我吃完了");
                spoon.setOwner(spouse);
​
            }
        }
    }

image-20220212225300783

◆工程中的活锁实例:消息队列

image-20220211210418169

  • 策略:消息如果处理失败,就放在队列开头重试
  • 由于依赖服务出了问题,处理该消息一直失败.
  • 没阻塞,但程序无法继续
  • 解决:放到队列尾部、重试限制

7.2 饥饿

image-20220213160527124

image-20220213160602261

image-20220211220451547

8. 面试常考问题

image-20220213160913129