【读书笔记】《Java并发编程实战》第十章 活跃性危险

1.死锁

我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁。

当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这种情况就是简单的死锁形式。(其中多个线程由于存在环路的锁依赖关系而永远地等待下去。)

死锁形式如下图所示:
在这里插入图片描述

1.1锁顺序死锁

下面为锁顺序死锁示例:

//简单的锁顺序死锁
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();
			}
		}
	}
}

上面程序中LeftRightDeadLock存在死锁风险。leftRight和rightLeft这两个方法分别获得left锁和right锁。如果一个线程调用了leftRight,而另一个线程调用了rightLeft,并且这两个线程的操作时交错执行,那么它们会发生死锁,如下图所示:

在这里插入图片描述
在LeftRightDeadLock中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。如果每个需要锁L和锁M的线程都以相同的顺序来获取L和M,那么就不会发生死锁了。

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

1.2动态的锁顺序死锁

如下代码,它将资金从一个账户转入另一个账户,但是有可能发生动态的锁顺序死锁:

//动态的锁顺序死锁
public void transferMoney(Account fromAccount, Account toAccount,
 DollarAmount amount) throws InsufficientFundsException {
	synchronized (fromAccount) {
		synchronized (toAccount) {
			if (fromAccount.getBalance().compareTo(amount) < 0)
				throw new InsufficientFundsException ();
			else {
				fromAccount.debit(amount);
				toAccount.credit(amount);
			}
		}
	}
}

上面代码中上锁的顺序取决于传递给transferMoney的参数顺序,而这些参数又取决于外部输入。如果两个线程同时调用transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就会发生死锁。例如如下调用:

A线程:transferMoney(myAccount, yourAccount, 10);
B线程:transferMoney(yourAccount, myAccount, 20);

对于动态的锁顺序死锁,由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。

在制定锁的顺序时,可以使用System.identityHashCode方法,该方法将返回由Object.hashCode返回的值。

下面的代码消除了发生死锁的可能性:

//通过锁顺序来避免死锁
public class Test{
   private static final Object tieLock = new Object();
   public void transferMoney(final Account fromAcct,final Account toAcct,final Integer amount)
   		throws InsufficientResourcesException{
   		
   	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);
   	
   	//这里通过比较锁的hash值来判断锁获取的顺序
   	if(fromHash < toHash)
   	{
   		synchronized (fromAcct) {
   			synchronized (toAcct) {
   				new Helper().transfer();
   			}
   		}
   	}
   	else if(fromHash > toHash)
   	{
   		synchronized (fromAcct) {
   			synchronized (toAcct) {
   				new Helper().transfer();
   			}		
   		}
   	}
   	//如果两个锁的hash值相等,那么执行以下策略,保证每次只有一个线程以未知顺序获得两个锁
   	else {
   		synchronized (tieLock) {
   			synchronized (fromAcct) {
   				synchronized (toAcct) {
   					new Helper().transfer();
   				}				
   			}
   		}
   	}
   }
}

1.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);
   	}
   }
}

Dispatcher代表一个出租车车队。

//在相互协作对象之间的锁顺序死锁
public class Dispatcher {
   private Set<Taxi> taxis;
   private Set<Taxi> availableTaxis;
   public Dispatcher(){
   	taxis= new HashSet<Taxi>();
   	availableTaxis= new HashSet<Taxi>();
   }
   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);
   }
}

如果在持有锁时调用某个外部方法,那么就有可能出现活跃性问题,那么就需要警惕对待。

2.死锁的避免与诊断

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

2.1支持定时的锁

还有一项技术可以检测死锁和从死锁中恢复过来,即显示使用Lock类的定时tryLock功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显示锁则可以指定一个超过时限,在等待超过该时间后tryLock会返回一个失败信息。如果超过时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制权。

2.2通过ThreadDump来分析死锁

虽然防止死锁的主要责任在于编码者,但JVM仍然通过线程转储(ThreadDump)来帮助识别死锁的发生。ThreadDump包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。ThreadDump还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。在生成ThreadDump之前,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个组件都是商业产品,并经过大量的测试,但每一个组件中都存在一个错误,并且这个错误只有当它们进行交互时才会显现出来,并导致服务器出现一个严重的故障。
在这里插入图片描述
当诊断死锁时,JVM可以帮我们做许多工作——哪些锁导致了这个问题,设计哪些线程,它们持有哪些其他的锁,以及是否间接地给其他线程带来不利的影响。

2.3其他活跃性危险

尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括:饥饿丢失信号活锁等。

2.3.1饥饿

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

2.3.2活锁

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

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

活锁通常发生在处理事务消息的应用程序中:不能成功处理某个消息时,回滚整个事务,并把这个消息重新放回待处理的队列头部,假如之后还是不能处理成功,那么这个过程将循环执行,使这种情况发生的消息通常称为“毒药消息”(Poison Message)。

要解决活锁问题,需要在重试机制中引入随机性(如以太协议在重复发生冲突时采用指数方式回退机制:冲突发生时等待随机的时间然后重试,如果等待的时间相同的话还是会冲突),在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生

猜你喜欢

转载自blog.csdn.net/Handsome_Le_le/article/details/107837879