不要问是什么梗(不要问我是什么歌曲) -凯发网娱乐
最佳答案
前言
hello 大家好,我是bug哥,死锁问题是面试中出现频率很高的一个问题,今天『面试の神』系列就来讲一讲死锁。
背景线程同步是克服多线程程序中竞争条件的好工具。但是,它也有阴暗的一面:死锁,难以发现、重现和修复的严重错误。防止它们发生的唯一可靠方法是正确设计您的代码,这是本文的主题。我们将看看死锁的起源,找到一种发现现有代码中潜在死锁的方法,并提出设计无死锁同步的实用方法。
假设读者已经熟悉多线程编程,并且对 java 中的线程同步原语有很好的理解。在接下来的部分中,我们不会区分同步语句和锁 api,使用术语“锁”来表示这两种类型的可重入锁。
死锁机制现在有以下两种方法:
void increment() { synchronized(lock1) { synchronized(lock2) { variable ; } }}void decrement() { synchronized(lock2) { synchronized(lock11) { variable--; } }}
这两个方法被有意设计为产生死锁,以便于我们能详细考虑这是如何发生的。
increment() 和 decrement() 基本上都由以下 5 个步骤组成:
step
increment()
decrement()
1
acquire lock1
acquire lock2
2
acquire lock2
acquire lock1
3
perform increment
perform decrement
4
release lock2
release lock1
5
release lock1
release lock2
显然,这两种方法中的第 1 步和第 2 步只有在相应的锁空闲时才能通过,否则,执行线程将不得不等待它们的释放。
假设有两个并行线程,一个执行 increment(),另一个执行 decrement()。每个线程的步骤会按正常顺序执行,但是,如果我们将两个线程放在一起考虑,一个线程的步骤将与另一个线程的步骤随机交错。随机性来自系统线程调度程序强加的不可预测的延迟。可能的交织模式或场景非常多并且可以分为两组。第一组是其中一个线程足够快以获取两个锁的地方。
比如下面的表格:
no deadlock
thread-1
thread-2
result
1: acquire lock1
lock1 busy
2: acquire lock2
lock2 busy
1: acquire lock2
wait for lock2 release
3: perform increment
waiting at lock2
4: release lock2
intercept lock2
lock2 changed owner
2: acquire lock1
wait for lock1 release
5: release lock1
intercept lock1
lock1 changed owner
3: perform decrement
4: release lock1
lock1 free
5: release lock2
lock2 free
该组中的所有案例均成功完成。
在第二组中,两个线程都成功获取了锁。结果见下表:
deadlock
thread-1
thread-2
result
1: acquire lock1
lock1 busy
1: acquire lock2
lock2 busy
2: acquire lock2
wait for lock2 release
2: acquire lock1
wait for lock1 release
waiting at lock2
waiting at lock1
…
…
该组中的所有情况都会导致第一个线程等待第二个线程拥有的锁,而第二个线程等待第一个线程拥有的锁的情况,因此两个线程都无法进一步进行:
这是一个典型的死锁情况。它至少满足一下3点:
至少有两个线程,每个线程至少占用两个锁。死锁只发生在特定的线程时序组合中。死锁的发生取决于锁定顺序。第二个属性意味着死锁不能随意重现。此外,它们的再现性取决于操作系统、cpu 频率、cpu 负载和其他因素。后者意味着软件测试的概念不适用于死锁,因为相同的代码可能在一个系统上完美运行而在另一个系统上失败。
因此,交付正确应用程序的唯一方法是通过设计消除死锁。这种设计有两种基本方法,现在,让我们从更简单的方法开始。
粗粒度同步如果我们的应用程序中的任何线程都不允许同时持有多个锁,则不会发生死锁。但是我们应该使用多少锁以及将它们放在哪里?
最简单和最直接的答案是用一个锁保护所有事务。例如,为了保护一个复杂的数据对象,您可以将其所有公共方法声明为同步的。 java.util.hashtable 中使用了这种方法。简单的代价是由于缺乏并发而导致的性能损失,因为所有方法都是相互阻塞的。
幸运的是,在许多情况下,粗粒度同步可以以较少限制的方式执行,从而允许一些并发和更好的性能。为了解释它,我们应该引入一个事务连接变量的概念。假设如果满足两个条件中的任何一个,则两个变量在事务上连接:
存在涉及两个变量的交易。两个变量都连接到第三个变量(传递性)。因此,您首先以这样一种方式对变量进行分组,即同一组中的任何两个变量在事务上都是连接的,而不同组中的任何两个变量都没有。然后通过单独的专用锁保护每个组:
这种粗粒度同步的一个很好的现实例子是 java.util.concurrent.concurrenthashmap。在这个对象内部,有许多相同的数据结构(“桶”),每个桶都由自己的锁保护。事务被分派到由键的哈希码确定的存储桶。因此,具有不同键的交易大多会进入不同的存储桶,这使得它们可以并发执行而不会牺牲线程安全性,由于存储桶的事务独立性,这是可能的。
死锁分析假设需要确定给定的代码是否包含潜在的死锁。我们称这种任务为“同步分析”或“死锁分析”。我们要如何处理这个问题?
最有可能的是,我们会尝试对线程争用锁的所有可能场景进行排序,试图找出是否存在不良场景。在死锁机制一节中,我们采用了最简单的方法,结果发现场景太多了。即使在最简单的情况下,也有 252 个,因此彻底检查它们是不可能的。在实践中,您可能最终只会考虑几个场景,并希望自己没有遗漏一些重要的事情。换句话说,公平的死锁分析无法通过初级的方法完成,我们需要一种专门的、更有效的方法。
锁定图此方法包括构建锁定图并检查它是否存在循环依赖关系。锁定图是显示锁和线程在这些锁上的交互的图形。此类图中的每个闭环都表示可能存在死锁,并且没有闭环保证了代码的死锁安全性。
如何画锁定图?我们以死锁机制一节中的代码为例:
对于代码中的每个锁,在图表上放置一个相应的节点;在示例中,这些是 lock1 和 lock2对于所有线程试图在已经持有锁 a 的情况下获取锁 b 的语句,画一个从节点 a 到节点 b 的箭头;在这个例子中, increment() 中有 lock1 -> lock2, decrement() 中有 lock2 -> lock1。如果一个线程按顺序使用多个锁,则为每两个连续的锁绘制一个箭头。这里形成了一个闭环:lock1 -> lock2 -> lock1,告诉我们代码包含潜在的死锁。
更复杂的例子void transaction1(int amount) { synchronized(lock1) { synchronized(lock2) { // do something; } }}void transaction2(int amount) { synchronized(lock2) { synchronized(lock13) { // do something; } }}void transaction3(int amount) { synchronized(lock3) { synchronized(lock11) { // do something; } }}
让我们看看这段代码是否是死锁安全的。有3个锁:lock1、lock2、lock3和3条锁路径:lock1 -> lock2 in transaction1(),lock2 -> lock3 in transaction2(),lock3 -> lock1 in transaction3()。
绘制锁定图如下:
同样,图 a 表明我们的设计包含潜在的死锁。但是,不仅如此。它还提示我们如何修复设计;我们只需要打破循环!例如,我们可以在方法 transaction3() 中交换锁。相应的箭头改变方向,图 b 中的图变为无循环,保证了固定代码的死锁安全性。
带锁排序的细粒度同步采取尽可能细粒度的同步方式,希望得到最大可能的事务并发度作为回报。这种设计基于两个原则。
第一个原则是禁止任何变量同时参与多个事务。
为了实现这一点,我们将每个变量与一个唯一的锁相关联,并通过获取与相关变量关联的所有锁来启动每个事务。以下代码说明了这一点:
void transaction(item i1, item i2, item i3, double amount) { synchronized(i1.lock) { synchronized(i2.lock) { synchronized(i3.lock) { // do something; } } }}复制代码
一旦获得了锁,其他事务就不能访问这些变量,因此它们不会被并发修改。这意味着系统中的所有事务都是一致的。同时,允许在不相交变量集上的事务并发运行。因此,我们获得了一个高度并发但线程安全的系统。
但是,这样的设计会立即导致死锁的可能性,因为现在我们处理多个线程和每个线程的多个锁。此时,我们就需要用到第二个设计原则。
第二个设计原则是必须以规范的顺序获取锁以防止死锁。
这意味着我们将每个锁与一个唯一的常量索引相关联,并始终按照它们的索引定义的顺序获取锁。将这个原理应用到上面的代码中,我们得到了细粒度设计的完整说明:
void transaction(item i1, item i2, item i3, double... amounts) { // 使用item的id属性作为锁的索引 item[] order = {i1, i2, i3}; arrays.sort(order, (a,b) -> long.compare(a.id, b.id)); synchronized(order, [0].lock) { synchronized(order, [1].lock) { synchronized(order, [2].lock) { // do something; } } }}
但是,确定规范排序确实可以防止死锁吗?我们能证明吗?答案是肯定的,我们可以使用锁定图来完成。
假设我们有一个有 n 个变量的系统,所以有 n 个关联的锁,因此图中有 n 个节点。如果没有强制排序,锁会以随机顺序被抓取,所以在图中,会有双向随机箭头,并且肯定会存在表示死锁的闭环:
如果我们强制执行锁排序,从高到低索引的锁路径将被排除,所以唯一剩下的箭头将是那些从左到右的箭头:
在上图中我们找不到一个闭环,因为只有当箭头双向流动时,闭环才可能存在,没有闭环意味着没有死锁。
通过使用细粒度锁和锁排序,我们可以构建一个高并发、线程安全和无死锁的系统。但是,提高并发性是否需要付出代价?
首先,在低并发的情况下,与粗粒度的方法相比,存在一定的速度损失。每个锁捕获是一个相当昂贵的操作,但细粒度设计假设锁捕获至少是两倍。但是,随着并发请求数量的增加,由于使用了多个 cpu 内核,细粒度设计很快就会变得更好。
其次,由于大量的锁对象,存在内存开销。幸运的是,这很容易解决。如果受保护的变量是对象,我们可以摆脱单独的锁对象,并将变量本身用作自己的锁。否则,例如如果变量是原始数组元素,我们可能只需要有限数量的额外对象。为此,我们定义了从变量 id 到中等大小的锁数组的映射。在这种情况下,锁必须按它们的实际索引排序,而不是按变量 id。
总结在本文中,我们探讨了多线程编程中的死锁问题。我们发现如果按照一定的设计模式编写同步代码,可以完全避免死锁。我们还研究了此类设计为何以及如何工作,其适用性的限制是什么,以及如何有效地发现和修复现有代码中的潜在死锁。预计所提供的材料为设计完美的无死锁同步提供了足够的实用指南。
最后创作不易,如果觉得这篇文章对您有所帮助,还请多多关注,多多点赞!!感谢!!