多线程Java程序中常见错误的巧处理

  在几乎所有的Smashing语言中,由于多线程引发的错误都有着难以重现的特点,程序的死锁或其他多线程错误可能只在某些特殊的情况下才会出现,或者在不同的VM上运行同一个程序时错误表现不同。因此,在编写多线程程序时,事先认识和防范可能出现的错误尤为重要。
  无论是客户端还是服务器端多线程Java程序,最常见的多线程问题包括死锁、隐性死锁和数据竞争。
  死锁
  死锁是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞因此,程序不可能正常终止。
  导致死锁的请求必须局部使用“同步”关键词来管理线程对特定对象的访问。“同步”关键词的作用是,确保在某个时刻只有一个线程被允许执行允许特定的代码块,因此,被执行的线程首先必须拥有对变量或对象的排他性的访问权限。当线程访问对象时,线程会给对象加锁,而这个锁导致其他也想访问相同对象的线程被阻塞,协议第一个线程释放它加在对象上的锁。
  由于这个原因,在使用“同步”关键词时,很容易出现两个线程等待对方做出某种动作的情况。代码一是一个导致死锁的简单例子。
  //代码一
  class Deadlocker{
  int field_1;
  私有对象lock_1=new int[1];
  int字段_2;
  私有对象lock_2=new int[1];
  公共无效方法1(int值){
  “同步”(lock_1){
  “同步”(lock_2){
  field_1=0;字段_2=0;
  }
  }
  }
  public void method2(int value){
  “同步”(lock_2){
  “同步”(lock_1){
  field_1=0;字段_2=0;
  }
  }
  }
  }
  参考代码一,考虑下面的流程:
  ◆一个线程(ThreadA)调用method1()。
  ◆ThreadA在lock_1上同步,但允许被抢先执行。
  ◆另一个线程(ThreadB)开始执行。
  ◆ThreadB调用method2()。
  ◆ThreadB获得lock_2,继续执行,企图获得lock_1。但ThreadB不能获得lock_1,ThreadA占有lock_1。◆因为现在,ThreadB阻塞,因为它
  在等待ThreadA释放lock_1。
  ◆现在轮到ThreadA继续执行。ThreadA试图获得lock_2,但不能成功,因为lock_2已经被ThreadB占有了。
  ◆ThreadA和ThreadB都被阻塞,程序死锁。
  当然,大部分的死锁不会这么严重,需要仔细分析代码才能看出,对于规模越来越多的多线程程序尤其如此。好的线程分析工具,例如JProbe Threadalyzer能够分析死锁并隐指出产生问题的代码位置。
  性死锁
  隐性死锁由于不规范的编程方式引起,但不一定每次测试运行时都会出现程序死锁的情况。由于这个原因,一些隐性死锁可能要到应用正式发布之后才会发现,因此下面它的存在性比普通死锁更大。介绍两个种导致隐性死锁的情况:加锁队列和占有并等待。
  加锁队列
  当多个线程分别尝试同时占有两个锁时,会出现加锁队列冲突的情况。如果一个线程占有了另外一个一个线程重复的锁,就可能出现死锁。考虑下面的情况,ThreadA和ThreadB两个线程分别需要同时拥有lock_1、lock_2两个锁,加锁过程可能如下:
  ◆ThreadA获得lock_1;
  ◆ThreadA被抢占,VM调度程序转到ThreadB;
  ◆ThreadB获得lock_2;
  ◆ThreadB被抢占,VM调度程序转到ThreadA;
  ◆ThreadA试图获得lock_2,但lock_2被ThreadB占有,所以ThreadA阻塞;
  ◆调度程序转到ThreadB;
  ◆ThreadB试图获得lock_1,但lock_1被ThreadA占有,所以ThreadB阻塞;
  ◆ThreadA和ThreadB死锁。
  指出必须是,在代码不做周期性的情况下,有些时候上述死锁过程不会出现,VM调度程序可能让其中一个线程同时获得lock_1和lock_2两个锁,即线程获得两个锁的过程不会被中断。在这种情况下,常规的死锁检测很难确定错误所在。如果一个线程获得了占有并
  等待
  一个锁之后还要等待来自另一个线程的通知,可能出现另一种隐性死锁,考虑代码二。
  //代码二
  public class queue{
  static java.lang.Object queueLock_;
  制片人制片人_;
  消费者consumer_;
  公共类生产者{
  无效生产(){
  while(!done){
  “同步”(queueLock_){
  ProduceItemAndAddItToQueue();}
  “同步”(consumer_){
  consumer_.notify();
  }
  }
  }
  }
  公共类Consumer{
  Consumer(){
  while(!done){
  “同步”(queueLock_){
  “同步”(consumer_){
  Consumer_.wait();}
  }
  removeItemFromQueueAndProcessIt();
  }
  }}
  }
  }
  }
  在
  代码二中,生产者向队列加入一个新的内容后通知消费者,便于其处理新的内容。问题存在,消费者可能保持添加在队列上的锁,阻止生产者访问队列,甚至在Consumer等待Producer的通知时又继续保持锁。这样,由于Producer无法向队列添加新的内容,而Consumer却在等待Producer加入新内容的通知,结果就导致了死锁。在等待时占有率的
  锁是一种隐性的死锁,这件事可能符合比较理想的情况发展——生产者线程不需要被消费者质疑的锁。尽管如此,因为除非有绝对可靠的理由肯定生产者线程永远不需要该锁,否则编程方式仍然是不安全的。有时这种“占有并等待”还可能引发一连串的线程等待,例如,线程A占有线程B需要的锁并等待,而线程B又占有线程C需要的锁并等待等要
  改正代码二的错误,只需修改消费者类,把wait()移出“synchronized”()即可。
  数据竞争
  数据竞争是由于访问共享资源(如变量)时缺乏或局部不一致引入同步机制引起如果没有正确地限定某个时刻某个线程可以访问变量,就会出现数据竞争,此时赢得竞争的线程获得访问许可,但会导致不可预知的结果。
  由于线程的运行在任何时候都可能被中断(同时运行机会被其他线程抢占),所以不能假设先开始运行的线程总是比后开始运行的线程先访问到相互共享的数据。另外,在不同的VM上,线程的调度方式也可能不同,从而使数据竞争问题变得更加复杂。
  有时,数据竞争不会影响程序的最终运行结果,但在另外一些时候,有可能导致不可预测的结果。
  良性数据竞争
  并非所有的数据竞争都是错误。考虑代码三个的例子。假设getHouse()向所有的线程返回相同的House,可以看出,这里会出现竞争:BrickLayer从House.foundationReady_读取,而FoundationPourer读取到House.foundationReady_。
  //代码三
  公共类House{
  公共易失性布尔基础Ready_=false;
  }
  public class FoundationPourer extends Thread{
  public void run(){
  House a=getHouse();
  a.foundationReady_=true;
  }
  }
  public class BrickLayer extends Thread{
  public void run(){
  House a=getHouse();
  while(!a.foundationReady_){
  尝试{
  Thread.sleep(500);
  }
  catch(Exception e){
  System.err.println(“异常:”+e);
  }
  }
  }
  }
  }
  存在竞争性,但根据Java VM规范,布尔数据的读取和读取都是原则性的,因此,VM不能中断线程的读取或读取成功操作。不存在将其改回原来数据的必要(不需要“回退”),所以代码三的数据竞争是良性竞争,代码是安全的。修改数据竞争首先看一下代码四的例子。
  //
  代码
  四
  公共类帐户{
  private intbalance_;//账户余额
  public int getBalance(void){
  return Balance_;
  }
  公共无效setBalance(int设置){
  余额_=设置;
  }
  }
  公共类客户信息{
  私有int numAccounts_;
  私人账户[]账户_;
  公共无效提款(int accountNumber,int amount){
  int temp=accounts_[accountNumber].getBalance();
  温度=温度-数量;
  账户_[账户编号].setBalance(temp);
  }
  public void Deposit(int accountNumber,int amount){
  int temp=accounts_[accountNumber].getBalance();
  温度=温度+数量;
  账户_[账户编号].setBalance(temp);
  }
  }
  如果丈夫A和妻子B试图通过不同的银行柜员机同时向同一账户存钱,会发生什么事情?让我们假设账户的最终余额是100元,看看程序的一种可能的执行经过。
  B存钱25元,她的柜员机开始执行deposit()。首先取得当前余额100,把这个余额保存在本地的临时变量,然后把临时变量加25,临时变量的值变成125。现在,在调用setBalance()之前,线程调度器中断了该线程。
  A存入50元。当B的线程仍处于挂起状态时,A这面开始执行deposit():getBalance()返回100(因为此时B的线程尚未把修改后的余额写入),A的线程在现有余额的基础上加50得到150,并把150这个值保存到临时变量。接着,A的线程在调用setBalance()之前,也被中断执行现在
  ,B的线程连接运行,把保存临时变量中的值(125)写入到余额,柜员机告诉B说交易完成,账户余额是125元。接下来,A的线程继续运行,把临时变量的值(150)读取到余额,柜员机告诉A说交易完成,账户余额是150元。
  最后得到的结果是什么?B的消失不见了,就像B根本没有存过钱一样。
  也许有人会认为,可以把getBalance()和setBalance()改成同步方法保护Account.balance_,解决数据竞争问题。其实这种办法是行不通的。“同步”关键词可以确保同一时刻只有一个线程执行getBalance()或setBalance()方法,但不能在一个线程操作期间阻止另一个线程修改账户余额。
  要正确运用“同步”关键词,就必须认识到这里要保护整个交易过程不被另一个线程干扰,而不仅仅是对数据访问的某个步骤进行保护。
  所以,本例的关键是当一个线程获得当前余额之后,要保证其他的线程不能修改余额,第一个直到一个线程的余额处理工作全部完成。的修改方法是把deposit()和withdraw()改成同步方法。
  死锁、隐性死锁和数据竞争是Java多线程编程中最常见的错误。要写出健壮的多线程代码,正确理解和使用“同步”关键词是很重要的。另外,好的线程分析工具,例如JProbe Threadalyzer,能够极大地简化错误检测。对于分析那些不一定每次执行时都会出现的错误,分析工具尤其有用。