【活跃性问题】全面分析死锁、活锁和饥饿

死锁

死锁的概述

死锁发生在并发中,并且互不相让。

描述:当两个或者多个线程(或者进程)互相持有对方所占有的资源,又不主动释放自己的资源,导致线程陷入阻塞,即为死锁。多个线程如果存在环形的锁依赖关系,可能导致死锁。

例子:有两个人见面分别向对方鞠躬,然而出于绅士风度,两人都不想早于对方起身。
【活跃性问题】全面分析死锁、活锁和饥饿_第1张图片

死锁的影响

数据库事务发生死锁时,数据库会强行终止事务。但JVM无法自动处理死锁。陷入死锁的线程可能非常重要,不能放弃锁,因此由程序员处理。

死锁发生的几率不高,但是很大。一般发生在高并发场景,影响用户多;根据死锁在系统中位置不同,可能导致系统崩溃、性能降低等。

压力测试无法找出所有潜在的死锁。例如,线程等待锁被调起是随机的;短时间持有锁,是为了降低锁的竞争程度,但却增加了测试中找出潜在死锁的难度。

一段必然死锁的代码–代码演示

public class MustDeadLock implements Runnable {
    private int flag;

    public MustDeadLock(int flag) {
        this.flag = flag;
    }

    private static final Object resourceA = new Object();
    private static final Object resourceB = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(new MustDeadLock(0));
        Thread thread2 = new Thread(new MustDeadLock(1));
        thread1.start();
        thread2.start();
    }

    @Override
    public void run() {
        if (flag == 0) {
            synchronized (resourceA) {
                System.out.println(Thread.currentThread().getName() + "获取A锁");
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resourceB) {
                    System.out.println(Thread.currentThread().getName() + "获取B锁");
                }
            }
        } else {
            synchronized (resourceB) {
                System.out.println(Thread.currentThread().getName() + "获取B锁");
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resourceA) {
                    System.out.println(Thread.currentThread().getName() + "获取A锁");
                }
            }
        }
    }
}
Thread-1获取B锁
Thread-0获取A锁

Process finished with exit code -1 / 130
控制台正常终止结束信号是0。
代码演示:转账

转账需要两把锁:转出账户和转入账户,两者都不能同时转入转出。获取两把锁成功,并且余额大于0,则扣除转出账户,增加转入账户余额,是原子操作。互相转钱时可能导致死锁。

死锁的4个必要条件

  1. 互斥条件:资源同时只有一个线程占用。
  2. 请求与保持条件:线程请求被其他线程占用的资源,而不释放自己占用的资源。
  3. 不可剥夺条件:线程占用的资源在使用完之前不能被其他线程抢占。
  4. 循环等待条件:发生死锁时,必然存在线程资源依赖的环形链。

四个条件是必要条件,必须同时满足才会发生死锁。

定位死锁

jstack精确定位死锁

使用jps命令定位线程PID

> jps
25604 MustDeadLock

使用jstack打印死锁信息

> jstack -l 25604

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HSjqYKvc-1583594286763)(931BFB9548734896AD717D643BD3713E)]

jstack无法应对复杂死锁,但是可以用来分析栈信息。

ThreadMXBean检查死锁

// 使用ThreadMXBean检测死锁
Thread.sleep(1000);
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadLockThreads = threadMXBean.findDeadlockedThreads();
if (deadLockThreads != null && deadLockThreads.length != 0) {
    for (long deadLockThread : deadLockThreads) {
        ThreadInfo threadInfo = threadMXBean.getThreadInfo(deadLockThread);
        System.out.println("死锁线程:" + threadInfo.getThreadName());
    }
}

死锁修复策略

平时开发需要提前预防死锁,如果线上出现死锁,很难做到无损失的解决。因此要保存好堆栈信息,然后立刻重启服务器,避免影响用户体验。

死锁的修复策略有以下几种:

  • 预防与避免策略:转账换序方案、哲学家就餐的换手方案。
  • 检查与恢复策略:一段时间检查是否有死锁,如果有,剥夺资源,打破死锁。
  • 鸵鸟策略:死锁发生概率很低,直接忽略;等到死锁发生时,人工修复。

预防策略–转账换序方案【代码演示】

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

public class TransferMoneyNotDeadLock implements Runnable {
    private int flag;

    public TransferMoneyNotDeadLock(int flag) {
        this.flag = flag;
    }

    /**
     * 两个账户锁
     */
    private static Account account1 = new Account(500);
    private static Account account2 = new Account(500);

    /**
     * 额外的锁,用于hashcode相同时规范获取锁顺序
     */
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread thread0 = new Thread(new TransferMoneyNotDeadLock(0));
        Thread thread1 = new Thread(new TransferMoneyNotDeadLock(1));
        thread0.start();
        thread1.start();
        thread0.join();
        thread1.join();
        System.out.println("账户1余额:" + account1.blance);
        System.out.println("账户2余额:" + account2.blance);
    }

    @Override
    public void run() {
        if (flag == 0) {
            transMoney(account1, account2, 200);
        } else {
            transMoney(account2, account1, 200);
        }
    }

    private void transMoney(Account from, Account to, int account) {
        // 获取两个账户的hashcode,通过比较大小规范获取锁的顺序
        int fromHashCode = System.identityHashCode(from);
        int toHashCode = System.identityHashCode(to);
        if (fromHashCode < toHashCode) {
            synchronized (from) {
                synchronized (to) {
                    transfer(from, to, account);
                }
            }
        } else if (fromHashCode > toHashCode) {
            synchronized (to) {
                synchronized (from) {
                    transfer(from, to, account);
                }
            }
        } else { // 此时hashcode值相同
            synchronized (lock) {
                synchronized (from) {
                    synchronized (to) {
                        transfer(from, to, account);
                    }
                }
            }
        }
    }

    private void transfer(Account from, Account to, int account) {
        if (from.blance < account) {
            throw new RuntimeException("账户余额不足");
        }
        from.blance -= account;
        to.blance += account;
        System.out.println("成功转账" + account + "元");
    }

    static class Account {
        int blance;

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

案例中,使用锁的hashcode值决定锁的顺序,在hash冲突时采用加时赛(使用额外的锁)。如果对象数据有能够标识顺序的字段(例如自增主键),则不需要额外的锁。

实际场景不会直接加锁,可能用到事务保证转账的一致性和原子性,使用消息队列异步处理,即使加锁也会用分布式加锁。

预防策略–哲学家就餐的换手方案

哲学家就餐问题:假设五位哲学家围成一桌,两人中间放置一根筷子。哲学家除了思考就是吃饭,哲学家吃饭时先拿左手边筷子,再拿右手边筷子,此时下一位哲学家吃饭只能等待左手边筷子。如果五位哲学家同时吃饭,同时拿起了左手边筷子,就会导致死锁和资源耗尽。

哲学家就餐问题的解决方案:

  1. 就餐前检查资源是否足够–预防免策略
  2. 改变一个哲学家就餐的顺序–预防策略
  3. 限制同时就餐人数,同一时间最多只能有4个科学家就餐–预防策略
  4. 检查与恢复

避免策略 – 银行家算法

描述:以银行借贷分配策略为基础,判断并保证系统处于安全状态

  • 客户在第一次申请贷款时,声明所需最大资金量,在满足所有贷款要求并完成项目时,及时归还。
  • 在客户贷款数量不大于银行最大值时,银行家尽量满足客户需要

角色

  • 银行家–操作系统
  • 资金–资源
  • 客户–申请资源的线程

思想:银行的剩余资源满足某个申请的未来需要,并回收资源。以此迭代,不断满足未来需要、回收资源,构成一个安全序列。不满足未来需要,表示不安全。

检查与恢复策略–代码演示

思想:一段时间检查是否有死锁,如果有,剥夺资源,打破死锁。

【代码演示】使用显式锁Lock中的定时tryLock功能代替内置锁机制。

public class TryLockDeadLock implements Runnable {
    private int flag;

    public TryLockDeadLock(int flag) {
        this.flag = flag;
    }

    private static final Lock lock1 = new ReentrantLock();
    private static final Lock lock2 = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(new TryLockDeadLock(0));
        Thread thread2 = new Thread(new TryLockDeadLock(1));
        thread1.start();
        thread2.start();

    }

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            if (flag == 0) {
                try {
                    if (lock1.tryLock(1, TimeUnit.SECONDS)) {
                        System.out.println(Thread.currentThread().getName() + "获取lock1");
                        TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        if (lock2.tryLock(1, TimeUnit.SECONDS)) {
                            System.out.println(Thread.currentThread().getName() + "获取lock2,此时拥有两把锁");
                            lock2.unlock();
                            lock1.unlock();
                            break;
                        } else {
                            System.out.println(Thread.currentThread().getName() + "获取lock2失败,重试");
                            lock1.unlock();
                            TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println(Thread.currentThread().getName() + "获取lock1失败,重试");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } else {
                try {
                    if (lock2.tryLock(1, TimeUnit.SECONDS)) {
                        System.out.println(Thread.currentThread().getName() + "获取lock2");
                        TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        if (lock1.tryLock(1, TimeUnit.SECONDS)) {
                            System.out.println(Thread.currentThread().getName() + "获取lock1,此时拥有两把锁");
                            lock1.unlock();
                            lock2.unlock();
                            break;
                        } else {
                            System.out.println(Thread.currentThread().getName() + "获取lock1失败,重试");
                            lock2.unlock();
                            TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println(Thread.currentThread().getName() + "获取lock2失败,重试");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }
    }
}

检测与恢复策略–操作系统概念

检测算法:锁的调用链路

  1. 允许系统进入死锁状态
  2. 每次调用锁都记录,维护锁调用链路
  3. 定期检查锁的调用链路图,是否存在死锁
  4. 一旦发生死锁,采用死锁恢复机制进行恢复。

恢复方法
进程终止

  • 终止所有线程,重新计算
  • 逐个终止线程直到死锁消失。每终止一个线程都要做死锁检测,开销很大。

资源抢占

  • 选取一个牺牲品(线程),最有效的是讲线程回滚到足够打破死锁。但某个线程一直被牺牲,可能造成饥饿,因此需要限制回滚次数。

实际开发中如何避免死锁

1. 设置超时时间

造成超时的可能性大概有:发生死锁、线程陷入死循环、线程执行慢。

  • Lock的tryLock具备超时功能,如果获取锁失败,可以打印日志以及报警等。
  • synchronized不具备尝试锁能力。不能中断试图获取monitor锁的线程

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

使用并发类发生死锁的概率低。

  • 并发类:ConcurrentHashMap、ConcurrentLinkeedQueue、AatomicBoolean
  • 实际应用中java.uti.concurrent.atomic包中的类
  • 多使用并发集合少用同步集合 ,并发集合比同步集合的可扩展性更好
  • 并发场景需要用到map,首先想到用ConcurrentHashMap

3. 降低锁的使用粒度:用不同的锁而不是一个锁。锁的保护范围(临界区)越小越好。

4. 如果可以使用同步代码块,不使用同步方法:自己指定锁对象。同步方法比同步代码块范围大。

5. 给线程起有意义的名字,便于debug和排查。

6. 避免锁的嵌套:例如MustDeadLock类

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

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


活锁

活锁:线程没有阻塞,始终在运行,但是程序得不到进展,重复做同样的事情。
活锁与死锁效果一样,程序无法正常运行。发生死锁,线程陷入阻塞;活锁不会阻塞,会更加耗费CPU资源。

举例1:类似于死锁例子,有两个人见面分别向对方鞠躬,然而处于绅士风度,两人都不想早于对方起身。因此两个人分别向对方说“你先请”,陷入循环,最终二人仍未起身。

举例2:例如哲学家就餐问题,五位哲学家同时拿起左手边筷子会造成死锁。如果给锁加5秒超时,5秒超时后获取锁失败,哲学家再次同时拿起筷子,循环往复,便是活锁。

活锁演示–谦让的绅士

public class LiveLock {

    public static void main(String[] args) {
        People gentleman1 = new People("绅士1");
        People gentleman2 = new People("绅士2");

        Action getUpAction = new Action(gentleman1);

        new Thread(() -> {
            try {
                gentleman1.doAction(getUpAction, gentleman2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            try {
                gentleman2.doAction(getUpAction, gentleman1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }

}

class Action {
    private People executor;

    public Action(People executor) {
        this.executor = executor;
    }

    public synchronized void execute() {
        System.out.println(executor.getName() + " 执行起身动作");
    }

    public People getexecutor() {
        return executor;
    }

    public void setexecutor(People executor) {
        this.executor = executor;
    }
}

class People {
    private String name; // 姓名
    private boolean isBendOver; // 是否弯腰

    public People(String name) {
        this.name = name;
        this.isBendOver = true;
    }

    public void doAction(Action action, People other) throws InterruptedException {
        while (isBendOver) {
            // 如果对方正在起身,等对方先起身
            if (action.getexecutor() != this) {
                TimeUnit.MILLISECONDS.sleep(1);
                continue;
            }
            // 如果对方仍在弯腰,请对方起身
            if (other.isBendOver) {
                // 使用随机元素打破活锁
                // Random rand = new Random();
                // if (other.isBendOver && rand.nextInt(10) < 9) {
                System.out.println(name + "对" + other.getName() + "说,您先起身。");
                action.setexecutor(other);
                continue;
            }
            action.execute();
            isBendOver = false;
            System.out.println(name + ":我起身了");
            action.setexecutor(other);
        }
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

活锁解决方法:加入随机因素,减小获取锁碰撞的概率。

在消息队列中,如果依赖服务出现问题,例如宕机,消息处理失败,由于消息在消息队列头部,会导致消息队列一直重试发送消息。

解决方法:(1)放到消息队列末尾;(2)限制重试上限,超出上限的数据可以持久化到数据库中,触发报警机制,等待调度再次调起。

饥饿

出现线程饥饿可能的原因:

  • 当线程需要某些资源(一般是CPU),但是始终无法得到。
  • 线程优先级设置过低,或者其他线程持有锁不释放。

线程饥饿时,无法得到很好地执行,导致系统响应性差。

结合系统优先级描述。

你可能感兴趣的:(Java并发编程,活跃性问题,死锁,活锁,饥饿)