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