Java多线程之死锁问题,wait和notify

简介: Java多线程之死锁问题,wait和notify

这个博客研究的死锁问题是基于Java当中进行叙述的, 而在Java当中, 与死锁问题息息相关的就是 synchronized关键字了.

一. synchronnized 的特性

1. 互斥性

synchronized 会起到互斥效果, 这里的互斥其实很好理解, 一个线程执行到某个对象的 synchronized 中时, 此时就是针对这个对象加锁了, 而如果此时其他线程如果也想要使用 synchronized 针对同一个对象进行加锁, 就必须等到该对象对象上的锁释放掉才行, 这便是互斥的效果了.


2. 可重入性

同一个线程针对同一个对象, 连续加锁两次, 是否会有问题; 如果没问题, 就是可重入的, 如果有问题, 就是不可重入的.


看下面的代码, 在Java当中是可行的.

class Counter {
    public int count = 0;
    synchronized public void add() {
        synchronized (this) {
            count++;
        }
    }
}

这里的锁对象是this只要有线程调用add, 进入add方法的时候,就会先加锁(能够加锁成功), 紧接着又遇到了代码块, 再次尝试加锁.


站在this的视角(锁对象)它认为自己已经被另外的线程给占用了, 这里的第二次加锁是否要阻塞等待呢? 如果这里的第二次获取锁成功, 这个锁就是可重入的, 如果进入阻塞等待的状态, 就是不可重入的, 此时如果进入了阻塞等待大的状态, 可想而知, 我们的程序就 “僵住了” , 这也就是是一种死锁的情况了.


上面的代码在Java代码中是很容易出现的, 为了避免上面所说情况的出现, Java中 synchronized 就被设置成可重入的了.


synchronized可重入的特性其实就是是在锁对象里面记录一下, 当前的锁是哪个线程持有的, 如果再次加锁的线程和持有线程是同一个, 就可以获取锁, 否则就阻塞等待.

二. 死锁问题

1. 什么是死锁

死锁是指两个或两个以上的进程在执行过程中, 由于竞争资源或者由于彼此通信而造成的一种阻塞的现象, 若无外力作用, 它们都将无法推进下去; 此时称系统处于死锁状态或系统产生了死锁, 这些永远在互相等待的进程称为死锁进程; 通俗点说, 死锁就是两个或者多个相互竞争资源的线程, 你等我, 我等你, 你不放我也不放, 这就造成了他们之间的互相等待, 导致了 “永久” 阻塞.


一旦程序出现死锁, 就会导致线程无法继续执行后续的工作, 程序势必会有严重的bug, 而且是死锁非常隐蔽的, 开发阶段, 不经意间, 就会写出死锁代码, 还不容易测试出来, 所以这就需要我们对死锁问题有一定的认识以方便我们以后的调试和修改.

2. 死锁的四个必要条件

互斥使用: 线程1拿到了锁, 线程2就得进入阻塞状态(锁的基本特性).

不可抢占: 线程1拿到锁之后, 必须是线程1主动释放, 不可能线程1还没有释放, 线程2强行获取到锁.

请求和保持: 线程1拿到锁A后, 再去获取锁B的时候, A这把锁仍然保持, 不会因为要获取锁B就把A释放了.

循环等待: 线程1先获取锁A再获取锁B, 线程2先获取锁B再获取锁A, 线程1在获取锁B的时候等待线程2释放B,同时线程2在获取锁A的时候等待线程1释放A.

而在Java代码中, 前三点 synchronized锁的基本特性, 我们是无法改变的, 循环等待是这四个条件里唯一 一个和代码结构相关的, 是我们可以控制的.

3. 常见的死锁场景及解决

3.1 不可重入造成的死锁

同一个线程针对同一个对象, 连续加锁两次, 如果锁不是可重入锁, 就会造成死锁问题.


最开始介绍synchronized的特性的时候所说, synchronized具有可重入性, 而在Java中还有一个ReentrantLock锁也是可重入锁, 所以说, 在Java程序中, 不会出现这种死锁问题.


3.2 循环等待的场景

哲学家就餐问题(多个线程多把锁)

  • 场景

73d8c9be8b2a4960a39693770de0ac9a.png

有五位沉默的哲学家围坐在一张圆桌旁, 每个哲学家有两种状态.


思考人生(相当于线程的阻塞状态)

拿起筷子吃面条(相当于线程获取到锁然后执行一些计算)

有五只筷子供他们使用, 哲学家需要拿到左手和右手边的两根筷子之后才能吃饭, 吃完后将筷子放下继续思考.


由于操作系统随机调度, 这五个哲学家, 随时都可能想吃面条, 也随时可能要思考人生.


假设出现了极端情况, 同─时刻, 所有的哲学家同时拿起右手的筷子, 哲学家们需要再拿起左手的筷子才可以吃面条, 而此时他们发现没有筷子可以拿了, 都在等左边的哲学家放下筷子, 这里的筷子落实到程序中就相当于锁, 此时就陷入了互相阻塞等待的状态, 这种场景就是典型的因为循环等待造成的死锁问题.

73d8c9be8b2a4960a39693770de0ac9a.png

解决方案

我们可以给按筷子编号, 哲学家们拿筷子时需要遵守一个规则, 拿筷子需要先拿编号小的, 再拿编号大的, 再来看这个场景, 哲学家 2, 3, 4, 5 分别拿起了两手边编号为 1, 2, 3, 4 编号较小的筷子, 而1号哲学家想要拿到编号编号较小的1号筷子发现已经被拿走了, 此时就空出了5号筷子, 这样5号哲学家就可以拿起5号筷子去吃面条了, 等5号哲学家放下筷子后, 4号哲学家就可以拿起4号筷子去吃面条了, 以此类推…

73d8c9be8b2a4960a39693770de0ac9a.png

对应到程序中, 这样的做法其实就是在给锁编号, 然后再按照一个规定好的顺序来加锁, 任意线程加多把锁的时候, 都让线程遵守这个顺序, 这样就解决了互相阻塞等待的问题.


两个线程两把锁

两个线程两把锁, t1, t2线程先各自针对锁A, 锁B加锁, 然后再去获取对方的锁, 此时双方就会陷入僵持状态, 造成了死锁问题.

73d8c9be8b2a4960a39693770de0ac9a.png

这里可以看一下这里举出来的现实中的例子来理解这里的场景:


前段时间疫情还没有放开的时候, 走到哪里都离不开健康码, 某一天这个健康码就给给崩了, 手机上的健康码没办法正常打开了, 于是程序员就赶到公司去修复这个bug, 但是在公司楼下被保安拦住了, 保安要求出示健康码才能上楼, 程序员说: “健康码出问题了, 我上楼修复了才能出示健康码” ; 保安又说: “你出示了健康码才能上楼”; 此时场景就陷入了僵持的状态, 程序员上不了楼, 健康码也无法修复; 这个场景就可以类比这里的锁问题.


观察下面的代码及执行结果:


这里的代码是为了构造一个死锁的场景, 代码中的sleep是为了确保两个线程先把第一个锁拿到, 因为线程是抢占式执行的, 如果没有sleep的作用, 这里的死锁场景是不容易构造出来的.

public class TestDemo14 {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                System.out.println(Thread.currentThread().getName()+"获取到了锁A");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println(Thread.currentThread().getName()+"获取到了锁B");
                }
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            synchronized (B) {
                System.out.println(Thread.currentThread().getName()+"获取到了锁B");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    System.out.println(Thread.currentThread().getName()+"获取到了锁A");
                }
            }
        }, "t2");
        t1.start();
        t2.start();
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

看这里的执行结果, t1线程获取到了锁A但并没有获取到锁B, t2线程获取到了锁B但并没有获取到锁A, 也就是说t1和t2两个线程进入了相互阻塞的状态, 线程无法获去到两把锁, 我们可以使用jconsole工具来观察一下这两个线程的状态, 分析一下是哪里的代码造成这里死锁问题的.


可以发现, t1线程此时是处于BLOCKED状态的, 表示获取锁, 获取不到的阻塞状态; 根据堆栈跟踪的信息反映在代码中是在第14行.

73d8c9be8b2a4960a39693770de0ac9a.png

同样的, t2线程此时也是处于BLOCKED阻塞状态的; 根据堆栈跟踪的信息反映在代码中是在第27行.

73d8c9be8b2a4960a39693770de0ac9a.png

上面叙述的是两个线程死锁问题的代码场景和具体分析, 那么这里的锁问题如何解决呢?


其实也不需要特别复杂的算法, 实际开发中只需要解单高效的解决问题即可, 复杂了反而会使程序容易出bug, 可能会引出新的问题, 就比如上面介绍的哲学家就餐问题通过限制加锁顺序来解决死锁问题就是一种简单高效的解决办法, 而这里也一样, 也可以通过控制加锁的顺序来解决, 我们让t1和t2两个线程都按照相同的顺序来获取锁, 比如这里规定先获取锁A, 再获取锁B, 这样按照相同的顺序去获取锁就避免了循环等待造成的死锁问题, 代码如下:

public class TestDemo14 {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                System.out.println(Thread.currentThread().getName()+"获取到了锁A");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println(Thread.currentThread().getName()+"获取到了锁B");
                }
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            synchronized (A) {
                System.out.println(Thread.currentThread().getName()+"获取到了锁B");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println(Thread.currentThread().getName()+"获取到了锁A");
                }
            }
        }, "t2");
        t1.start();
        t2.start();
    }
}

最后的执行结果两个线程都获取到了A,B锁.

73d8c9be8b2a4960a39693770de0ac9a.png

三. Object类中提供线程等待的方法

1. 常用方法

除了Thread类中的能够实现线程等待的方法, 如join, sleep, 在Object类中也提供了相关线程等待的方法.

方法 解释
public final void wait() throws InterruptedException 释放锁并使线程进入WAITING状态
public final native void wait(long timeout) throws InterruptedException 相比于上面, 多了一个最长等待时间
public final native void notify(); 随机唤醒一个WAITING状态的线程, 并加锁, 搭配wait方法使用
public final native void notifyAll(); 唤醒所有处于WAITING状态的线程, 并加锁(很可能产生锁竞争), 搭配wait方法使用


Thread类中的join和sleep方法定程度上也能控制线程的执行顺序, 但通过join和sleep控制并不够灵活:


使用join, 则必须要t1彻底执行完, t2才能执行; 如果是希望t1先干50%的活, 就让t2开始行动, join就无能为力了.

使用sleep, 指定一个休眠时间的, 但是t1执行的这些任务, 到底花了多少时间, 是不好估计的.

而使用wait和notify可以更好的解决上述的问题.


下面的代码t线程中没有使用synchronized进行加锁, 直接调用了wait方法, 会产生非法锁状态异常.

public class TestDemo15 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("执行完毕!");
        });
        t.start();
        System.out.println("wait前");
        t.wait();
        System.out.println("wait后");
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

之所以这里会抛出这个异常, 是因为wait方法的执行步骤为:


先释放锁

再让线程阻塞等待

最后满足条件后, 重新尝试获取锁, 并在获取到锁后, 继续往下执行

而上面的代码都没有加锁, 又怎么能释放锁锁呢, 所以会抛出异常, 所以说, wait操作需要搭配synchronized来使用.


所以对上面的代码做出如下修改即可,

synchronized (t) {
    System.out.println("wait前");
    t.wait();
    System.out.println("wait后");
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

2. wait和notify的搭配使用

wait方法常常搭配notify方法搭配一起使用, notify方法用来唤醒wait等待的线程, wait能够释放锁, 使线程等待, 而notify唤醒线程后能够获取锁, 然后使线程继续执行, 执行流程如下:

73d8c9be8b2a4960a39693770de0ac9a.png

在Java中, notify方法也需要在加锁前提下使用.

代码示例:

public class TestDemo16 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        Thread t1 = new Thread(() -> {
            // 这个线程负责进行等待
            System.out.println("t1: wait 之前");
            try {
                synchronized (object) {
                    object.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t1: wait 之后");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("t2: notify 之前");
            synchronized (object) {
                // notify 务必要获取到锁, 才能进行通知
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                object.notify();
            }
            System.out.println("t2: notify 之后");
        });
        t1.start();
        // 此处写的 sleep 500 是大概率会让当前的 t1 先执行 wait 的.
        // 极端情况下 (电脑特别卡的时候), 可能线程的调度时间就超过了 500 ms
        // 还是可能 t2 先执行 notify.
        Thread.sleep(500);
        t2.start();
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

注意事项:


虽然这里wait是阻塞了, 阻塞在synchronized代码块里, 实际上, 这里的阻塞是释放了锁的, 此时其他线程是可以获取到object这个对象的锁的, 这里的阻塞,就处在WAITING状态.

73d8c9be8b2a4960a39693770de0ac9a.png

代码中的锁对象和调用wait, notify方法的对象必须是相同的才能够起到应有的效果, notify只能唤醒在同一个对象上等待的线程.

73d8c9be8b2a4960a39693770de0ac9a.png

代码中要保证先执行wait, 后执行notify才是有意义的.

73d8c9be8b2a4960a39693770de0ac9a.png

wait无参数版本, 是一个死等的版本, 只要不进行notify, 就会死等下去, 可以采用wait带参数版本设计代码避免死等可能出现的问题.

3. wait 和 sleep 的区别

相同点

都可以使线程暂停一段时间来控制线程之间的执行顺序.

wait可以设置一个最长等待时间, 和sleep一样都可以提前唤醒.

不同点

wait是Object类中的一个方法, sleep是Thread类中的一个方法.

wait必须在synchronized修饰的代码块或方法中使用, sleep方法可以在任何位置使用.

wait被调用后当前线程进入BLOCK状态并释放锁,并可以通过notify和notifyAll方法进行唤醒;sleep被调用后当前线程进入TIMED_WAIT状态,不涉及锁相关的操作.

使用sleep只能指定一个固定的休眠时间, 线程中执行操作的执行时间是无法确定的; 而使用wait在指定操作位置就可以唤醒线程.

sleep和wait都可以被提前唤醒, interruppt唤醒sleep, 是会报异常的, 这种方式是一个非正常的执行逻辑; 而noitify唤醒wait是正常的业务执行逻辑, 不会有任何异常.

4. 练习: 顺序打印ABC

有三个线程, 分别只能打印A, B, C, 实现代码控制三个线程固定按照ABC的顺序打印.

public class TestdDemo17 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("A");
            synchronized (locker1) {
                locker1.notify();
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("B");
            synchronized (locker2) {
                locker2.notify();
            }
        });
        Thread t3 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    locker2.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("C");
        });
        t2.start();
        t3.start();
        Thread.sleep(100);
        t1.start();
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

目录
相关文章
|
5天前
|
数据采集 存储 Java
高德地图爬虫实践:Java多线程并发处理策略
高德地图爬虫实践:Java多线程并发处理策略
|
1天前
|
消息中间件 监控 安全
【JAVAEE学习】探究Java中多线程的使用和重点及考点
【JAVAEE学习】探究Java中多线程的使用和重点及考点
|
2天前
|
Java 调度 Windows
【Java EE】多线程(一)
【Java EE】多线程(一)
|
5天前
|
监控 Java 调度
Java多线程实战-从零手搓一个简易线程池(四)线程池生命周期状态流转实现
Java多线程实战-从零手搓一个简易线程池(四)线程池生命周期状态流转实现
|
5天前
|
设计模式 Java
Java多线程实战-从零手搓一个简易线程池(三)线程工厂,核心线程与非核心线程逻辑实现
Java多线程实战-从零手搓一个简易线程池(三)线程工厂,核心线程与非核心线程逻辑实现
|
5天前
|
Java 测试技术
Java多线程实战-从零手搓一个简易线程池(二)线程池实现与拒绝策略接口定义
Java多线程实战-从零手搓一个简易线程池(二)线程池实现与拒绝策略接口定义
|
5天前
|
存储 安全 Java
Java多线程实战-从零手搓一个简易线程池(一)定义任务等待队列
Java多线程实战-从零手搓一个简易线程池(一)定义任务等待队列
|
5天前
|
消息中间件 缓存 NoSQL
Java多线程实战-CompletableFuture异步编程优化查询接口响应速度
Java多线程实战-CompletableFuture异步编程优化查询接口响应速度
|
5天前
|
存储 消息中间件 Java
Java多线程实战-异步操作日志记录解决方案(AOP+注解+多线程)
Java多线程实战-异步操作日志记录解决方案(AOP+注解+多线程)
|
5天前
|
缓存 Java 测试技术
Java多线程实战-实现多线程文件下载,支持断点续传、日志记录等功能
Java多线程实战-实现多线程文件下载,支持断点续传、日志记录等功能