第十章 锁与活跃性的那些事

本文围绕Java多线程展开,介绍了死锁的多种类型,如锁顺序死锁、动态的锁顺序死锁等,分析了死锁产生的原因及避免和诊断方法,还提及支持定时的锁技术。此外,阐述了其他活跃性危险,包括饥饿、糟糕的响应性和活锁,并给出相应解决办法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

安全性和活跃性之间存在某种制衡:
使用加锁机制来确保线程安全,但如果过度使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。同样,使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁(Resource Deadlock)
java程序无发从死锁中恢复出来。

10.1 死锁

如果每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。这样下就会产生死锁。
如果数据库出现一组事物发生死锁时,将选择泽一个牺牲者并放弃这个事物。JVM在解决死锁没有这么强大,**当一组java线程发生死锁时,就真的gaveover了—这些线程永远不能在使用了,恢复的唯一方法就是重启应用。**当出现死锁的时候往往是在最糟糕的时候。

10.1.1 锁顺序死锁
//      10-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();
            }
        }
    }

    void doSomething() {
    }

    void doSomethingElse() {
    }
}

leftRight和rightLeft这两个方法分别获得left锁和right锁。如果一个线程调用了leftRight,另一个线程调用了rightLeft,并且两个线程的操作是交错执行的,会发生死锁,如图:
不当的执行时机
发生死锁的原因是:两个线程 试图以不同的顺序来获取相同的锁。如果按照相同的顺序来请求锁,就不会出现循环的加锁依赖性,就不会产生死锁。

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

10.1.2 动态的锁顺序死锁

有时候,并不能请吃的知道是否在锁顺序上有足够的控制权来避免死锁。考虑10-2中的代码,它将资金从一个账户转入到另一个账户。在开始转账之前,首先要获得这两个Account对象的锁,以却不通过原子方式来更新两个账户中的余额,同时又不能破坏一些不变性条件,例如“账户的余额不能为负数”

//      10-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);
        }
    }
  }
  static class DollarAmount implements Comparable<DollarAmount> {
            // Needs implementation
            public DollarAmount(int amount) {
            }
            public DollarAmount add(DollarAmount d) {
                return null;
            }
            public DollarAmount subtract(DollarAmount d) {
                return null;
            }
            public int compareTo(DollarAmount dollarAmount) {
                return 0;
            }
        }
        static class Account {
            private DollarAmount balance;
            private final int acctNo;
            private static final AtomicInteger sequence = new AtomicInteger();
            public Account() {
                acctNo = sequence.incrementAndGet();
            }
            void debit(DollarAmount d) {
                balance = balance.subtract(d);
            }
            void credit(DollarAmount d) {
                balance = balance.add(d);
            }
            DollarAmount getBalance() {
                return balance;
            }
            int getAcctNo() {
                return acctNo;
            }
        }
        static class InsufficientFundsException extends Exception {
        }
}

上面的例子中似乎按相同的顺序来获得锁,但事实上锁的顺序取决与传递给transferMoney的参数顺序,而这些参数顺序又取决与外部输入。
如果两个线程同时调用transferMoney,其中一个线程从X向Y转账,而另一个线程从Y向X转账,那么就会发生死锁:
A: transferMoney(myAccount, yourAccount, 10);
B: transferMoney(yourAccount, myAccount, 20);

我们希望改进10.2中的程序并且不发生死锁情况。就如下面的例子中,可以通过使用System.identityHashCode来定义锁的顺序。

//       10-3  通过锁顺序来避免死锁
private static final Object tieLock=new Object();

   public void transferMoney(final Account fromAcct,
                             final Account toAcct,
                             final DollarAmount amount)
               throws InsufficientFundsException{  //自定义异常类,继承Exception类,当取款的数额大于存款时抛出
       class Helper{
           public void transfer()throws InsufficientFundsException {
               if(fromAcct.getBalance().compareTo(amount)<0)
                   throw new InsufficientFundsException();
               else{
                   fromAcct.debit(amount);   //debit记入借方,fromAcct减少amount
                   toAcct.credit(amount);    //credit记入贷方,toAcct增加amount
               }
           }
       }
       //使用了System.identityHashCode来定义锁的顺序
       ////返回给定对象的哈希码,该代码与默认的方法 hashCode() 返回的代码一样,无论给定对象的类是否重写 (override)hashCode()。
       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 (toAcct) {
                synchronized (fromAcct) {
                    new Helper().transfer();
                }
            }
        }else{   //如果得到两个相同的hashcode,使用加时赛锁,从而保证每次只有一个线程以未知的顺序得到这两个锁
            synchronized (tieLock) {
                synchronized (fromAcct) {
                    synchronized (toAcct) {
                        new Helper().transfer();
                    }
                }
            }
        }
   }

在极少数情况下,两个对象可能拥有相同的散列值(HashCode),此时必须通过某种任意的方法来决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛(Tie-Breaking)锁”。在获得两个Account锁之前,首先获得这个“加时赛”锁,从而保证每次只有一个线程以未知的顺序得到这两个锁,从而消除了死锁发生的可能性(只要一致地使用这种机制)。

锁被持有的时间通常很短暂,然而死锁往往是很严重的问题。
作为商业产品的应用程序可能每天被执行数十亿次获取锁-释放锁的操作,只要在这数十亿次操作中有一次发生了错误,就可能导致程序发生死锁,并且即使应用程序通过了压力测试也不可能找出所有潜在的死锁(短时间持有锁是为了降低锁的竞争程度,却增加了在测试中找出潜在死锁风险的难度),10-4中的DemonstrateDeadlock在多数系统下很快发生死锁。
为了简便,DemonstrateDeadlock没有考虑账户余额来负数的问题。

//        10-4  在典型条件下会发生死锁的循环
public class DemonstrateDeadlock {
    private static final int NUM_THREADS = 20;
    private static final int NUM_ACCOUNTS = 5;
    private static final int NUM_ITERATIONS = 1000000;   //iteration 循环,迭代

    public static void main(String[] args) {
        final Random rnd = new Random();
        final Account[] accounts = new Account[NUM_ACCOUNTS];

        for (int i = 0; i < accounts.length; i++)
            accounts[i] = new Account();

        class TransferThread extends Thread {
            public void run() {
                for (int i = 0; i < NUM_ITERATIONS; i++) {
                    int fromAcct = rnd.nextInt(NUM_ACCOUNTS);
                    int toAcct = rnd.nextInt(NUM_ACCOUNTS);  //生成0到NUM_ACCOUNTS之间的随机int
                    DollarAmount amount = new DollarAmount(rnd.nextInt(1000));
                    try {
                        DynamicOrderDeadlock.transferMoney(accounts[fromAcct], accounts[toAcct], amount);
                    } catch (DynamicOrderDeadlock.InsufficientFundsException ignored) {
                    }
                }
            }
        }
        for (int i = 0; i < NUM_THREADS; i++)
            new TransferThread().start();
    }
}
10.1.3 在协作对象之间发生的死锁

某些获取多个锁的操作并不想上面那么明显,这两个锁不一定在同一个方法中被获取。10-5中两个互相协作的类,在出租车调度系统中可能会用到它们。Taxi代表一个出租车对象,包含位置和目的地两个属性,Dispatcher代表一个出租车车队。

//      10-5  在互相协作对象之间的锁顺序死锁(不要这样做)  
//   容易发生死锁
  class Taxi{
        private Point location,destination;  //Taxi代表一个出租车对象,包含位置和目的地两个属性
        private final Dispatcher dispatcher; //Dispatcher代表一个出租车车队

        public Taxi(Dispatcher dispatcher){  
            this.dispatcher=dispatcher;
        }
        public synchronized Point getLocation(){
            return location;
        }
        public synchronized void setLocation(Point location){
            this.location=location;
            if(location.equals(destination))
                dispatcher.notifyAvailable(this);
        }
    }

    class Dispatcher{
        private final Set<Taxi> taxis;
        private final Set<Taxi> availableTaxis;

        public Dispatcher(){
            taxis=new HashSet<Taxi>();
            availableTaxis=new HashSet<Taxi>();
        }
        public synchronized void notifyAvailable(Taxi taxi){  //notify 通知 ,available 空闲的
            availableTaxis.add(taxi);
        }
        public synchronized Image getImage(){
            Image image = new Image();
            for (Taxi t : taxis)
               image.drawMarker(t.getLocation());
            return image;
        }   
    }

尽管没有任何方法显式地获取两个锁,但setLocation和getImage等方法的调用者都会获得两个锁。如果一个线程在收到GPS接收器的更新事件时调用setLocation,那么它将首先更新出租车的位置,然后判断它是否到达了目的地。如果到达了,它会通知Dispatcher:它需要一个新目的地。因为setLocation和notifyAvailable都是同步方法,因此调用setLocation的线程将首先获得Taxi的锁,然后再获得Dispatcher的锁。同样,调用getImage的线程将首先获取Dispatcher锁,然后再获取每一个Taxi的锁(每次获取一个)。这与LeftRightDeadlock中的情况相同,两个线程按照不同的顺序来获取两个锁,因此可能产生死锁。
**如果在持有锁时调用某个外部方法,那么将出现活跃性问题。**在这个外部方法中可能或获取其他锁(这可能产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

10.1.4 开放调用

方法调用相当于一种抽象屏障,你无需了解在调用方法中所执行的操作,也正是由于不知道在被调用方法中执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。

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

依赖于开放调度的类通常能表现出更好的行为,并且与那些在调度方法时需要持有锁的类相比,也更易于编写。
这种通过开放来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的类,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易得多。
同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。
通过尽可能地使用开放调用,将更容易找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一直的顺序来获得锁。

将10-5修改为开放调用,从而消除死锁的风险,这需要使同步代码块仅被用于保护那些涉及共享状态的操作,如10-6所示。
通常,如果只是为了语法紧凑或简单性(而不是因为整个方法必须通过一个锁来保护)而使用同步方法(而不是同步代码块)

/      10-6   通过公开调用来避免在互相协作的对象之间产生死锁
 class Taxi{
        private Point location,destination;  //Taxi代表一个出租车对象,包含位置和目的地两个属性
        private final Dispatcher dispatcher; //Dispatcher代表一个出租车车队

        public Taxi(Dispatcher dispatcher){
            this.dispatcher=dispatcher;
        }
        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);
        }
    }

    class Dispatcher{   //调度
        private final Set<Taxi> taxis;
        private final Set<Taxi> availableTaxis;

        public Dispatcher(){
            taxis=new HashSet<Taxi>();
            availableTaxis=new HashSet<Taxi>();
        }
        public synchronized void notifyAvailable(Taxi taxi){  //notify 通知 ,available 空闲的
            availableTaxis.add(taxi);
        }
        public  Image getImage(){
            Set<Taxi> copy;
            synchronized (this) {
                copy=new HashSet<Taxi>(taxis);
            }
            Image image=new Image();
            for(Taxi t:copy)
                image.drawMarker(t.getLocation());
            return image;
        }   
    }

在程序中应尽量使用开放调用,与那些在持有锁时调用外部方法的程序相比,更容易对依赖于开放调用的程序进行死锁分析。

有时候在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变成非原子操作。
在许多情况下,某个操作失去原子性是可以接受的。例如,对于两个操作:更新出租车位置以及通知调度程序这辆出租车已准备好出发去一个新的目的地,这两个操作并不需要实现为一个原子操作。

然而,在某写情况下,丢失原子性会引发错误,此时需要通过另一种技术来实现原子性。
例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。
例如,在关闭某个服务时,你可能希望所有正在运行的操作执行完成以后,再释放这些服务占用的资源。如果在等待操作完成的同时持有该服务的锁,那么将容易导致死锁,但如果在服务关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。
这个问题的解决方法是,在将服务的状态更新为“关闭”之前一直持有锁,这样其他想要开始新操作的线程,包括想关闭该服务的其他操作,会发现服务已经不可用,因此也就不会试图开始新的操作。然后,你可以等待关闭操作结束,并且知道当开放调用完成后,只有执行关闭操作的线程才能访问服务的状态。因此,这项技术依赖于一些协议(而不是通过加锁)来防止其他线程来进入代码的临界区。

10.2 死锁的避免与诊断

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

在使用细粒度(fine-grained)锁的程序中,可以通过使用一种两阶段策略(Two-Part Strategy)来检查代码中的死锁:首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用,这能极大地简化分析过程。
如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查或者借助自动化的源代码分析工具。

10.2.1 支持定时的锁

还有一项技术可以检查死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能(13章)来代替内置锁机制。

当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以执行一个超时时限(Timeout),在等待超过该事件后tryLock会返回一个失败信息。
如果超时时限要比获取锁的时间要长很多,那么就可以在发生某个以外情况后重新获得控制权。

当定时锁失败时,并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在持有锁时错误地进入了无限循环,还可能是某个操作的执行时间远远超出了预期。
然而,至少能记录所发生的失败,以及关于这次操作的其他有用信息,并通过一种更平缓的方法来重新启动计算,而不是关闭整个进程。
即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效地应对死锁问题。

如果在获取锁时超时,那么可以释放这个锁,然后后退并在一段时间后再次蚕食,从而消除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,如果在嵌套的方法调用中请求多个锁,那么即使你知道已经有了外层的锁,也无法释放它)

10.3 其他活跃性危险

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

10.3.1 饥饿

当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿(Starvation)”。

引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无限循环,或无限制等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。

在Thread API定义的线程优先级只是作为线程调度的参考。在Thread API中定义了10个优先级,**JVM根据需要将它们映射到操作系统的调度优先级,这种映射时与特定平台(不同的操作系统)相关的。**在某些操作系统中,如果优先级的数量少于10个,那么有多个Java优先级会被映射到同一个优先级。

要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级。

10.3.2 糟糕的响应性

不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或者正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。

10.3.3 活锁

活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理其在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回先沟通的结果(有时候也被称为毒药消息,Poison Message)。

虽然处理信息的线程没有阻塞,但也无法继续执行下去。这种形式的活锁通常时由过度的错误恢复代码造成的,因为它错误将不可修复的错误作为可修复的错误。

当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。

要解决这种活锁问题,需要在重试机制中引入随机性。

在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值