线程安全--深入探究线程等待机制和死锁问题

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
云数据库 RDS PostgreSQL,集群系列 2核4GB
简介: 线程安全--深入探究线程等待机制和死锁问题

一.线程等待机制

1.什么是线程等待机制

线程等待机制是多线程编程中用于同步线程执行流程的一种技术,它允许一个线程暂停执行(即进入等待状态),直到满足特定条件或其他线程发送一个通知信号为止。在Java以及许多其他支持多线程的语言中,这种机制通常通过以下方式实现:

1.wait() 方法

wait()java.lang.Object 类的一个方法,当在一个对象上调用 wait() 时,当前线程必须首先获得该对象的监视器(即锁)。调用后,线程会释放对象的锁,并进入等待状态,直到被其他线程通过调用 notify()notifyAll() 方法唤醒。

2.notify()notifyAll() 方法

notify() 唤醒在此对象监视器上等待的一个单个线程。notifyAll() 唤醒在此对象监视器上等待的所有线程。

2.wait() 方法和join()方法和sleep()方法的区别

我们都知道wait() 方法和join()方法和sleep()方法都是控制线程行为的方法那么它们之间有什么区别吗?

  1. wait() 方法
  • 属于 java.lang.Object 类的方法,必须在 synchronized 代码块或方法中调用,因为它依赖于对象的监视器(锁)。
  • 当调用 wait() 时,当前线程会释放所持有的对象监视器(锁),并进入等待状态,直到其他线程调用同一对象上的 notify()notifyAll() 方法将其唤醒。
  • wait() 方法主要用于线程间同步与通信,它使得线程能够有条件地等待资源就绪。
  1. join() 方法
  • 属于 java.lang.Thread 类的实例方法,用于让当前线程等待调用该方法的目标线程终止。
  • 当调用 t.join() 时,当前线程将阻塞,直到线程 t 完成它的任务。
  • join() 有助于实现线程间的顺序执行,比如主线程等待子线程完成后再继续执行。
  1. sleep() 方法
  • 也是 java.lang.Thread 类的静态方法,但它并不涉及线程间的交互和同步。
  • sleep(long millis) 会让当前线程暂时停止执行一段时间,这段时间内线程不会消耗CPU资源,但仍保持“活着”的状态。
  • sleep() 方法调用期间,线程不会释放已经获取的任何锁资源。
  • 主要用于模拟延迟或防止繁忙循环消耗过多CPU资源。

总结起来:

1.wait() 是一种协调机制,用于线程间通信和同步,会释放锁并阻塞线程直到收到通知。

2.join() 用于线程顺序控制,让一个线程等待另一个线程结束,同样会阻塞当前线程,但不涉及锁的管理。

3.sleep() 是简单的线程暂停机制,仅影响单个线程的行为,用于暂停一定时间,不涉及线程间的通信和锁的状

3.线程等待机制的代码案例

题目要求:有三个线程,分别只能打印A,B和C要求按顺序打印ABC,打印10次

// 创建一个演示类Demo1
public class Demo1 {
    // 定义一个共享静态变量count,用于记录循环次数
    public static int count;
 
    // 程序主入口
    public static void main(String[] args) throws InterruptedException {
        // 创建一个共享的对象locker作为线程间的同步锁
        Object locker = new Object();
 
        // 创建线程t1,循环10次,每次检查count是否能被3整除,不能则等待,能则输出当前线程名、增加count并唤醒所有等待线程
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                synchronized (locker) { // 对locker对象进行同步
                    while (count % 3 != 0) { // 如果count不是3的倍数
                        try {
                            locker.wait(); // 当前线程等待,释放locker锁
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e); // 处理中断异常
                        }
                    }
                    System.out.print(Thread.currentThread().getName()); // 输出当前线程名
                    count++; // 增加count值
                    locker.notifyAll(); // 唤醒所有等待locker锁的线程
                }
            }
        }, "A");
 
        // 创建线程t2,逻辑与t1类似,但检查count是否为3的倍数加1
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                synchronized (locker) {
                    while (count % 3 != 1) {
                        try {
                            locker.wait();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.print(Thread.currentThread().getName());
                    count++;
                    locker.notifyAll();
                }
            }
        }, "B");
 
        // 创建线程t3,逻辑与t1类似,但检查count是否为3的倍数加2
        Thread t3 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                synchronized (locker) {
                    while (count % 3 != 2) {
                        try {
                            locker.wait();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println(Thread.currentThread().getName()); // 输出当前线程名并换行
                    count++;
                    locker.notifyAll();
                }
            }
        }, "C");
 
        // 启动三个线程
        t1.start();
        t2.start();
        t3.start();
 
        // 主线程休眠1秒,以便给其他线程运行机会
        Thread.sleep(1000);
    }
}

输出结果:

在上述代码中,线程 t1t2t3 分别会在满足各自条件时输出相应内容并更新 count 变量。具体过程如下:

  1. t1 获取到 locker 锁后,会检查 count 是否为3的倍数。如果不是,则释放锁并调用 wait() 进入等待状态。此时,t1 不再占用锁,t2t3 就有机会竞争锁。
  2. t2t3 其中之一成功获取锁,并满足自己的条件(即 count 为3的倍数加1或2),则会输出相应的线程名并增加 count 的值,然后调用 notifyAll() 唤醒所有等待 locker 锁的线程。
  3. 被唤醒的线程会重新开始尝试获取锁,并再次进入 while 循环检查条件。即使由于“虚假唤醒”被唤醒,由于采用了 while 循环而非 if,线程也会在未满足条件时继续等待,从而确保了正确的执行逻辑。
  4. 如此反复,直到 count 达到10*3=30,所有线程完成各自的循环,整个程序结束。在此过程中,线程间通过 wait()notifyAll() 实现了同步与协作,确保了线程按序交替执行,并共同维护了对 count 变量的正确更新(count++操作是在锁内部完成的,不会出现内存可见性问题)。

注意:这里还有一个小知识点就是这里的条件判断为什么要用 while 而不是用 if?

1.多线程环境下,即使满足某个条件后调用了 wait() 方法,线程被唤醒时并不能保证条件依然成立。这是因为 notify() notifyAll() 方法只会唤醒一个或所有等待的线程,但并不会立即恢复它们的执行,而是需要重新竞争锁资源。如果在当前线程被唤醒并重新获取锁之前,有其他线程改变了共享资源(如 count 变量),那么之前满足的条件可能不再满足。

2.为了确保线程安全,在进入临界区后使用 while 循环不断检查条件是一种最佳实践,这被称为“循环等待-通知”模式。只有当条件确实满足时,线程才会退出循环并执行后续操作,否则将继续等待,直到其他线程改变条件并再次唤醒它。这样可以防止出现“虚假唤醒”的问题,提高程序的健壮性。

3.当线程调用 wait() 方法时,它会释放锁并进入等待状态,直到被其他线程唤醒或者等待超时。然而,操作系统可能会出于效率或者其他因素(例如定时器精度、系统调度策略等)无预期地唤醒等待中的线程。尽管这种情况在实践中并不常见,但它是合法的,标准并未禁止此类行为。

为了避免虚假唤醒导致的错误逻辑执行,编程时推荐采用循环检查条件的方式来调用 wait() 方法,而不是简单地使用 if 判断之后立即调用 wait()

4.为什么要使用wait()方法和notify()方法

使用 wait()notify() 方法的必要性和应用场景主要包括以下几点:

  1. 必要性
  • 线程同步与协作:在多线程环境中,当多个线程共享资源并且需要按照某种特定顺序或条件进行操作时,wait()notify() 方法是必要的。例如,在生产者-消费者问题中,生产者线程需要在缓冲区满时等待,消费者线程在缓冲区空时等待,两者都需要对方操作后才能继续执行,这就需要用到 wait()notify() 来进行线程间的有效沟通。
  • 避免资源竞争与死锁:通过适时地释放锁并进入等待状态,线程能够有效地避免资源的竞争,减少死锁发生的可能性。
  • 性能优化:相比不断地轮询检查条件是否满足(称为“忙等待”),wait() 方法可以让线程在等待条件变化时释放CPU,从而节省系统资源。
  1. 应用场景
  • 生产者-消费者模型:生产者线程负责生成数据放入共享容器,当容器满时,生产者调用 wait() 方法等待;消费者线程从容器中取出数据消费,当容器空时,消费者调用 wait() 方法等待。一旦数据产生或消耗,对应的线程会调用 notify() 方法唤醒等待的线程。
  • 资源池管理:在资源池达到最大限制时,请求新资源的线程会被阻塞,直到有线程归还资源后通过 notify() 触发等待线程继续执行。
  • 信号量控制:在复杂的并发场景中,可以通过 wait()notify() 控制多个线程在多个资源之间按需分配和回收。

总之,wait()notify() 方法是在多线程编程中实现高效、安全线程间通信的核心工具,它们解决了线程间的同步问题,使得不同线程可以根据预设条件有序地进行工作,极大地提高了程序设计的灵活性和并发效率。不过需要注意的是,使用这两个方法时应当遵循特定的准则,如必须在同步代码块或同步方法中调用,同时还要警惕死锁和其他并发问题的发生。在现代Java编程中,java.util.concurrent 包提供的高级并发工具(如 SemaphoreBlockingQueue(阻塞队列)CountDownLatch 等)往往更加易于理解和使用,但了解底层的 wait()notify() 工作原理仍然具有重要意义。

二.死锁(面试常考)

1.什么是死锁

死锁是指在多线程或多进程环境下,两个或多个进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都无法向前推进(即完成各自的任务)。具体地说,每个进程都至少占有一个资源,并且正在等待另一个进程中占有的资源被释放,然而这个资源又正被等待它释放资源的进程所占有,形成了一个环形等待链,这样每个进程都在等待别的进程释放资源,从而陷入了一个永久的停滞状态。

2.MySql的死锁问题(也是面试常考)

这里既然提到了死锁问题,博主就顺便也简单说明一下MySql的死锁问题吧

MySQL数据库中的死锁(Deadlock)指的是两个或多个事务在执行过程中,由于对相同资源请求不同的锁定顺序,彼此互相等待对方释放锁定资源,从而形成的一种循环等待状态,导致事务无法正常执行下去。

MySQL中的死锁主要发生在并发事务处理过程中,当两个或多个事务尝试以不同的顺序锁定相同的资源时可能发生。例如:

  • 事务A锁定了记录R1,并试图锁定记录R2;
  • 与此同时,事务B已经锁定了记录R2,并尝试锁定记录R1;
  • 由于事务A持有R1的锁,所以事务B无法获得R1的锁,而事务B持有R2的锁,事务A也无法获得R2的锁;
  • 因此,两个事务都进入了等待对方释放资源的状态,形成了死锁。

MySQL处理死锁的方式包括:

  1. 自动检测与解决: MySQL的InnoDB存储引擎具有死锁检测机制,定期检查是否存在死锁,并在检测到死锁时自动回滚其中一个事务,使其他事务得以继续执行。
  2. 预防死锁
  • 设计应用程序时确保事务尽可能短小,减少事务锁定资源的时间。
  • 确保事务对资源的锁定顺序一致,例如按照相同的索引顺序访问表。
  • 适当设置事务的隔离级别,虽然较低的隔离级别(如读已提交)可以减少死锁的可能性,但也可能导致更多的并发问题(如不可重复读或幻读)。
  1. 手动解决
  • 当遇到死锁时,DBA可以手动分析并决定是否应该杀死其中一个或多个事务,以打破死锁循环。
  1. 应用层处理
  • 在应用程序设计层面,可以通过添加重试机制来应对可能出现的死锁,当事务因死锁被回滚时,应用层可以捕获异常并重新发起事务。
  1. 配置调整
  • 调整数据库相关的参数,如增大innodb_lock_wait_timeout,当等待锁的时间超过设定阈值时,事务将自动回滚,从而避免无限期等待。

综上所述,MySQL中的死锁问题需要结合数据库服务器的内部机制和应用程序的设计来进行综合考虑和处理。

当然,MySQL的死锁问题有很多细节值得深入探讨和扩展,下面是一些额外的点:

1.死锁的检测与处理

  1. 死锁检测算法:InnoDB存储引擎使用了一种基于等待图的死锁检测算法。每当事务请求新的锁时,都会检查是否存在循环等待的情况。如果有,InnoDB会选择牺牲(rollback)一个事务以打破死锁。
  2. innodb_deadlock_detect 参数:MySQL可以通过配置 innodb_deadlock_detect 参数来控制是否启用死锁检测功能。默认情况下,此参数为ON,表示InnoDB会积极检测死锁并在发现死锁时立即回滚一个事务。但是,关闭此选项意味着系统在等待锁超时后才有可能检测到死锁。
  3. innodb_lock_wait_timeout:这是一个影响死锁处理的重要参数,它指定了一个事务在等待锁时可以等待的最大时间(单位秒)。超时后,事务将被回滚并抛出一个错误,客户端可以根据错误代码识别死锁并选择合适的重试策略。

2.死锁的预防策略

  • 严格的事务顺序:在编写事务时,尽量保持固定的资源获取顺序,避免交叉锁定资源引起死锁。
  • 最小化锁定范围:只锁定那些真正需要修改的数据,避免不必要的大范围锁定。
  • 合理设置事务隔离级别:尽管较低的隔离级别减少了死锁的风险,但可能引入其他并发问题。根据实际需求选择合适的事务隔离级别,兼顾并发性能和一致性需求。
  • 及时提交或回滚事务:不要让事务长时间持有锁,特别是在循环中逐条处理大量记录时,应及时提交或回滚事务,释放资源。
  • 使用乐观锁或版本控制:在某些场景下,可以使用乐观锁(如CAS操作)或数据库的行版本控制机制(MVCC),这类机制在一定程度上降低了死锁发生的概率。

3.死锁监控与排查

  • SHOW ENGINE INNODB STATUS:可以查看InnoDB引擎的状态,其中包含有关最近发生的死锁的信息。
  • performance_schema:MySQL的性能模式包含了丰富的监控信息,可以帮助开发者跟踪和诊断死锁的具体情况。
  • 日志记录:通过对MySQL错误日志的监控,可以捕获到死锁相关的错误信息,帮助定位问题。

3.Java中的死锁问题(面试常考)

1.死锁发生的必要条件(缺一不可)

  1. 互斥条件(锁的特性):至少有一个资源是不可共享的,也就是说,一段时间内仅允许一个进程使用。
  2. 持有并等待条件:已经获得了至少一个资源的进程还在等待获取其他资源,而不会释放已持有的资源。
  3. 非抢占条件(锁的特性):资源一旦被分配给一个进程,就不能被强制性地从该进程中收回,只能由进程自身主动释放。
  4. 循环等待条件:存在一个进程-资源的闭环链,每个进程都在等待下一个进程所占用的资源,循环阻塞等待了。

2.出现死锁的几个经典场景(在多线程的环境下)

1.锁顺序不一致:线程A按照顺序先锁定资源A再锁定资源B,而线程B则是先锁定资源B再锁定资源A,当两个线程同时执行时,可能因资源获取顺序不同而导致死锁

Java代码

public class Demo2 {
    public static void main(String[] args) {
        Object lockerA = new Object();
        Object lockerB = new Object();
        Thread A = new Thread(() -> {
            synchronized (lockerA) {
                System.out.println("线程A获取锁A");
                try {
                    Thread.sleep(100); // 模拟处理时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockerB) {
                    System.out.println("线程A获取锁B");
                }
            }
        });
        Thread B = new Thread(() -> {
            synchronized (lockerB) {
                System.out.println("线程B获取锁B");
                try {
                    Thread.sleep(100); // 模拟处理时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockerA) {
                    System.out.println("线程B获取锁A");
                }
            }
 
        });
        A.start();
        B.start();
    }
}

2.资源分配不当:假设线程A持有了资源R1并请求资源R2,线程B持有了资源R2并请求资源R1,这时线程A和线程B都将阻塞,形成死锁。

Java代码:

public class Demo3 {
        // 定义两种资源对象
        static class Resource {
            private String name;
 
            public Resource(String name) {
                this.name = name;
            }
 
            @Override
            public String toString() {
                return "Resource: " + name;
            }
        }
 
        public static void main (String[]args){
            // 创建资源R1和R2
            Resource r1 = new Resource("R1");
            Resource r2 = new Resource("R2");
 
            // 创建线程A和线程B
            Thread threadA = new Thread(() -> {
                synchronized (r1) {
                    System.out.println(Thread.currentThread().getName() + " 获取 " + r1);
                    synchronized (r2) {
                        System.out.println(Thread.currentThread().getName() + " 获取 " + r2);
                    }
                }
            }, "线程A");
 
            Thread threadB = new Thread(() -> {
                synchronized (r2) {
                    System.out.println(Thread.currentThread().getName() + " 获取 " + r2);
                    synchronized (r1) {
                        System.out.println(Thread.currentThread().getName() + " 获取 " + r1);
                    }
                }
            }, "线程B");
 
            // 启动线程
            threadA.start();
            threadB.start();
        }
}

3.经典的哲学家就餐问题:五位哲学家围绕圆桌而坐,每位哲学家有一只手拿筷子,他们需要两只筷子才能就餐。当所有哲学家都拿起左边的筷子后,大家都在等待右边筷子,形成死锁。

4.死锁的解决方法

  1. 预防死锁
  • 资源一次性分配:一次性为进程分配所需的全部资源,这样就可以避免循环等待条件。
  • 资源有序分配:规定所有的进程都按照统一的顺序申请资源,这样可以避免循环等待。
  • 破坏请求和保持条件:要求进程在请求新资源之前释放已有资源,或者在申请资源时不立即锁定资源,而是等到可以一次性获得所有所需资源时才进行锁定。
  • 破坏不可剥夺条件:允许资源抢占,即当一个进程请求资源无法得到满足时,系统可以剥夺已经分配给其他进程但尚未使用的资源。
  1. 避免死锁
  • 动态分配资源时使用银行家算法等策略,系统在分配资源前预先计算是否有足够的资源满足所有进程的安全序列,以防止系统进入不安全状态,从而避免死锁。
  1. 检测死锁
  • 在系统中设置周期性的死锁检测机制,通过算法(如Wait-For Graph算法)来发现死锁。
  • 当检测到死锁时,采取一定的策略进行解除,如选择一个进程取消(回滚或撤销),或者选择资源进行抢占。
  1. 解除死锁
  • 撤销进程:选择一个死锁进程进行回滚,释放其所占用的资源,使其余进程得以继续执行。
  • 资源剥夺:强制从死锁进程手中剥夺部分资源,分配给其他进程,打破循环等待。
  1. 资源超时回收
  • 设置资源请求的超时时间,当请求超出指定时间仍未能获取资源时,释放已持有资源,然后重新发起请求,这样可以避免长期等待和死锁。

每种方法都有其适用场景和局限性,实际应用时需要根据系统的具体情况和性能要求选择合适的方法。在设计并发和多线程程序时,良好的编程实践和精心设计的资源管理策略也是非常重要的,例如尽量减少资源的持有时间、确保资源释放的完整性以及避免不必要的资源竞争。

以上就是关于线程安全问题的所以内容了,感谢你的阅读!


 

 


相关实践学习
如何在云端创建MySQL数据库
开始实验后,系统会自动创建一台自建MySQL的 源数据库 ECS 实例和一台 目标数据库 RDS。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助 &nbsp; &nbsp; 相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
26天前
|
存储 监控 安全
深入理解ThreadLocal:线程局部变量的机制与应用
在Java的多线程编程中,`ThreadLocal`变量提供了一种线程安全的解决方案,允许每个线程拥有自己的变量副本,从而避免了线程间的数据竞争。本文将深入探讨`ThreadLocal`的工作原理、使用方法以及在实际开发中的应用场景。
49 2
|
1月前
|
Java
线程池内部机制:线程的保活与回收策略
【10月更文挑战第24天】 线程池是现代并发编程中管理线程资源的一种高效机制。它不仅能够复用线程,减少创建和销毁线程的开销,还能有效控制并发线程的数量,提高系统资源的利用率。本文将深入探讨线程池中线程的保活和回收机制,帮助你更好地理解和使用线程池。
86 2
|
2月前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
31 1
|
2月前
|
安全 Java 开发者
在多线程编程中,确保数据一致性与防止竞态条件至关重要。Java提供了多种线程同步机制
【10月更文挑战第3天】在多线程编程中,确保数据一致性与防止竞态条件至关重要。Java提供了多种线程同步机制,如`synchronized`关键字、`Lock`接口及其实现类(如`ReentrantLock`),还有原子变量(如`AtomicInteger`)。这些工具可以帮助开发者避免数据不一致、死锁和活锁等问题。通过合理选择和使用这些机制,可以有效管理并发,确保程序稳定运行。例如,`synchronized`可确保同一时间只有一个线程访问共享资源;`Lock`提供更灵活的锁定方式;原子变量则利用硬件指令实现无锁操作。
30 2
|
2月前
|
安全 Java 程序员
【多线程-从零开始-肆】线程安全、加锁和死锁
【多线程-从零开始-肆】线程安全、加锁和死锁
54 0
|
4月前
|
监控 安全 Java
Java多线程调试技巧:如何定位和解决线程安全问题
Java多线程调试技巧:如何定位和解决线程安全问题
143 2
|
3月前
|
安全 Java
LinkedBlockingQueue 是线程安全的,为什么会有两个线程都take()到同一个对象了?
LinkedBlockingQueue 是线程安全的,为什么会有两个线程都take()到同一个对象了?
74 0
|
3月前
|
安全 Java API
Java线程池原理与锁机制分析
综上所述,Java线程池和锁机制是并发编程中极其重要的两个部分。线程池主要用于管理线程的生命周期和执行并发任务,而锁机制则用于保障线程安全和防止数据的并发错误。它们深入地结合在一起,成为Java高效并发编程实践中的关键要素。
33 0
|
4月前
探索操作系统中的线程同步机制
【8月更文挑战第31天】在多线程编程领域,理解并实现线程同步是至关重要的。本文通过浅显易懂的语言和生动的比喻,带你走进线程同步的世界,从互斥锁到信号量,再到条件变量,逐步揭示它们在协调线程行为中的作用。我们将一起动手实践,用代码示例加深对线程同步机制的理解和应用。
|
4月前
|
存储 安全 Java
【多线程面试题十七】、如果不使用synchronized和Lock,如何保证线程安全?
这篇文章探讨了在不使用`synchronized`和`Lock`的情况下保证线程安全的方法,包括使用`volatile`关键字、原子变量、线程本地存储(`ThreadLocal`)以及设计不可变对象。