Java多线程之死锁、活锁与饥饿

Java多线程之死锁

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

死锁的影响

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

  • 数据库中:检测并放弃事务
  • JVM中:无法自动处理(但是可以检测)

死锁发生的几率不高,但是危害大:

  • 不一定发生,但是遵守“墨菲定律”
  • 一旦发生,多是高并发场景,影响用户多
  • 整个系统崩溃、子系统崩溃、性能下降
  • 压力测试无法找出所有潜在的死锁(并发量和死锁是正相关,而不是必然关系)
    ##发生死锁的例子
  • 最简单的情况
package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 08:42
 * @Description: 必定发生死锁的情况
 */
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成功拿到两把锁");
                }
            }
        }
    }
}

  • 实际生产中的例子:转账
package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 09:22
 * @Description: 转账操作遇到死锁,一旦打开注释,便会发生死锁
 */
public class TransferMoney implements Runnable{
    int flag = 1;
    Account a = new Account(600);
    Account b = new Account(500);
    @Override
    public void run() {
        if (flag == 1) {
            transferMoney(a, b, 200);
        }
        if (flag == 0) {
            transferMoney(b, a, 200);
        }
    }

    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的余额: " + r1.a.balance);
        System.out.println("b的余额: " + r2.b.balance);
    }

    public static void transferMoney(Account from, Account to, int amount) {
        synchronized (from) {
            //打开注释,则会发生死锁
//            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 {
        int balance;

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

  • 模拟多人随机转账
package deadlock;

import java.util.Random;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 10:31
 * @Description: 多人同时转账,还是很危险
 */
public class MultiTransferMoney {
    private static final int NUM_ACCOUNTS = 500;
    private static final int NUM_MONEY = 1000;
    private static final int NUM_INTERATIONS = 1000000;
    private static final int NUM_THREADS = 20;
    
    public static void main(String[] args) {
        Random rnd = new Random();
        TransferMoney.Account[] accounts = new TransferMoney.Account[NUM_ACCOUNTS];
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = new TransferMoney.Account(NUM_MONEY);

        }
        class TransferThread extends Thread {
            @Override
            public void run() {
                for (int i = 0; i <NUM_INTERATIONS; i++) {
                    int fromAcct = rnd.nextInt(NUM_ACCOUNTS);
                    int toAcct = rnd.nextInt(NUM_ACCOUNTS);
                    int amount = rnd.nextInt(NUM_MONEY);
                    TransferMoney.transferMoney(accounts[fromAcct],
                            accounts[toAcct], amount);
                }
            }
        }
        for (int i = 0; i < NUM_THREADS; i++) {
            new TransferThread().start();
        }
    }
}

  • 运行结果:
    在这里插入图片描述

死锁的四个必要条件

  1. 互斥条件
  2. 请求与保持条件
  3. 不可剥夺条件
  4. 循环等待条件
    注意:缺一不可

如何定位死锁

  • jstack(命令行)
  • ThreadMXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//返回一系列陷入死锁的线程
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();

修复死锁的策略

线上发生死锁应该怎么办

  • 保存案发现场(堆栈信息)并重启服务器
  • 暂时保证线上服务的安全,再利用前面保存的信息,排查死锁,修改代码,重新发

常见修复策略

避免策略

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

//转账时避免死锁
//通过hashcode来决定获取锁的顺序、冲突时需要“加时赛”
//当有主见时就更加方便
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 {
            //避免hash冲突时导致死锁
            synchronized (lock) {
                synchronized (to) {
                    synchronized (from) {
                        new Helper().transfer();
                    }
                }
            }
        }
哲学家就餐问题

问题描述:哲学家就餐问题可以这样表述,假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗意大利面,每两个哲学家之间有一只餐叉。因为用一只餐叉很难吃到意大利面,所以假设哲学家必须用两只餐叉吃东西。他们只能使用自己左右手边的那两只餐叉。哲学家就餐问题有时也用米饭和筷子而不是意大利面和餐叉来描述,因为很明显,吃米饭必须用两根筷子。哲学家从来不交谈,这就很危险,可能产生死锁,每个哲学家都拿着左手的餐叉,永远都在等右边的餐叉(或者相反)。即使没有死锁,也有可能发生资源耗尽。例如,假设规定当哲学家等待另一只餐叉超过五分钟后就放下自己手里的那一只餐叉,并且再等五分钟后进行下一次尝试。这个策略消除了死锁(系统总会进入到下一个状态),但仍然有可能发生“活锁”。如果五位哲学家在完全相同的时刻进入餐厅,并同时拿起左边的餐叉,那么这些哲学家就会等待五分钟,同时放下手中的餐叉,再等五分钟,又同时拿起这些餐叉。在实际的计算机问题中,缺乏餐叉可以类比为缺乏共享资源。一种常用的计算机技术是资源加锁,用来保证在某个时刻,资源只能被一个程序或一段代码访问。当一个程序想要使用的资源已经被另一个程序锁定,它就等待资源解锁。当多个程序涉及到加锁的资源时,在某些情况下就有可能发生死锁。例如,某个程序需要访问两个文件,当两个这样的程序各锁了一个文件,那它们都在等待对方解锁另一个文件,而这永远不会发生。
在这里插入图片描述

package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 11:55
 * @Description: 掩饰哲学家就餐问题导致的死锁
 */
public class DiningPhilosophers {
    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();
        }
    }
    public static class Philosopher implements Runnable{

        private Object leftChopstick;
        private Object rightChopstick;

        public Philosopher(Object leftChopstick, Object rightChopstick) {
            this.leftChopstick = leftChopstick;
            this.rightChopstick = rightChopstick;
        }

        @Override
        public void run() {
            while (true) {
                try {
                    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));
        }
    }
}

多种解决方案
  • 服务员检查(避免策略),相当于引入了外界的协调
  • 改变一个哲学家拿叉子的顺序(避免策略)
  • 餐票(避免策略)
  • 领导调节(检测与恢复策略)
检测与恢复策略
死锁检测算法
  • 允许发生死锁
  • 每次调用锁都记录
  • 定期检查“锁的调用链路图”中是否存在环路
  • 一旦检测发生了死锁,就用死锁恢复机制进行恢复
死锁恢复机制
  • 恢复方法1:进程终止(逐个终止线程,直到死锁消除)
  • 恢复方法2:资源抢占(把已经分发出去的锁给收回来,让线程回退几部,不用结束整个线程,成本比较低,但是可能同一个线程一直被抢占,就会造成饥饿
鸵鸟策略

如果死锁发生概率极低,那么可以忽略,等到死锁出现再进行人工修复

实际工程中如何避免死锁

  1. 设置超时时间
  • Lock的tryLock(long timeout, TimeUnit unit)
  • sunchronized不具备尝试锁的能力
  • 造成超时的可能性多:发生了死锁、线程陷入了死循环、线程执行很慢
  • 获取锁失败:打日志、发报警邮件、重启等
package deadlock;

import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 14:47
 * @Description: 用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();
                }
            }
        }
    }
}

执行结果:

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

  • ConcurrentHashMap、ConcurrnetLinkedQueue、AutomicBoolean等
  • 实际应用中java.util.concurrent.atomic十分有用,简单方便且效率比使用Lock更高
  • 多用并发集合少用同步集合,并发集合比同步集合的课扩展性更好
  • 并发场景需要用到map,优先考虑ConcurrentHashMap
  1. 尽量降低锁的使用粒度,用不同的锁而不是同一个锁
  2. 如果能使用同步代码块,就不使用同步方法,自己使用锁对象
  3. 给线程起个有意义的名字:debug和排查时效率更高
  4. 避免锁的嵌套:MustDeadLock类
  5. 分配资源前先看能不能收回来:银行家算法
  6. 尽量不要几个功能用同一把锁:专锁专用

其他活性故障

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

  • 活锁(LiveLock)
  • 饥饿

活锁

在银行家算法场景中的不同体现:
在完全相同的时刻同时坐上餐桌,并同时拿起左边的筷子,那么这些哲学家就会等待五分钟分钟,同时放下手中的筷子,再等五分钟以后又拿起来,在实际计算机的问题中,将这类问题比喻为缺乏共享资源

什么是活锁

  • 虽然线程并没有阻塞,也始终在运行,但是程序却得不到进展,因为线程始终重复做同样的事
package deadlock;

/**
 * @Auther: Bob
 * @Date: 2020/2/17 15:40
 * @Description: 展示活锁的问题
 */
public class LiveLock {
    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 has eaten!", owner.name);
        }
    }
    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("Messi");
        Diner wife = new Diner("Suarez");
        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();
    }
}

运行结果:
(img-8qygRSSH-1581938358164)(./1581926810669.png)]

如何解决活锁问题

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

  1. 以太网的指数退避算法(重试时间不固定)
  2. 加入随机因素
工程中的活锁实例:消息队列

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

饥饿

  • 当线程需要某些资源(如CPU),但是却始终得不到
  • 线程的优先级设置得过低,或者由于某线程持有锁同时又无限循环而不释放锁,或者某程序始终占用某文件的写锁
  • 饥饿可能会导致响应性差
  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值