《java并发编程实战》 第十章 活跃性危险与如何避免

第十章 活跃性危险与如何避免

活跃性危险在第一章介绍,包括死锁、饥饿、活锁。

死锁

  经典的“哲学家进餐”问题: 5个哲学家去吃中餐,坐在一张圆桌旁,他们有五根筷子(不是五双),并且每两个人中间放一根筷子。哲学家们时而思考,时而进餐。每个人都需要一双筷子才能吃到东西,并且在吃完后将筷子放回原处继续思考。 如果每个人都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷子。——产生死锁
  也就是 每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。 (正确的做法是尝试获得两根邻近的筷子,如果其中一根正在被另一个哲学家使用,那么他应该放弃自己得到的那根,等待一段时间后再尝试。)
  当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。 在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式(或者称为“抱死[Deadly Embrace]“) 。
在数据库系统的设计中考虑了监测死锁以及从死锁中恢复,在执行一个事务时可能需要获得多个锁,并一直持有这些锁直到事务提交。因此两个事务之间很可能发生死锁,但事实上这种情况并不多见。数据库服务器不会让这种情况发生,当它检测到一组事务发生死锁时,将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。
  而java解决死锁问题方面没有数据库服务那么强大,发生死锁时可能造成应用程序完全停止,或者某个特定的子系统停止,或者性能降低,恢复应用程序唯一方式就是中止并重启。解决死锁的思路,所有线程以固定的顺序来获得锁,在程序中就不会出现锁顺序死锁问题。

顺序死锁

在这里插入图片描述
原因:发生死锁的原因是两个线程A和B试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,就不会出现循环的加锁依赖性,因此也就不会发生死锁
解决方法:按照相同的顺序来请求锁

//容易发生死锁
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 一眼就能看出,很多看似无害的代码,当运行后才能发现出现死锁。动态的顺序死锁原因在于并不清楚是否在锁顺序上有足够的控制权来避免死锁的发生。
  如果两个线程同时调用transferMoney,其中一个线程从X往Y转账,另一个线程从Y往X转账。很有可能A获得myAccount的锁等待yourAccount的锁,然而B此时持有yourAccount锁等待myAccount的锁。
    transferMoney(myAccount,yourAccount,10);
    transferMoney(yourAccount,myAccount,20);

public class DynamicDeadlock {
   /**
    * 转账操作(会发生死锁)
    * 两个账户相互给对方转账时,会发生死锁,相当于简单的锁顺序死锁
    * @param fromAccount 转出账户
    * @param toAccount   转入账户
    * @param amount      转账金额
    */
   public void transferMoney(Account fromAccount, Account toAccount, int amount)
           throws InsufficientResourcesException {
       synchronized (fromAccount) {
           synchronized (toAccount) {
               if (fromAccount.getBalance() < 0) {
                   throw new InsufficientResourcesException();
               } else {
                   fromAccount.debit(amount);
                   toAccount.credit(amount);
               }
           }
       }
   }
   class Account {
       private int balance;
       public int getBalance() {
           return balance;
       }
       public void setBalance(int balance) {
           this.balance = balance;
       }
       public void debit(int amount)  {    // 转出
           this.setBalance(getBalance() - amount);
       }
       public void credit(int amount) {
           this.setBalance(getBalance() + amount);
       }
   }
}

  要解决这个问题,只需控制好获得锁的顺序。制定锁的顺序时,可以使用System.identifyHashCode方法,该方法由Object.hashCode返回的值。虽然极少极少情况下,fromAcct、toAcct两个对象可能会拥有相同的散列值,为避免这种情况,使用”加时赛“锁,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性。如果在Account中包含一个唯一的、不可变的、具备可比性的键值,例如账号,那么制定锁的顺序会更加容易,使用键值进行排序,因而可以不用”加时赛“锁。

public class SaveDynamicDeadlockProblem {
   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);
   	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();
   				}				
   			}
   		}
   	}
   }
}

协作对象之间发生死锁

  有时候获取锁的操作并不像上面代码中那么明显,两个锁不一定必须在同一个方法中获取,有可能发生在两个相互协作的对象之间,这时候查找死锁会比较困难:如果在持有锁的情况下调用某个外部方法,这时候就要警惕死锁。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);
   }
}

测试程序:

public class TestDeadLock {
   public static void main(String[] args) {
   	final Dispatcher d1 = new Dispatcher();
   	final Taxi t1 = new Taxi(d1,"乌鸦大大");
   	d1.addTaxi(t1);
   	Thread thread1 = new Thread(new  Runnable() {
   		public void run() {
   			//先获取dispatcher锁,然后是taxi的锁
   			d1.reportLocation();
   		}
   	});
   	Thread thread2 = new Thread(new  Runnable() {
   		public void run() {
   			//先获取taxi锁,然后是dispatcher的锁
   			t1.setLocation("zjut");
   		}
   	});
   	//最后二者陷入死锁
   	thread1.start();
   	thread2.start();
   }
}

以上协作对象之间容易发生死锁,死锁运行结果:
在这里插入图片描述

持有锁时调用外部方法(协作对象之间死锁解决办法)

  相对于持有锁时调用外部方法的情况,如果在调用某个方法时不需要持有锁,这种调用就叫做“开放调用”(Open Call)。在程序中应该尽量使用开放调用,以便于对依赖于开放调用的程序进行死锁。

资源死锁

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

如何避免和诊断死锁

  尽量减少潜在加锁交互数量,是避免死锁的有效办法。尽可能的使用开发调用,能极大的简化分析过程。
  当检查代码是死锁时,首先找出在哪些地方获取了多个锁,然后对这些实例进行全局分析,确保它们在整个程序中获取锁的顺序都保持一致。

使用支持定时的锁

  简单来说支持定时的锁就是显式使用Lock类中的定时tryLock功能来代替内置锁机制从而可以检测死锁,并且万一发生死锁能从死锁中自己恢复过来。
  定时锁优点1:定时锁失败时,你不需要知道到底是怎么失败的,失败的原因可能多种多样,但是至少你能记录所发生的失败以及失败时涉及操作的其他有用信息,以便通过一种更平缓的方式来重新启动计算,而不是把整个进程都关闭了。
  定时锁优点2:使用定时锁来获取多个锁也能有效地应对死锁问题,获取锁超时时,可以释放这个锁,然后后退并稍后重试,从而消除死锁发生的条件(很多时候死锁都是因为两个线程同时等待对方的锁造成的,错开执行时序可以消除死锁),时程序得以恢复(这种技术只在同时获取两个锁时有效)。

通过线程转储(Thread Dump)信息来分析死锁

  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可以帮我们做许多工作——哪些锁导致了这个问题,设计哪些线程,它们持有哪些其他的锁,以及是否间接地给其他线程带来不利的影响。其中一个线程持有MumbleDBConnection上的锁,并等待获得MumbleDBCallableStatement上的锁,而另一个线程持有MumbleDBCallableStatement上的锁,并等待MumbleDDConnection上的锁。
  在这里使用的JDBC驱动程序中明显存在一个锁顺序问题:不同的调用链通过JDBC驱动程序以不同的顺序获取多个锁。如果不是由于另一个错误,这个问题永远不会显现出来:多个线程试图同时使用同一个JDBC连接。这并不是应用程序的设计初衷——开发人员惊讶地发现同一个Connection被两个线程并发使用。在JDBC规范中并没有要求Connection必须是线程安全的,以及Connection通常被封闭在单个线程中使用,而在这里就采用了这种假设。这个生产商视图提供一个线程安全的JDBC驱动,因此在驱动程序代码内部对多个JDBC对象施加了同步机制。然而,生产商却没有考虑锁的顺序,因而驱动程序很容易发生死锁,而正是由于这个存在死锁风险的驱动程序与错误共享Connection的应用程序发生了交互,才使得这个问题暴露出来。因为单个错误并不会产生死锁,只有这两个错误同时发生时才会产生,即使它们分别进行了大量测试。
  文件转储的信息查看方式有很多种,以运行我《java并发编程实战》笔记第八章中ThreadDeadLock 为例,查看线程饥饿,资源死锁。
首先运行程序:
在这里插入图片描述
jvisualvm.exe工具:打开你java安装包jdk的bin下的jvisualvm.exe工具,可以看出、tid=0x00000000578dc000线程java.lang.Thread.State: TIMED_WAITING (on object monitor),Object.wait() 或 TIMED_WAITING表示对象等待中,其中一句ScheduledThreadPoolExecutor$DelayedWorkQueue.take说明当TwoPrintTask想获得print1Result、print2Result线程的结果时,print1Result、print2Result想运行却还只能在等待newSingleThreadExecutor的工作队列中TwoPrintTask完成。

在这里插入图片描述
使用jconsole工具,(通过打开cmd然后输入jconsole打开),很神奇检测死锁并没有检测出来,(所以个人认为我《java并发编程实战》笔记第八章中ThreadDeadLock严格意义上并不是死锁,可以说是基于资源的死锁 )。而其中提示的Chapter8.ThreadDeadLock$TwoPrintTask.call(ThreadDeadLock.java:17)正是我程序中 return print1Result.get()+print2Result.get()+“success”;一句。
在这里插入图片描述
代码:
在这里插入图片描述
dump 文件里,值得关注的线程状态有:
  死锁,Deadlock(重点关注)
  执行中,Runnable
  等待资源,Waiting on condition(重点关注)
  等待获取监视器,Waiting on monitor entry(重点关注)
  暂停,Suspended
  对象等待中,Object.wait() 或 TIMED_WAITING
   阻塞,Blocked(重点关注)
   停止,Parked

饥饿

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

活锁

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

  活锁就是类似于绅士让路一样,是另一种形式的活跃性问题,这种问题发生时,尽管不会阻塞线程(绅士相遇都相互让路了,可以各自继续赶路),但也不能执行到预期结果(一直在让路,到不了旅行目的地),因为线程将不断重复相同的操作,而且总是失败。
  活锁通常发生在处理事务消息的应用程序中:不能成功处理某个消息时,回滚整个事务,并把这个消息重新放回待处理的队列头部,假如之后还是不能处理成功,那么这个过程将循环执行,使这种情况发生的消息通常称为“毒药消息”(Poison Message)。
  要解决活锁问题,需要在重试机制中引入随机性(如以太协议在重复发生冲突时采用指数方式回退机制:冲突发生时等待随机的时间然后重试,如果等待的时间相同的话还是会冲突),在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生

猜你喜欢

转载自blog.csdn.net/weixin_41262453/article/details/86726803