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

目录
相关文章
|
2天前
|
存储 监控 Java
【Java并发】【线程池】带你从0-1入门线程池
欢迎来到我的技术博客!我是一名热爱编程的开发者,梦想是编写高端CRUD应用。2025年我正在沉淀中,博客更新速度加快,期待与你一起成长。 线程池是一种复用线程资源的机制,通过预先创建一定数量的线程并管理其生命周期,避免频繁创建/销毁线程带来的性能开销。它解决了线程创建成本高、资源耗尽风险、响应速度慢和任务执行缺乏管理等问题。
89 60
【Java并发】【线程池】带你从0-1入门线程池
|
13天前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
72 14
|
16天前
|
安全 Java 程序员
Java 面试必问!线程构造方法和静态块的执行线程到底是谁?
大家好,我是小米。今天聊聊Java多线程面试题:线程类的构造方法和静态块是由哪个线程调用的?构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节有助于掌握Java多线程机制。下期再见! 简介: 本文通过一个常见的Java多线程面试题,详细讲解了线程类的构造方法和静态块是由哪个线程调用的。构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节对掌握Java多线程编程至关重要。
48 13
|
17天前
|
安全 Java 开发者
【JAVA】封装多线程原理
Java 中的多线程封装旨在简化使用、提高安全性和增强可维护性。通过抽象和隐藏底层细节,提供简洁接口。常见封装方式包括基于 Runnable 和 Callable 接口的任务封装,以及线程池的封装。Runnable 适用于无返回值任务,Callable 支持有返回值任务。线程池(如 ExecutorService)则用于管理和复用线程,减少性能开销。示例代码展示了如何实现这些封装,使多线程编程更加高效和安全。
|
1月前
|
缓存 安全 算法
Java 多线程 面试题
Java 多线程 相关基础面试题
|
9月前
|
安全 Java
深入理解Java并发编程:线程安全与性能优化
【2月更文挑战第22天】在Java并发编程中,线程安全和性能优化是两个重要的主题。本文将深入探讨这两个主题,包括线程安全的基本概念,如何实现线程安全,以及如何在保证线程安全的同时进行性能优化。
72 0
|
9月前
|
存储 安全 Java
深入理解Java并发编程:线程安全与锁机制
【5月更文挑战第31天】在Java并发编程中,线程安全和锁机制是两个核心概念。本文将深入探讨这两个概念,包括它们的定义、实现方式以及在实际开发中的应用。通过对线程安全和锁机制的深入理解,可以帮助我们更好地解决并发编程中的问题,提高程序的性能和稳定性。
|
6月前
|
存储 安全 Java
解锁Java并发编程奥秘:深入剖析Synchronized关键字的同步机制与实现原理,让多线程安全如磐石般稳固!
【8月更文挑战第4天】Java并发编程中,Synchronized关键字是确保多线程环境下数据一致性与线程安全的基础机制。它可通过修饰实例方法、静态方法或代码块来控制对共享资源的独占访问。Synchronized基于Java对象头中的监视器锁实现,通过MonitorEnter/MonitorExit指令管理锁的获取与释放。示例展示了如何使用Synchronized修饰方法以实现线程间的同步,避免数据竞争。掌握其原理对编写高效安全的多线程程序极为关键。
90 1
|
7月前
|
安全 Java 开发者
Java并发编程中的线程安全问题及解决方案探讨
在Java编程中,特别是在并发编程领域,线程安全问题是开发过程中常见且关键的挑战。本文将深入探讨Java中的线程安全性,分析常见的线程安全问题,并介绍相应的解决方案,帮助开发者更好地理解和应对并发环境下的挑战。【7月更文挑战第3天】
129 0
|
8月前
|
安全 Java 开发者
Java并发编程中的线程安全策略
在现代软件开发中,Java语言的并发编程特性使得多线程应用成为可能。然而,随着线程数量的增加,如何确保数据的一致性和系统的稳定性成为开发者面临的挑战。本文将探讨Java并发编程中实现线程安全的几种策略,包括同步机制、volatile关键字的使用、以及java.util.concurrent包提供的工具类,旨在为Java开发者提供一系列实用的方法来应对并发问题。
65 0