一.线程等待机制
1.什么是线程等待机制
线程等待机制是多线程编程中用于同步线程执行流程的一种技术,它允许一个线程暂停执行(即进入等待状态),直到满足特定条件或其他线程发送一个通知信号为止。在Java以及许多其他支持多线程的语言中,这种机制通常通过以下方式实现:
1.wait()
方法:
wait()
是 java.lang.Object
类的一个方法,当在一个对象上调用 wait()
时,当前线程必须首先获得该对象的监视器(即锁)。调用后,线程会释放对象的锁,并进入等待状态,直到被其他线程通过调用 notify()
或 notifyAll()
方法唤醒。
2.notify()
和 notifyAll()
方法:
notify()
唤醒在此对象监视器上等待的一个单个线程。notifyAll()
唤醒在此对象监视器上等待的所有线程。
2.wait() 方法和join()方法和sleep()方法的区别
我们都知道wait() 方法和join()方法和sleep()方法都是控制线程行为的方法那么它们之间有什么区别吗?
- wait() 方法
- 属于
java.lang.Object
类的方法,必须在synchronized
代码块或方法中调用,因为它依赖于对象的监视器(锁)。 - 当调用
wait()
时,当前线程会释放所持有的对象监视器(锁),并进入等待状态,直到其他线程调用同一对象上的notify()
或notifyAll()
方法将其唤醒。 wait()
方法主要用于线程间同步与通信,它使得线程能够有条件地等待资源就绪。
- join() 方法
- 属于
java.lang.Thread
类的实例方法,用于让当前线程等待调用该方法的目标线程终止。 - 当调用
t.join()
时,当前线程将阻塞,直到线程t
完成它的任务。 join()
有助于实现线程间的顺序执行,比如主线程等待子线程完成后再继续执行。
- 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); } }
输出结果:
在上述代码中,线程 t1
、t2
和 t3
分别会在满足各自条件时输出相应内容并更新 count
变量。具体过程如下:
- 当
t1
获取到locker
锁后,会检查count
是否为3的倍数。如果不是,则释放锁并调用wait()
进入等待状态。此时,t1
不再占用锁,t2
和t3
就有机会竞争锁。 - 若
t2
或t3
其中之一成功获取锁,并满足自己的条件(即count
为3的倍数加1或2),则会输出相应的线程名并增加count
的值,然后调用notifyAll()
唤醒所有等待locker
锁的线程。 - 被唤醒的线程会重新开始尝试获取锁,并再次进入
while
循环检查条件。即使由于“虚假唤醒”被唤醒,由于采用了while
循环而非if
,线程也会在未满足条件时继续等待,从而确保了正确的执行逻辑。 - 如此反复,直到
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()
方法的必要性和应用场景主要包括以下几点:
- 必要性:
- 线程同步与协作:在多线程环境中,当多个线程共享资源并且需要按照某种特定顺序或条件进行操作时,
wait()
和notify()
方法是必要的。例如,在生产者-消费者问题中,生产者线程需要在缓冲区满时等待,消费者线程在缓冲区空时等待,两者都需要对方操作后才能继续执行,这就需要用到wait()
和notify()
来进行线程间的有效沟通。 - 避免资源竞争与死锁:通过适时地释放锁并进入等待状态,线程能够有效地避免资源的竞争,减少死锁发生的可能性。
- 性能优化:相比不断地轮询检查条件是否满足(称为“忙等待”),
wait()
方法可以让线程在等待条件变化时释放CPU,从而节省系统资源。
- 应用场景:
- 生产者-消费者模型:生产者线程负责生成数据放入共享容器,当容器满时,生产者调用
wait()
方法等待;消费者线程从容器中取出数据消费,当容器空时,消费者调用wait()
方法等待。一旦数据产生或消耗,对应的线程会调用notify()
方法唤醒等待的线程。 - 资源池管理:在资源池达到最大限制时,请求新资源的线程会被阻塞,直到有线程归还资源后通过
notify()
触发等待线程继续执行。 - 信号量控制:在复杂的并发场景中,可以通过
wait()
和notify()
控制多个线程在多个资源之间按需分配和回收。
总之,wait()
和 notify()
方法是在多线程编程中实现高效、安全线程间通信的核心工具,它们解决了线程间的同步问题,使得不同线程可以根据预设条件有序地进行工作,极大地提高了程序设计的灵活性和并发效率。不过需要注意的是,使用这两个方法时应当遵循特定的准则,如必须在同步代码块或同步方法中调用,同时还要警惕死锁和其他并发问题的发生。在现代Java编程中,java.util.concurrent
包提供的高级并发工具(如 Semaphore
、BlockingQueue(阻塞队列)
、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处理死锁的方式包括:
- 自动检测与解决: MySQL的InnoDB存储引擎具有死锁检测机制,定期检查是否存在死锁,并在检测到死锁时自动回滚其中一个事务,使其他事务得以继续执行。
- 预防死锁:
- 设计应用程序时确保事务尽可能短小,减少事务锁定资源的时间。
- 确保事务对资源的锁定顺序一致,例如按照相同的索引顺序访问表。
- 适当设置事务的隔离级别,虽然较低的隔离级别(如读已提交)可以减少死锁的可能性,但也可能导致更多的并发问题(如不可重复读或幻读)。
- 手动解决:
- 当遇到死锁时,DBA可以手动分析并决定是否应该杀死其中一个或多个事务,以打破死锁循环。
- 应用层处理:
- 在应用程序设计层面,可以通过添加重试机制来应对可能出现的死锁,当事务因死锁被回滚时,应用层可以捕获异常并重新发起事务。
- 配置调整:
- 调整数据库相关的参数,如增大innodb_lock_wait_timeout,当等待锁的时间超过设定阈值时,事务将自动回滚,从而避免无限期等待。
综上所述,MySQL中的死锁问题需要结合数据库服务器的内部机制和应用程序的设计来进行综合考虑和处理。
当然,MySQL的死锁问题有很多细节值得深入探讨和扩展,下面是一些额外的点:
1.死锁的检测与处理
- 死锁检测算法:InnoDB存储引擎使用了一种基于等待图的死锁检测算法。每当事务请求新的锁时,都会检查是否存在循环等待的情况。如果有,InnoDB会选择牺牲(rollback)一个事务以打破死锁。
- innodb_deadlock_detect 参数:MySQL可以通过配置
innodb_deadlock_detect
参数来控制是否启用死锁检测功能。默认情况下,此参数为ON,表示InnoDB会积极检测死锁并在发现死锁时立即回滚一个事务。但是,关闭此选项意味着系统在等待锁超时后才有可能检测到死锁。 - innodb_lock_wait_timeout:这是一个影响死锁处理的重要参数,它指定了一个事务在等待锁时可以等待的最大时间(单位秒)。超时后,事务将被回滚并抛出一个错误,客户端可以根据错误代码识别死锁并选择合适的重试策略。
2.死锁的预防策略
- 严格的事务顺序:在编写事务时,尽量保持固定的资源获取顺序,避免交叉锁定资源引起死锁。
- 最小化锁定范围:只锁定那些真正需要修改的数据,避免不必要的大范围锁定。
- 合理设置事务隔离级别:尽管较低的隔离级别减少了死锁的风险,但可能引入其他并发问题。根据实际需求选择合适的事务隔离级别,兼顾并发性能和一致性需求。
- 及时提交或回滚事务:不要让事务长时间持有锁,特别是在循环中逐条处理大量记录时,应及时提交或回滚事务,释放资源。
- 使用乐观锁或版本控制:在某些场景下,可以使用乐观锁(如CAS操作)或数据库的行版本控制机制(MVCC),这类机制在一定程度上降低了死锁发生的概率。
3.死锁监控与排查
- SHOW ENGINE INNODB STATUS:可以查看InnoDB引擎的状态,其中包含有关最近发生的死锁的信息。
- performance_schema:MySQL的性能模式包含了丰富的监控信息,可以帮助开发者跟踪和诊断死锁的具体情况。
- 日志记录:通过对MySQL错误日志的监控,可以捕获到死锁相关的错误信息,帮助定位问题。
3.Java中的死锁问题(面试常考)
1.死锁发生的必要条件(缺一不可)
- 互斥条件(锁的特性):至少有一个资源是不可共享的,也就是说,一段时间内仅允许一个进程使用。
- 持有并等待条件:已经获得了至少一个资源的进程还在等待获取其他资源,而不会释放已持有的资源。
- 非抢占条件(锁的特性):资源一旦被分配给一个进程,就不能被强制性地从该进程中收回,只能由进程自身主动释放。
- 循环等待条件:存在一个进程-资源的闭环链,每个进程都在等待下一个进程所占用的资源,循环阻塞等待了。
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.死锁的解决方法
- 预防死锁:
- 资源一次性分配:一次性为进程分配所需的全部资源,这样就可以避免循环等待条件。
- 资源有序分配:规定所有的进程都按照统一的顺序申请资源,这样可以避免循环等待。
- 破坏请求和保持条件:要求进程在请求新资源之前释放已有资源,或者在申请资源时不立即锁定资源,而是等到可以一次性获得所有所需资源时才进行锁定。
- 破坏不可剥夺条件:允许资源抢占,即当一个进程请求资源无法得到满足时,系统可以剥夺已经分配给其他进程但尚未使用的资源。
- 避免死锁:
- 动态分配资源时使用银行家算法等策略,系统在分配资源前预先计算是否有足够的资源满足所有进程的安全序列,以防止系统进入不安全状态,从而避免死锁。
- 检测死锁:
- 在系统中设置周期性的死锁检测机制,通过算法(如Wait-For Graph算法)来发现死锁。
- 当检测到死锁时,采取一定的策略进行解除,如选择一个进程取消(回滚或撤销),或者选择资源进行抢占。
- 解除死锁:
- 撤销进程:选择一个死锁进程进行回滚,释放其所占用的资源,使其余进程得以继续执行。
- 资源剥夺:强制从死锁进程手中剥夺部分资源,分配给其他进程,打破循环等待。
- 资源超时回收:
- 设置资源请求的超时时间,当请求超出指定时间仍未能获取资源时,释放已持有资源,然后重新发起请求,这样可以避免长期等待和死锁。
每种方法都有其适用场景和局限性,实际应用时需要根据系统的具体情况和性能要求选择合适的方法。在设计并发和多线程程序时,良好的编程实践和精心设计的资源管理策略也是非常重要的,例如尽量减少资源的持有时间、确保资源释放的完整性以及避免不必要的资源竞争。
以上就是关于线程安全问题的所以内容了,感谢你的阅读!