第十章、避免活跃性危险
发布日期:2021-09-12 09:57:59 浏览次数:20 分类:技术文章

本文共 8111 字,大约阅读时间需要 27 分钟。

文章目录

避免活跃性危险

一、死锁

书上讲了一个例子:“哲学家吃饭”,每个人都拿自己左边的那一根筷子,那么谁都吃不了饭,产生死锁。

当一个线程永远的持有一个锁,并且其他线程都尝试获得这个锁时,那么他们将永远被阻塞。 在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式(或者称为“抱死[Deadly Embrace]“) 。

在数据库系统的设计中考虑了监测死锁以及从死锁中恢复,在执行一个事务时可能需要获得多个锁,并一直持有这些锁直到事务提交。因此两个事务之间很可能发生死锁,但事实上这种情况并不多见。数据库服务器不会让这种情况发生,当它检测到一组事务发生死锁时,将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。

而java解决死锁问题方面没有数据库服务那么强大,发生死锁时可能造成应用程序完全停止,或者某个特定的子系统停止,或者性能降低,恢复应用程序唯一方式就是中止并重启。解决死锁的思路,所有线程以固定的顺序来获得锁,在程序中就不会出现锁顺序死锁问题。

1、顺序死锁

LeftRightDeadlock中发生死锁的原因:<color=blue>两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,就不会出现循环的加锁依赖性,因此也就不会发生死锁。

public class LeftRightDeadlock {
private final Object left = new Object() ; private final Object right = new Object() ; public void leftRight() {
synchronized (left) {
synchronized (right) {
//doSomething(); } } } public void rightLeft() {
synchronized (right) {
synchronized (left) {
doSomethingElse(); } } }}

2、动态的顺序死锁(书中使用的是参数作为动态的顺序死锁)

动态的顺序死锁不同于LeftRightDeadlock 一眼就能看出,很多看似无害的代码,当运行后才能发现出现死锁。动态的顺序死锁原因在于并不清楚是否在锁顺序上有足够的控制权来避免死锁的发生。

如果两个线程同时调用transferMoney,其中一个线程从X往Y转账,另一个线程从Y往X转账。很有可能A获得myAccount的锁等待yourAccount的锁,然而B此时持有yourAccount锁等待myAccount的锁。是否死锁取决于transferMoney的参数

public void transferMoney(Account fromAccount,Account toAccount,DollarAmount amount)throws InsufficientFundsException{
synchronized(fromAccount) {
synchronized(toAccount) {
if(fromAccount.getBalance().compareTo(account)<0) {
throw new InsufficientFundsException(); } else {
fromAccount.debit(account); toAccount.credit(account); } } }}

要解决这个问题,只需控制好获得锁的顺序。制定锁的顺序时,可以使用System.identifyHashCode方法,该方法由Object.hashCode返回的值。下面的代码中,使用了System.identityHashCode来定义锁的顺序。

public class Test {
private static final Object tieLock = new Object(); public void transferMoney(final Account fromAcct,final Account toAcct,final Integer amount) throws InsufficientResourcesExceptio{
class Helper {
public void transfer() throws InsufficientResourcesException {
if(fromAcct.getBalance().compareTo(amount) < 0) throw new InsufficientResourcesException(); else {
fromAcct.debit(amount); toAcct.credit(amount); } } } int fromHash = System.identityHashCode(fromAcct); int toHash = System.identityHashCode(toAcct); if(fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer(); } } } else if(fromHash > toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer(); } } }else {
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer(); } } } } }}
transferMoney(MyAccount,yourAccount,10); transferMoney(yourAccount,MyAccount,10); 分析一下你会发现,在上面的代码中获得锁的顺序是一样的。

虽然极少极少情况下,两个对象可能会拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又重新引入死锁。为避免这种情况,使用”加时赛“锁,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性。

3、在协作对象之间发生的死锁

有时候获取锁的操作并不像上面代码中那么明显,两个锁不一定必须在同一个方法中获取,有可能发生在两个相互协作的对象之间,这时候查找死锁会比较困难:如果在持有锁的情况下调用某个外部方法,这时候就要警惕死锁。Taxi描述的是出租车对象,包含位置和目的地两个属性。

public class Taxi {
private String location; private String destination; private Dispatcher dispatcher; public Taxi(Dispatcher dispatcher,String destination) {
this.dispatcher = dispatcher; this.destination = destination; } public synchronized String getLocation() {
return this.location; } /** * 该方法先获取Taxi的this对象锁后,然后调用Dispatcher类的方法时,又需要获取 * Dispatcher类的this方法。 * @param location */ public synchronized void setLocation(String location) {
this.location = location; System.out.println(Thread.currentThread().getName()+" taxi set location:"+location); if(this.location.equals(destination)) {
dispatcher.notifyAvailable(this); } }}public class Dispatcher {
private Set
taxis; private Set
availableTaxis; public Dispatcher(){
taxis= new HashSet
(); availableTaxis= new HashSet
(); } public synchronized void notifyAvailable(Taxi taxi) {
System.out.println(Thread.currentThread().getName()+" notifyAvailable."); availableTaxis.add(taxi); } /** * 打印当前位置:有死锁风险 * 持有当前锁的时候,同时调用Taxi的getLocation这个外部方法;而这个外部方法也是需要加锁的 * reportLocation的锁的顺序与Taxi的setLocation锁的顺序完全相反 */ public synchronized void reportLocation(){
System.out.println(Thread.currentThread().getName()+" report location."); for(Taxi t:taxis){
t.getLocation(); } } public void addTaxi(Taxi taxi){
taxis.add(taxi); }}

如果在持有锁的时候调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获得其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法获取当前被持有的锁。

4、开放调用

开放调用:如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。

class Taxi{
private Point location,destination; ... public synchronized Point getLocation() {
return location; } public void setLocation(Point location) {
boolean reachedDestination; synchronized(this) //在这个锁内部不会获取其他的锁 {
this.location = location; reachedDestination = location.equals(destination); } if(reachedDestination) {
dispatcher.notifyAvailable(this); } }}public class Dispatcher {
private Set
taxis; private Set
availableTaxis; public Dispatcher(){
taxis= new HashSet
(); availableTaxis= new HashSet
(); } public synchronized void notifyAvailable(Taxi taxi) {
System.out.println(Thread.currentThread().getName()+" notifyAvailable."); availableTaxis.add(taxi); } public Image getImage {
Set
copy; synchronized(this) {
copy = new HashSet
(taxis); } Image image = new Image(); for(Taxi t:copy) image.drawMarket(t.getLocation()); return image; }}

5、资源死锁

正如当多个线程相互持有彼此正在等待的锁而又不释放自己持有的锁的时候就会发生死锁,当他们在相同的资源集合上等待时,也会发生死锁。

  • 例如,假设有两个资源池,两个不同数据库的连接池,A线程可能持有与数据库D1的连接,并等待与数据库D2的连接,而线程B持有D2的连接并等待与D1的连接。(资源池越大,出现这种可能性就越小)。
  • 再例如,《java并发编程实战》笔记第八章中ThreadDeadLock,在单线程的ExecutorService中,一个任务A提交另一个任务B,在第一个任务A中等待另一个任务B的运行结果,而任务B想运行却要等待A先完成。

二、死锁的避免与诊断

如果程序必须获得多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。

在使用细粒度的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:
1、首先,找出在什么地方将获取多个锁,然后对所有这些实例进行全局分析,从而确保获取锁的顺序的一致。
2、尽可能使用开放调用,这能极大简化分析过程。

1、支持定时的锁

显式使用Lock类的tryLock功能来代替内置锁机制,从而可以检测死锁和从死锁中恢复过来。显式锁可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。

定时锁的优点:

  • 定时锁失败时,你不需要知道到底是怎么失败的,失败的原因可能多种多样,然而至少你能记录所发生的失败以及失败时涉及操作的其他有用信息,以便通过一种更平缓的方式来重新启动计算,而不是把整个进程都关闭了

  • 使用定时锁来获取多个锁也能有效地应对死锁问题,获取锁超时时,可以释放这个锁,然后后退并稍后重试,从而消除死锁发生的条件(很多时候死锁都是因为两个线程同时等待对方的锁造成的,错开执行时序可以消除死锁),时程序得以恢复(这种技术只在同时获取两个锁时有效)。

2、通过线程转储信息来分析死锁

JVM提供了线程转储技术来帮助识别死锁的发生,线程转储包括各个运行中的线程栈追踪信息。线程转储包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中可以获得这些锁,以及被阻塞的线程在等待哪个锁。

在许多IDE(集成开发环境)中都可以请求线程转储。例如要在UNIX平台上触发线程转储操作,可以通过向JVM的进程发送SIGQUIT信号(kill -3),或者在UNIX平台中按下Ctrl-\键,在Windows平台中按下Ctrl-Break键。

     如果使用显式的Lock类而不是内部锁,那么Java5.0并不支持与Lock相关的转储信息,在线程转储中不会出现显式地Lock。虽然在Java6中包含了对显式Lock地线程转储和死锁检测等的支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。
  如下图片给出了一个J2EE应用程序中获取的部分线程的转储信息。在导致死锁的故障中包括3个组件:一个J2EE应用程序,一个J2EE容器,以及一个JDBC驱动程序,分别由不同的生产商提供。这3个组件都是商业产品,并经过大量的测试,但每一个组件中都存在一个错误,并且这个错误只有当它们进行交互时才会显现出来,并导致服务器出现一个严重的故障。

三、其他活跃性危险

1、饥饿

线程由于无法访问它所需要的资源而不能继续进行时,就发生了饥饿。引发线程饥饿最常见的资源就是CPU时钟周期,如果一个线程的优先级不当(线程优先级明显高于其他线程)或者在持有锁时发生无线循环、无限等待某个资源,这就会导致此线程长期占用CPU时钟周期,其他需要这个锁的线程无法得到这个锁,因此就发生了饥饿。

2、活锁

假如有这样一个场景:两个绅士在去往对方城市旅游的路上狭路相逢,他们彼此都让出对方的路,然而冤家路窄,另一条路上他们又相遇了,如果运气不好他们之后都又遇见对方的话,结果就是他们就这样反反复复地避让下去。因而也就没法旅游了

活锁就是类似于绅士让路一样,是另一种形式的活跃性问题,这种问题发生时,尽管不会阻塞线程(绅士相遇都相互让路了,可以各自继续赶路),但也不能执行到预期结果(一直在让路,到不了旅行目的地),因为线程将不断重复相同的操作,而且总是失败。

  活锁通常发生在处理事务消息的应用程序中:不能成功处理某个消息时,回滚整个事务,并把这个消息重新放回待处理的队列头部,假如之后还是不能处理成功,那么这个过程将循环执行,使这种情况发生的消息通常称为“毒药消息”(Poison Message)。
  要解决活锁问题,需要在重试机制中引入随机性(如以太协议在重复发生冲突时采用指数方式回退机制:冲突发生时等待随机的时间然后重试,如果等待的时间相同的话还是会冲突),在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

转载地址:https://blog.csdn.net/weixin_38367817/article/details/103872591 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:第十三章、显式锁
下一篇:ThreadLocal讲解

发表评论

最新留言

留言是一种美德,欢迎回访!
[***.207.175.100]2024年04月24日 16时16分38秒