您的位置 首页 java

Java高级编程基础:深入理解死锁原理,真正发挥并发编程的优势

前言

我们平时采用并发编程的方式处理一个问题时,经常会遇到不同的 线程 相互之间被阻塞的问题。

其结果表现就是我们的应用程序执行速度变慢,甚至停滞,还有就是有些线程可能长时间都得不到资源,无法执行。造成了根本无法达到我们当初使用并发方式编程处理的预期效果,如此也就没有了使用并发编程带来的优势,所以如何避免线程之间相互阻塞问题是我们并发编程开发必须面对的一个非常关键的问题。

死锁 现象

死锁这个名词,对我们开发人员来说肯定是很熟悉的词了。虽然熟悉吧,但在我们平时开发过程中好像也不是经常能碰到。所以,处理死锁的经验一般也不是特别多。

我们前面说过,当多个线程共享操作同一个共享资源时,JVM一般会为每个线程的处理添加一个锁,也就是堆访问处理部分代码进行同步。

每个线程都会先获取要访问的资源的锁,然后才能对其进行独占式的操作,结束后将锁释放,其它线程才能获取该锁来操作该资源。

那么到底什么是死锁呢?

其实严格来说,死锁就是两个或者两个以上的线程在彼此等待对方释放资源锁,也就是说一个线程本身自己锁定了一个资源,而这个资源正被另一个资源等待访问,而这个线程又在等待对方释放它锁定的资源访问权。

比如线程1执行的代码正锁定资源A访问,并等待资源B的锁被释放。而线程2正锁定B资源而等待资源A被释放,如此就会造成线程1和线程2相互等待对方释放资源访问权而阻塞,这就是死锁。

为了展示死锁现象,我们设计了如下示例代码,我们假设一个场景,有两个独立的线程共同去访问一个共享的随机数资源,然后设定一个执行足够多次数这里设定为10000次,使用Random的nextBoolean()函数来获取均等的true或者 false 值,以调节是先锁定访问哪个资源不管是resource1还是resource2,让它们被线程首先锁定的次数均匀分布。

图 2-1

图 2-2

如果布尔变量b为ture,则线程首先锁定resource1,然后线程尝试获取resource2的锁。如果布尔变量b为false,线程首先锁定resource2,然后尝试锁定resource1。

执行该程序等待死锁出现,也就是程序被挂起,无法执行完循环设定的次数,只能等待我们手动终止它。

发生死锁

在这个执行过程中,thread-1持有resource2的锁并等待resource1上的锁,而thread-2持有resource1的锁并等待resource2。

Thread 1 死锁栈

Thread 2 死锁栈

如果我们将上面示例代码中的布尔变量b设置为固定的true或false值,不让布尔变量b的值在true和false之间均等的切换,我们将不会遇到任何死锁,因为thread-1和thread-2请求锁的顺序总是相同的。

相同的顺序

如此就会出现两个线程中的一个首先获得锁,然后请求第二个锁,而剩余线程不会占用第二个锁,而是也在等待第一个锁,所以第二个锁仍然可用。

死锁未发生

死锁条件

我们来总结一下形成死锁需要具备的条件,一般来说,死锁需要具备的要求如下:

  • 首先,资源访问的互斥执行,也就是说一个资源在任何时候都只能由一个线程访问。
  • 其次,出现资源占用,即一个线程在锁定了一个资源后,试图获取其他被独占资源上的另一个锁。
  • 再者,这种锁定是不可被剥夺的,也就是说如果一个线程持有锁一段特定的时间,且没有释放资源的机制。
  • 最后,出现循环等待,在运行时出现一组线程,其中两个(或多个)线程都在等待另一个线程释放它锁定的资源。

从上面条件来看,要产生死锁需要具备的条件其实很多,但在更高级的 多线程 应用程序出现死锁问题还是非常常见的。

为了避免死锁现象的出现,我们应当尽量的减少上面条件中其中任意一个的出现几率。

但像资源互斥这一条,这是一个经常不能不可消除的条件,因为资源必须被专用。当然情况也不一定总是这样的,我们的DBMS系统中,就可以采用一种被称为乐观锁定的技术来降低互斥条件,而不用对某些必须更新的表行使用悲观锁独占。

在等待另一个独占资源时避免资源占用的一种可能解决方案是在算法开始时锁定所有必要的资源,如果发现无法获得所有锁,则释放所有资源。

当然,这种情况不是所有场景都适合的,会出现可能锁定的资源事先是未知的,造成浪费资源。

建立锁定的剥夺机制,比如如果线程不能立即获得锁,则引入超时是避免死锁的产生。 Java 开发中类ReentrantLock就提供了指定锁定超时的参数。

正如我们从上面的示例代码中看到的,如果不同线程之间的锁请求序列都一样,就不会出现死锁。

如果我们能够将所有锁定代码放在一个方法中,并且所有线程都必须依次通过该方法,那么就可以轻松地控制它,而不会出现死锁了。

在开发更高级的并发应用程序中,我们甚至可以考虑创建独立的死锁检测系统来防止死锁出现。

在死锁检测系统中,我们需要实现某种类型的线程监视,其中每个线程要报告锁的成功获取及其获取锁的尝试。

如果线程和锁被建模为一个有序图,那么我们就能够检测两个不同的线程何时持有资源,同时请求另一个阻塞的资源。

然后找到那些被阻塞线程,强制它们释放获得的资源,如此就能够自动解决死锁情况。

线程饿死

我们在开发多线程程序时,会解决死锁或者保证其它重要线程执行时,会考虑为线程设置不同的优先级。从而让高优先级的线程得到执行,甚至可以

抢夺资源执行。如此以来就可能造成另外一个现象就是由于线程优先级的设定造成部分优先级较低的线程始终无法被执行。

原因在于根据优先级设定来分配CPU执行的时间,平时很多我们看上去合理的特性在被滥用时也会导致这种问题。

如果大多数高优先级的线程被执行,那么低优先级的线程久只能长时间等待,没有足够的时间来完整的执行它们的工作,就会出现“饿死”现象。

建议只有在有充分理由时才设置线程的优先级,否则尽量的少去认为设定线程优先级。

我们编程中常见的一个复杂的线程饿死示例是finalize()方法。Java语言通常用这个特性在对象被垃圾收集之前执行一些关闭资源代码。但是,当我们查看其线程的优先级时,就可能会发现它并没有被赋予以最高的优先级运行。

因此,如果我们在开发过程中发现对象的finalize()方法与代码的其余部分相比花费了太多时间,就有可能出现线程饥饿。

执行时间的另一个问题是,没有定义线程通过同步块的顺序。

当许多并行线程必须通过一些用同步块封装的代码时,可能会发生某些线程必须比其他线程等待更长的时间才能进入块的情况。

从理论上讲,这些线程就有可能永远不会进入这个同步块。针对这个问题,Java给出的解决方案是使用“公平”锁。就是在选择下一个要进入的线程时,公平锁将会线程的等待时间考虑在内。

Java SDK为其提供了一个公平锁的实现示例: java.util.concurrent.locks.ReentrantLock

如果在其构造函数将其 布尔 标志设置为true,ReentrantLock将授予对等待时间最长的线程的访问权。如此以来就保证了线程不会被饿死,但同时也会带来另一个问题,即没有考虑线程优先级。这样的结果可能造成经常在这个锁处等待的优先级较低的线程就有更多机会被执行,而扰乱了优先级秩序。

其实使用ReentrantLock类只会考虑那些等待锁的线程,而那些已经执行过多次的线程是肯定可以获取到锁的。而且那些因为线程优先级过低而通过公平锁获取锁的情况基本上不会经常出现,所以具有较高优先级的线程仍然会更频繁地获取到锁。

总结

我们在进行多线程的并发编程时,不可避免的会遇到多个线程并行的处理某个共享资源问题,根据同步原则,我们会选择在将处理操作代码部分进行同步,让每个线程进入该段代码之前都需要去获取资源锁,以免造成多线程并发操作造成状态不一致,信息丢失问题。但是由于锁的存在,就有可能造成多个线程占有资源同时申请其它资源时被相互阻塞,即死锁。这种死锁的发生的基础其实就是我们同步资源的基础条件,共享资源,独占使用,没有设定剥夺机制,相互之间出现循环等待等条件,所以,我们需要想办法去减少这种条件的出现。

另外,我们在为保证一些线程执行时考虑设定的优先级,以及线程资源剥夺机制时,可能会造成线程优先级较低的线程长时间甚至一直都得不到CPU执行时间,造成线程饿死问题,我们Java提供的可重入锁ReentrantLock可以解决这些问题,特别是其变种公平锁,可以避免线程饿死现象,但是需要跟优先级配合,避免低优先级频繁执行现象。

文章来源:智云一二三科技

文章标题:Java高级编程基础:深入理解死锁原理,真正发挥并发编程的优势

文章地址:https://www.zhihuclub.com/179545.shtml

关于作者: 智云科技

热门文章

网站地图