八、避免活跃性危险

在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但 如果讨度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。同样,我们使用 线搿池和饴号貴来限制对资源的使用,但这些被限制的行为可能会导致资源死锁(Resource Deadlocks Tava应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁 出现的条件。

一、死锁

经典的“哲学家进餐”问题很好地描述了死锁状况。5个哲学家去吃中餐,坐在一张圆桌 旁。他们有5根筷子(而不是5双),并且每两个人中间放一根筷子。哲学家们时而思考,时 而进餐。每个人都需要一双筷子才能吃到东西,并在吃完后将筷子放回原处继续思考。有些筷 子管理算法能够使每个人都能相对及时地吃到东西(例如一个饥饿的哲学家会尝试获得两根邻 近的筷子,但如果其中一根正在被另一个哲学家使用,那么他将放弃已经得到的那根筷子,并 等待几分钟之后再次尝试),但有些算法却可能导致一些或者所有哲学家都“饿死”(每个人 都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷 子)。后一种情况将产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的 资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。
当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式(或者称为“抱死[Deadly Embrace]”),其中多个线程由于存在环路的锁依赖关系而永远地等待下去。
当一组Java线程发生死锁时,“游 戏”将到此结束——这些线程永远不能再使用了。根据线程完成工作的不同,可能造成应用程 序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中 止并重启它,并希望不要再发生同样的事情。
与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是在最糟 糕的时候——在高负载情况下。

  • 锁顺序死锁

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

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中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。 如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。 如果每个需要锁L和锁M的线程都以相间的顺序来获取L和M,那么就不会发生死锁了。
如果所线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

  • 动态的锁顺序死锁

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

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的参数顺序,而这些参数顺序又取决于外部输入。如果两个线程同时调transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就会发生死锁:

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

如果执行时序不当,那么A可能获得myAccount的锁并等待yourAccount的锁,然而B此时持有yourAccount的锁,并正在等待myAccount的锁。
由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中 都按照这个顺序来获取锁。
在制定锁的顺序时,可以使用System.identityHashCode方法,该方法将返回由Object.hashCode返回的值。程序清单给出了另一个版本的transferMoney,在该版本中使用了 System.identityHashCode来定义锁的顺序。虽然增加了一些新的代码,但却消除了发生死锁的可能性。

public void transferMoney(final Account fromAcct,
                          final Account toAcct,
                          final DoliarAmount amount)
        throws InsufficientFundsException {

    class Helper {
        public void transfer() throws InsufficientFundsException {
            if (fromAcct.getBalance().compareTo(amount) < 0) {
                throw new InsufficientFundsException();
            } 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 (toAcct) {
            synchronized (fromAcct) {
                new Helper().transfer();
            }
        }
    } else {
        synchronized (tieLock) {
            synchronized (fromAcct) {
                synchronized (toAcct) {
                    new Helper().transfer();
                }
            }
        }
    }
}

在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来 决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛(Tie- Breaking)” 锁。 在获得两个Account锁之前,首先获得这个“加时赛”锁,从而保证每次只有 一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致地使用这种机 制)。如果经常会出现散列冲突的情况,那么这种技术可能会成为并发性的一个瓶颈(这类{以 于在整个程序中只有一个锁的情况),但由于System.identityHashCode中出现散列冲突的频率 非常低,因此这项技术以最小的代价,换来了最大的安全性。
如果在Account中包含一个唯一的、不可变的,并且具备可比性的键值,例如账号,那么 要制定锁的顺序就更加容易了:通过键值对对象进行排序,因而不需要使用“加时赛”锁。

  • 在协作对象之间发生的死锁

某些获取多个锁的操作并不像在LeftRightDeadlock或transferMoney中那么明显,这两个 锁并不一定必须在同一个方法中被获取。考虑程序清单中两个相互协作的类,在出租车调度系统中可能会用到它们。Taxi代表一个出租车对象,包含位置和目的地两个属性,Dispatcher 代表一个出租车车队。

// 注意容易发生死锁
class Taxi {
    private Point location, destination;
    private final 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) {
        availableTaxis.add(taxi);
    }

    public synchronized Image getlmage() {
        Image image = new Image();
        for (Taxi t : taxis){
            image.drawMarker(t.getLocation());
        }
        return image;
    }
}

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

  • 开放调用

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

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

  • 资源死锁

正如当名个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现,当资源池为空时的阻塞行为。如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程A可能持有与数据库D1的连接,并等待与数据库。D2的连接,而线程B则持有与D2的连接并等待与D1的连接。(资源池越大,出现这种情 况的可能性就越小。如果每个资源池都有N个连接,那么在发生死锁时不仅需要N个循环等待 的线程,而且还需要大量不恰当的执行时序。)

另一种基于资源的死锁形式就是线程饥饿死锁(Thread-Starvation Deadlock)。一个示例:一个任务提交另一个任务,并等待被提交任务在单线程的Executor中 执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor 中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。

二、死锁的避免与诊断

如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁。当然,这种情况通常并不现实,但如果能够避免这种情况,那么就能省去很多工作。如果必须获取多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。
在使用细粒度锁的程序中,可以通过使用一种两阶段策略(Two-Part Strategy)来检査代码中的死锁:首先,找出在什么地方将多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保他们在整个程序中获取锁的顺序都是一致。尽可能地使用开放调用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。

  • 支持定时的锁

还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功 能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限(Timeout),在等待超过该时间后tryLock会返回一个失败信息。 如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制权。
当定时锁失败时,你并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在 持有锁时错误地进入了无限循环,还可能是某个操作的执行时间远远超过了你的预期。然而, 至少你能记录所发生的失败,以及关于这次操作的其他有用佶息,并通过一种更平缓的方式来 重新启动计算,而不是关闭整个进程。
即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效地应对死锁问 题。如果在获取锁时超时,那么可以释放这个锁,然后后退并在一段时间后再次尝试,从而消 除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,如果在 嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法释放它。)

  • 通过线程转储信息来分析死锁

虽然防止死锁的主要责任在于你自己,但JVM仍然通过线程转储(Thread Dump)来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及哪些 锁和线程,以及这个锁的获取操作位于程序的哪些位置。
如果使用显式的Lock类而不是内部锁,那么Java 5.0并不支持与Lock相关的转储信息, 在线程转储中不会出现显式的Lock。虽然Java 6中包含对显式Lock的线程转储和死锁检测等 的支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在 的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。

三、其他活跃性危险

在并发程序中还存在一些其他的活跃性危险,包括:饥饿、丢失信号和活锁等。

  • 饥饿

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

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

操作系统的线程调度器会尽力提供公平的、活跃性良好的调度,甚至超出Java语言规 范的需求范围。在大多数Java应用程序中,所有线程都具有相同的优先级Thread.NORM PRIORITY。线程优先级并不是一种直观的机制,而通过修改线程优先级所带来的效果通常也 不明显。当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调 度优先级髙于其他线程,从而导致饥饿。

通常,我们尽量不要改变线程的优先级。只要改变了线程的优先级,程序的行为就将与 平台相关,并且会导致发生饥饿问题的风险。你经常能发现某个程序会在一些奇怪的地方调用 Thread.sleep或Thread.yield,这是因为该程序试图克服优先级调整问题或响应性问题,并试图 让低优先级的线程执行更多的时间。

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

  • 糟糕的响应

除饥饿以外的另一个问题是糟糕的响应性,CPU密集型的后台任务仍然可能对响应性造成影响, 因为它们会与事件线程共同竞争CPU的时钟周期。在这种情况下就可以发挥线程优先级的作 用,此时计算密集型的后台任务将对响应性造成影响。如果由其他线程完成的工作都是后台任 务,那么应该降低它们的优先级,从而提髙前台程序的响应性。
不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或许正在对一 个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程 就必须等待很长时间。

  • 活锁

活锁(Livelock)是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执
行,因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发生在处理事务消息的应 用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放 到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每 当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又 被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消 息,Poison Messaged。)虽然处理消息的线程并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。

当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无 法继续执行时,就发生了活锁。这就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都 让出对方的路,然而又在另一条路上相遇了。因此他们就这样反复地避让下去。

要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器 尝试使用相同的载波来发送数据包,那么这些数据包就会发生冲突。这两台机器都检査到了冲 突,并都在稍后再次重发。如果二者都选择了在1秒钟后重试,那么它们又会发生冲突,并且 不断地冲突下去,因而即使有大量闲置的带宽,也无法使数据包发送出去。为了避免这种情况 发生,需要让它们分别等待一段随机的时间。〔以太协议定义了在重复发生冲突时采用指数方 式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险。)在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

猜你喜欢

转载自blog.csdn.net/qq_27870421/article/details/90583273