在安全性和活跃性之间通常存在某种制衡。使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制地行为可能会导致资源死锁。Java应用程序无法从死锁中,恢复过来,因此在设计时一定要排除那些可能导致死锁出现的条件。
8.1 死锁
当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M时,线程B持有锁M想获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁,其中多个线程存在环路的锁依赖关系而永远地等待下去。
8.1.1 锁顺序死锁
8.1.2 动态的锁顺序死锁
有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生,如两个账户互相转账。
8.1.3 在协作对象之间发生死锁
如果在持有锁的情况下调用外部方法,那么就需要警惕死锁。
8.1.4 开放调用
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易地多。同理,分析一个完全依赖于开发调用的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能地使用开放调用,将更需要于找出那些需要获得多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁。
8.1.5 资源死锁
当它们在相同的资源集合上等待时,也会发生死锁。
8.2 死锁的避免与诊断
在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先,找出在什么地方将获取多个锁,然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用,这能极大简化分析过程。如果所有的调用都是开发调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化源码分析工具。
8.2.1 支持定时的锁
还有一项技术可以检测死锁并从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么久可以在发生某个意外情况后重新获得控制权。
8.2.2 通过线程转储信息来分析死锁
虽然防止死锁的主要责任在自己,但JVM仍然通过线程转储来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息。线程转储还包含加锁信息。在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息。
8.3 其他活跃性危险
8.3.1 饥饿
当线程无法访问它所需要的资源而不能继续执行时,就会发生。引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者持有锁时执行一些无法结束的结构,那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。
8.3.2 糟糕的响应性
如果在GUI应用程序中使用了后台线程,那么这种问题很常见。不良的锁管理也可能导致。
8.3.3 活锁
活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断执行相同的操作,而且总会失败。活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将会回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到错误的处理器时,都会发生事务回滚。由于这条消息又被放到队列开头,因此处理器将被反复调用,并返回相同的结果。虽然处理消息的线程并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码导致,因为它错误地将不可修复的错误作为可修复的错误。