Java多线程死锁问题详解(wait和notify)

目录
  • 一. synchronnized 的特性
    • 1. 互斥性
    • 2. 可重入性
  • 二. 死锁问题
    • 1. 什么是死锁
    • 2. 死锁的四个必要条件
    • 3. 常见的死锁场景及解决
      • 3.1 不可重入造成的死锁
      • 3.2 循环等待的场景
  • 三. Object类中提供线程等待的方法
    • 1. 常用方法
    • 2. wait和notify的搭配使用
    • 3. wait 和 sleep 的区别
    • 4. 练习: 顺序打印ABC
  • 总结

一. 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 循环等待的场景

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

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

  1. 思考人生(相当于线程的阻塞状态)
  2. 拿起筷子吃面条(相当于线程获取到锁然后执行一些计算)

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

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

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

解决方案

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

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

两个线程两把锁

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

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

前段时间疫情还没有放开的时候, 走到哪里都离不开健康码, 某一天这个健康码就给给崩了, 手机上的健康码没办法正常打开了, 于是程序员就赶到公司去修复这个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();
    }
}

执行结果:

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

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

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

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

其实也不需要特别复杂的算法, 实际开发中只需要解单高效的解决问题即可, 复杂了反而会使程序容易出bug, 可能会引出新的问题, 就比如上面介绍的哲学家就餐问题通过限制加锁顺序来解决死锁问题就是一种简单高效的解决办法, 而这里也一样, 也可以通过控制加锁的顺序来解决, 我们让t1t2两个线程都按照相同的顺序来获取锁, 比如这里规定先获取锁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锁.

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

1. 常用方法

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

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

我们知道由于线程之间的抢占式执行和操作系统的随机调度会导致线程之间执行顺序是 “随机” 的, 但在实际开发中很多场景下我们是希望可以协调多个线程之间的执行先后顺序的.

虽然线程在内核里的调度是随机的, 这个我们是没办法改变的, 但是我们可以通过一些api让线程主动阻塞, 主动放弃CPU来给别的线程让路, 以此来控制线程之间的执行顺序.

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

  • 使用join, 则必须要t1彻底执行完, t2才能执行; 如果是希望t1先干50%的活, 就让t2开始行动, join就无能为力了.
  • 使用sleep, 指定一个休眠时间的, 但是t1执行的这些任务, 到底花了多少时间, 是不好估计的.

而使用waitnotify可以更好的解决上述的问题.

下面的代码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后");
    }
}

执行结果:

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

  • 先释放锁
  • 再让线程阻塞等待
  • 最后满足条件后, 重新尝试获取锁, 并在获取到锁后, 继续往下执行

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

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

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

执行结果:

2. wait和notify的搭配使用

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

在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();
    }
}

执行结果:

注意事项:

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

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

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

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();
    }
}

执行结果:

总结

到此这篇关于Java多线程死锁问题的文章就介绍到这了,更多相关Java多线程死锁问题内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Java 锁粗化与循环问题

    1. 写在前面 "JVM 解剖公园"是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟.限于篇幅,仅对某个主题按照问题.测试.基准程序.观察结果深入讲解.因此,这里的数据和讨论可以当轶事看,并没有做一致性.写作风格.句法和语义错误.重复或一致性检查.如果选择采信文中内容,风险自负. Aleksey Shipilёv,JVM 性能极客 推特 @shipilev 问题.评论.建议发送到 aleksey@shipilev.net 译注:锁粗化(Lock Coarsening).锁

  • Java锁擦除与锁粗化概念和使用详解

    目录 一.什么是锁擦除 二.锁擦除的演示 三.什么是锁粗化 四.锁粗化的演示 一.什么是锁擦除 锁擦除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行擦除.锁擦除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行. 二.锁擦除的演示 public class LockErasureDemo { publi

  • Java多线程死锁问题详解(wait和notify)

    目录 一. synchronnized 的特性 1. 互斥性 2. 可重入性 二. 死锁问题 1. 什么是死锁 2. 死锁的四个必要条件 3. 常见的死锁场景及解决 3.1 不可重入造成的死锁 3.2 循环等待的场景 三. Object类中提供线程等待的方法 1. 常用方法 2. wait和notify的搭配使用 3. wait 和 sleep 的区别 4. 练习: 顺序打印ABC 总结 一. synchronnized 的特性 1. 互斥性 synchronized 会起到互斥效果, 这里的互

  • Java多线程ForkJoinPool实例详解

    引言 java 7提供了另外一个很有用的线程池框架,Fork/Join框架 理论 Fork/Join框架主要有以下两个类组成. * ForkJoinPool 这个类实现了ExecutorService接口和工作窃取算法(Work-Stealing Algorithm).它管理工作者线程,并提供任务的状态信息,以及任务的执行信息 * ForkJoinTask 这个类是一个将在ForkJoinPool执行的任务的基类. Fork/Join框架提供了在一个任务里执行fork()和join()操作的机制

  • Java 多线程优先级实例详解

    Java 多线程优先级实例详解 线程的优先级将该线程的重要性传递给调度器.尽管CPU处理现有线程集的顺序是不确定的,但是调度器将倾向于让优先权最高的线程先执行. 你可以用getPriority()来读取现有线程的优先级,并且在任何时刻都可以通过setPriority()来修改优先级. import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SimplePrio

  • java多线程编程技术详解和实例代码

     java多线程编程技术详解和实例代码 1.   Java和他的API都可以使用并发. 可以指定程序包含不同的执行线程,每个线程都具有自己的方法调用堆栈和程序计数器,使得线程在与其他线程并发地执行能够共享程序范围内的资源,比如共享内存,这种能力被称为多线程编程(multithreading),在核心的C和C++语言中并不具备这种能力,尽管他们影响了JAVA的设计. 2.   线程的生命周期 新线程的生命周期从"新生"状态开始.程序启动线程前,线程一直是"新生"状态:

  • JAVA 多线程爬虫实例详解

    JAVA 多线程爬虫实例详解 前言 以前喜欢Python的爬虫是出于他的简洁,但到了后期需要更快,更大规模的爬虫的时候,我才渐渐意识到Java的强大.Java有一个很好的机制,就是多线程.而且Java的代码效率执行起来要比python快很多.这份博客主要用于记录我对多线程爬虫的实践理解. 线程 线程是指一个任务从头至尾的执行流.线程提供了运行一个任务的机制.对于Java而言,可以在一个程序中并发地启动多个线程.这些线程可以在多处理器系统上同时运行. runnable接口 任务类必须实现runna

  • java多线程中断代码详解

    一.java中终止线程主要有三种方法: ①线程正常退出,即run()方法执行完毕了 ②使用Thread类中的stop()(已过期不推荐使用)方法强行终止线程. ③使用中断机制 t.stop()调用时,终止线程,会导致该线程所持有的锁被强制释放,从而被其他线程所持有,因此有可能导致与预期结果不一致.下面使用中断信号量中断非阻塞状态的线程中: public class TestStopThread { public static void main(String[] args) throws Int

  • Java多线程同步器代码详解

    同步器 为每种特定的同步问题提供了解决方案,同步器是一些使线程能够等待另一个线程的对象,允许它们协调动作.最常用的同步器是CountDownLatch和Semaphore,不常用的是Barrier 和Exchanger Semaphore Semaphore[信号标:旗语],通过计数器控制对共享资源的访问. 测试类: package concurrent; import concurrent.thread.SemaphoreThread; import java.util.concurrent.

  • JAVA多线程CountDownLatch使用详解

    前序: 上周测试给开发的同事所开发的模块提出了一个bug,并且还是偶现. 经过仔细查看代码,发现是在业务中启用了多线程,2个线程同时跑,但是新启动的2个线程必须保证一个完成之后另一个再继续运行,才能消除bug. 什么时候用? 多线程是在很多地方都会用到的,但是我们如果想要实现在某个特定的线程运行完之后,再启动另外一个线程呢,这个时候CountDownLatch就可以派上用场了 怎么用? 先看看普通的多线程代码: package code; public class MyThread extend

  • JAVA多线程编程实例详解

    本文实例讲述了JAVA多线程编程.分享给大家供大家参考,具体如下: 进程是系统进行资源调度和分配的一个独立单位. 进程的特点 独立性:进程是系统中独立存在的实体,拥有自己的独立资源和私有空间.在没有经过进程本身允许的情况下,不能直接访问其他进程. 动态性:进程与程序的区别在于,前者是一个正在系统中活动的指令,而后者仅仅是一个静态的指令集合 并发性:多个进程可以在单个处理器上并发执行,而不受影响. 并发性和并行性的区别: 并行性:在同一时刻,有多条指令在多个处理器上同时执行(多个CPU) 并发性:

  • java多线程下载实例详解

    本文实例讲述了java多线程下载.分享给大家供大家参考,具体如下: 使用多线程下载文件可以更快完成文件的下载,多线程下载文件之所以快,是因为其抢占的服务器资源多.如:假设服务器同时最多服务100个用户,在服务器中一条线程对应一个用户,100条线程在计算机中并非并发执行,而是由CPU划分时间片轮流执行,如果A应用使用了99条线程下载文件,那么相当于占用了99个用户的资源,假设一秒内CPU分配给每条线程的平均执行时间是10ms,A应用在服务器中一秒内就得到了990ms的执行时间,而其他应用在一秒内只

随机推荐